From 62a8ee362e26212f968e819d6054270e7a215277 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 21:51:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(B05=20=ED=8F=89=EB=A9=B4=20=C2=B7?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EA=B3=B5=EC=9A=A9):=20RouteEdit=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EB=B0=B0=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=EC=9E=AC=EA=B8=B0=20=ED=95=9C=20=EB=B2=8C=20=EB=96=BC=EC=96=B4?= =?UTF-8?q?=EB=83=84=20=C2=B7=20=EA=B8=8B=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=B5=EC=9A=A9=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?(PLAN=20626-3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=2053?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_UI_RouteEdit.ts 684 → 655 줄 — 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 휠 = 새 `_RouteEdit_Mouse.ts` · 재기 도구 + 단추 한 벌 = `_Measure.ts` `createRouteMeasure` (동작 그대로) - 긋기 방식 칸(`createDrawKindFields` · 기본값 · 이름) = 새 `A00_Common/map/map_draw_kind.ts` · `.css` — D03 DrawPanel 은 다시 내보냄(D03 화면 · 동작 같음 · 칸 꼴 선택자만 공용 이름) - D03 `paintPreview` · `planLabel` 받는 지도 꼴을 쓰는 칸만(Pick)으로 — B05 평면이 그대로 부름 - 시험 test_553_4 — 방식 칸 대조를 공용 파일로 옮김(낡은 자리 대조) Co-Authored-By: Claude Opus 5.5 --- A00_Common/map/map_draw_kind.css | 28 ++++ A00_Common/map/map_draw_kind.ts | 146 +++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 55 ++----- .../B05_Profile_UI_RouteEdit_Measure.ts | 16 ++ B05_Profile/B05_Profile_UI_RouteEdit_Mouse.ts | 72 +++++++++ .../D03_PlanAnalysis_UI_DrawPaint.ts | 6 +- .../D03_PlanAnalysis_UI_DrawPanel.ts | 150 ++---------------- .../D03_PlanAnalysis_UI_Style.css | 29 +--- resources/tester/test_553_4_de_parts.py | 12 +- 9 files changed, 300 insertions(+), 214 deletions(-) create mode 100644 A00_Common/map/map_draw_kind.css create mode 100644 A00_Common/map/map_draw_kind.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Mouse.ts diff --git a/A00_Common/map/map_draw_kind.css b/A00_Common/map/map_draw_kind.css new file mode 100644 index 000000000..d2d892999 --- /dev/null +++ b/A00_Common/map/map_draw_kind.css @@ -0,0 +1,28 @@ +/* 공용 긋기 방식 칸(626-3 · `map_draw_kind.ts`) — 고름 아래 길이 · 기울기 두 열 */ +.map-draw-kind { + display: flex; + flex-direction: column; + gap: 6px; +} + +.map-draw-kind__fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); + align-items: start; +} + +.map-draw-kind__fields[hidden] { + display: none; +} + +.map-draw-kind__fields .ui-field { + margin: 0; + min-width: 0; +} + +.map-draw-kind__fields .ui-input-unit, +.map-draw-kind__fields .ui-input { + width: 100%; + min-width: 0; +} diff --git a/A00_Common/map/map_draw_kind.ts b/A00_Common/map/map_draw_kind.ts new file mode 100644 index 000000000..024e2d33c --- /dev/null +++ b/A00_Common/map/map_draw_kind.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * map_draw_kind.ts + * 공용 긋기 방식 칸(PLAN 626-3 · 599-1 · 625-1) — 예정노선(D03) 그리기 칸 · 노드 판 · B05 평면 예정노선 그리기가 같이 씀. + * 꼴 = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정) + 길이 · 기울기 두 열(기울기 아래 ≈ °) · + * 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · 설명 글 없음(요구 463-6). + * 쓰는 법 = `createDrawKindFields({ value, onChange })` → { root, get, set, setDisabled }. + * ========================================================================== */ + +import type { UiSize } from "@ui/ui_template_button"; +import { createInputField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import "./map_draw_kind.css"; + +/** 기울기 % → 각(°) 한 자리 글. */ +const slopeDegText1 = (pct: number): string => ((Math.atan(pct / 100) * 180) / Math.PI).toFixed(1); + +/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ +export const DRAW_DEFAULT_LENGTH_M = 20; +export const DRAW_DEFAULT_SLOPE_PCT = 9; + +export type DrawConstraint = "free" | "length" | "slope" | "both"; + +export interface DrawKindValue { + kind: DrawConstraint; + lengthM: number; + slopePct: number; +} + +export const DRAW_KIND_LABELS: Record = { + free: "자유지정", + both: "길이·기울기 고정", + length: "길이 고정", + slope: "기울기 고정", +}; + +export interface DrawKindFields { + root: HTMLElement; + get: () => DrawKindValue; + /** 값을 바꿈(onChange 는 안 부름). */ + set: (value: Partial) => void; + setDisabled: (disabled: boolean) => void; +} + +/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */ +export function createDrawKindFields(opts: { + value: DrawKindValue; + onChange: (value: DrawKindValue) => void; + size?: UiSize; +}): DrawKindFields { + const size = opts.size ?? 24; + let value = { ...opts.value }; + let disabled = false; + const changed = (): void => opts.onChange({ ...value }); + const segment = createSegment({ + options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({ + value: kind, + label: DRAW_KIND_LABELS[kind], + })), + value: value.kind, + size, + columns: 2, + ariaLabel: "긋기 방식", + onChange: (next) => { + value.kind = next as DrawConstraint; + fieldsState(); + changed(); + }, + }); + segment.root.dataset.role = "draw-constraint"; + const lengthField = createInputField({ + label: "길이", + type: "number", + value: String(value.lengthM), + min: 1, + unit: "m", + size, + onInput: (text) => { + const next = Number(text); + lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); + if (!(next > 0)) return; + value.lengthM = next; + changed(); + }, + }); + lengthField.input.dataset.role = "draw-length"; + const slopeField = createInputField({ + label: "기울기", + type: "number", + value: String(value.slopePct), + unit: "%", + size, + onInput: (text) => { + const next = Number(text); + const ok = text.trim() !== "" && Number.isFinite(next); + slopeField.setError(ok ? undefined : "숫자"); + if (!ok) return; + value.slopePct = next; + degText(); + changed(); + }, + }); + slopeField.input.dataset.role = "draw-slope"; + const slopeDegText = createText("", "muted"); + slopeDegText.dataset.role = "draw-slope-deg"; + const degText = (): void => { + slopeDegText.textContent = `≈ ${slopeDegText1(Math.abs(value.slopePct))}°`; + }; + const slopeColumn = document.createElement("div"); + slopeColumn.append(slopeField.root, slopeDegText); + const fields = document.createElement("div"); + fields.className = "map-draw-kind__fields"; + fields.append(lengthField.root, slopeColumn); + const root = document.createElement("div"); + root.className = "map-draw-kind"; + root.append(segment.root, fields); + + function fieldsState(): void { + for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled; + lengthField.input.disabled = slopeField.input.disabled = disabled; + fields.hidden = value.kind === "free"; + lengthField.root.hidden = value.kind === "slope"; + slopeColumn.hidden = value.kind === "length"; + } + degText(); + fieldsState(); + + return { + root, + get: () => ({ ...value }), + set(next) { + value = { ...value, ...next }; + segment.set(value.kind); + lengthField.input.value = String(value.lengthM); + slopeField.input.value = String(value.slopePct); + lengthField.setError(undefined); + slopeField.setError(undefined); + degText(); + fieldsState(); + }, + setDisabled(next) { + disabled = next; + fieldsState(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 80130828c..43681bd41 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -22,7 +22,6 @@ import { type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { showToast } from "@ui/ui_template_elements"; import { bindMinorContourToggle, createSheetFollow, @@ -41,12 +40,12 @@ import { import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render"; -import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; +import { bindRouteEditMouse } from "./B05_Profile_UI_RouteEdit_Mouse"; import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; -import { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; +import { createRouteMeasure } from "./B05_Profile_UI_RouteEdit_Measure"; import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope"; import { captureView, @@ -191,22 +190,13 @@ export async function mountRouteEditor( // 횡단이 뜨면 그 측점 반폭 선을 그린다 — 새로 고침 뒤 고른 측점은 만드는 도중 불려 아래 선언이 아직이라 한 박자 뒤에(505-4). onShown: () => queueMicrotask(draw), }); - /** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ - const measure = createMeasureTool({ + /** 구간 재기 — 계획·예상 노선 위 두 점 · 재기 단추·작은 창. 셈·서버 묻기 · 배선은 `_Measure` 몫(계획서 0-9 ⑤). */ + const { measure, measureControls } = createRouteMeasure({ projectId, stationIntervalM, lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }), toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, - onChange: () => { - measureControls.sync(); - draw(); - }, - onHint: () => measureControls.sync(), - }); - /** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */ - const measureControls = bindMeasureControls({ - measure, box: measureBox, text: measureText, button: measureButton, @@ -547,37 +537,18 @@ export async function mountRouteEditor( offStation, }); - bindNodeSplice({ - canvas, - unrotate: rotation.unrotate, - segmentAt: pointer.segmentAt, - nodeAt: pointer.nodeAt, - insert: (segment, px, py) => { - edits.spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. - picked = segment + 1; - applyEdit("새 노드를 넣었습니다(직선 추가)."); - }, - remove: (index) => { - if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); - if (apexLock[index]) - return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error"); - edits.spliceNode(snapshotNow(), index); - picked = -1; - applyEdit("노드를 지웠습니다(직선 삭제)."); - }, - }); - - bindRouteEditNavigation({ + // 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 확대 · 돌리기 — 배선은 `_Mouse` 몫. + bindRouteEditMouse({ canvas, + rotation, + pointer, + toMetric, getView: () => view, - setView: (next) => { - view = next; - }, + setView: (next) => (view = next), getMeta: () => meta, - // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. - unrotateDelta: rotation.unrotateDelta, - rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3) - claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4) + snapshot: snapshotNow, + setPicked: (index) => (picked = index), + applyEdit: (message) => applyEdit(message), draw, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 2e4a90118..995a068bb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -183,6 +183,22 @@ export function bindMeasureControls(params: MeasureControlsParams): { return { sync, mode: () => mode }; } +/** 재기 도구 + 단추 · 작은 창 한 벌(626-3 · `_RouteEdit` 에서 뗌) — 상태 바뀜 = 창 맞춤 · 다시 그림 · 미리보기 = 창 글만. */ +export function createRouteMeasure( + params: Omit & Omit, +): { measure: MeasureTool; measureControls: ReturnType } { + const measure = createMeasureTool({ + ...params, + onChange: () => { + measureControls.sync(); + params.redraw(); + }, + onHint: () => measureControls.sync(), + }); + const measureControls = bindMeasureControls({ ...params, measure }); + return { measure, measureControls }; +} + export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Mouse.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Mouse.ts new file mode 100644 index 000000000..386837255 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Mouse.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Mouse.ts + * 계획노선 편집(평면) 마우스 배선 — 선 두 번 클릭 = 노드 넣기 · 노드 오른쪽 클릭 = 지움 · 가운데(휠) 버튼 끌기 = + * 지도 이동 · 휠 = 확대 · Ctrl+휠 = 돌리기. `B05_Profile_UI_RouteEdit.ts` 에서 뗌(626-3 · 동작 그대로). + * 노드 끌기 · 집기는 `_Pointer` 몫 · 집기 셈 · 듣기는 `_Input` 몫. + * ========================================================================== */ + +import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { showToast } from "@ui/ui_template_elements"; +import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; +import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits"; + +type Vertex = [number, number]; + +export interface RouteEditMouseParams { + canvas: HTMLCanvasElement; + rotation: Pick; + pointer: { + segmentAt: (px: number, py: number) => number; + nodeAt: (px: number, py: number) => number; + claimed: () => boolean; + }; + toMetric: (px: number, py: number) => Vertex; + getView: () => ViewState; + setView: (next: ViewState) => void; + getMeta: () => VWorldMeta | null; + /** 지금 편집값(같은 배열 — 넣고 빼면 그대로 바뀜). */ + snapshot: () => RouteEditSnapshot; + setPicked: (index: number) => void; + applyEdit: (message: string) => void; + draw: () => void; +} + +export function bindRouteEditMouse(params: RouteEditMouseParams): void { + const { canvas, rotation, pointer } = params; + bindNodeSplice({ + canvas, + unrotate: rotation.unrotate, + segmentAt: pointer.segmentAt, + nodeAt: pointer.nodeAt, + insert: (segment, px, py) => { + edits.spliceNode(params.snapshot(), segment + 1, params.toMetric(px, py)); // 편집값도 같은 자리에 끼운다. + params.setPicked(segment + 1); + params.applyEdit("새 노드를 넣었습니다(직선 추가)."); + }, + remove: (index) => { + const snapshot = params.snapshot(); + if (snapshot.planned.length <= 2) + return showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + if (snapshot.apexLock[index]) + return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error"); + edits.spliceNode(snapshot, index); + params.setPicked(-1); + params.applyEdit("노드를 지웠습니다(직선 삭제)."); + }, + }); + + bindRouteEditNavigation({ + canvas, + getView: params.getView, + setView: params.setView, + getMeta: params.getMeta, + // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. + unrotateDelta: rotation.unrotateDelta, + rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3) + claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4) + draw: params.draw, + }); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index e75e1096f..c584139bd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -5,7 +5,7 @@ * 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리. * 길이·기울기 고정 부채꼴(625-2) — 정방향 = 채움 · 역방향 = 테두리 · 노드 · 놓인 꺾임점 · 미리보기 고리 = * 기울기 부호 색(+ 오르막 `--draw-map-slope-up` 따뜻한 색 · − 내리막 `--draw-map-slope-down` 찬 색 · 막힘 = 경고 빨강). - * `paintFan` = ScreenMap 의 `toScreen` 만 씀 — B05 평면(626-3)이 그대로 씀. + * `paintFan` · `paintPreview` = ScreenMap 의 `toScreen` · `pxPerMeter` · `upright` 만 씀 — B05 평면(626-3)이 그대로 씀. * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ @@ -330,7 +330,7 @@ export function paintMark( * 고리 = 고른 점 기울기 부호 색(놓인 뒤 꺾임점과 같은 색). */ export function paintPreview( context: CanvasRenderingContext2D, - map: ScreenMap, + map: Pick, from: Vertex, preview: DrawPreview, compass: { lengthM: number; fan: Fan } | null, @@ -368,7 +368,7 @@ export function paintPreview( export function planLabel( context: CanvasRenderingContext2D, - map: ScreenMap, + map: Pick, x: number, y: number, text: string, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 20c2de8cc..5a8ed38ef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -2,150 +2,26 @@ * D03_PlanAnalysis_UI_DrawPanel.ts * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸. * - * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · - * 625-1) + 길이 · 기울기 두 열(기울기 아래 ≈ °) — 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · - * 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. + * 방식 칸 `createDrawKindFields` = 공용 `A00_Common/map/map_draw_kind`(626-3) — 그리기 칸 · 노드 판(598-4) · + * B05 평면 예정노선 그리기가 같은 부품을 씀(여기서 다시 내보냄). * 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). * 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만. * ========================================================================== */ -import type { UiSize } from "@ui/ui_template_button"; import { createButton } from "@ui/ui_template_elements"; -import { createInputField } from "@ui/ui_template_input"; -import { createSegment } from "@ui/ui_template_segment"; -import { createText } from "@ui/ui_template_text"; -import { fmt, slopeDeg } from "./D03_PlanAnalysis_UI_DrawPaint"; import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map"; +import type { DrawKindFields } from "../A00_Common/map/map_draw_kind"; -/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ -export const DRAW_DEFAULT_LENGTH_M = 20; -export const DRAW_DEFAULT_SLOPE_PCT = 9; - -export type DrawConstraint = "free" | "length" | "slope" | "both"; - -export interface DrawKindValue { - kind: DrawConstraint; - lengthM: number; - slopePct: number; -} - -export const DRAW_KIND_LABELS: Record = { - free: "자유지정", - both: "길이·기울기 고정", - length: "길이 고정", - slope: "기울기 고정", -}; - -export interface DrawKindFields { - root: HTMLElement; - get: () => DrawKindValue; - /** 값을 바꿈(onChange 는 안 부름). */ - set: (value: Partial) => void; - setDisabled: (disabled: boolean) => void; -} - -/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */ -export function createDrawKindFields(opts: { - value: DrawKindValue; - onChange: (value: DrawKindValue) => void; - size?: UiSize; -}): DrawKindFields { - const size = opts.size ?? 24; - let value = { ...opts.value }; - let disabled = false; - const changed = (): void => opts.onChange({ ...value }); - const segment = createSegment({ - options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({ - value: kind, - label: DRAW_KIND_LABELS[kind], - })), - value: value.kind, - size, - columns: 2, - ariaLabel: "긋기 방식", - onChange: (next) => { - value.kind = next as DrawConstraint; - fieldsState(); - changed(); - }, - }); - segment.root.dataset.role = "draw-constraint"; - const lengthField = createInputField({ - label: "길이", - type: "number", - value: String(value.lengthM), - min: 1, - unit: "m", - size, - onInput: (text) => { - const next = Number(text); - lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); - if (!(next > 0)) return; - value.lengthM = next; - changed(); - }, - }); - lengthField.input.dataset.role = "draw-length"; - const slopeField = createInputField({ - label: "기울기", - type: "number", - value: String(value.slopePct), - unit: "%", - size, - onInput: (text) => { - const next = Number(text); - const ok = text.trim() !== "" && Number.isFinite(next); - slopeField.setError(ok ? undefined : "숫자"); - if (!ok) return; - value.slopePct = next; - degText(); - changed(); - }, - }); - slopeField.input.dataset.role = "draw-slope"; - const slopeDegText = createText("", "muted"); - slopeDegText.dataset.role = "draw-slope-deg"; - const degText = (): void => { - slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(value.slopePct)))}°`; - }; - const slopeColumn = document.createElement("div"); - slopeColumn.append(slopeField.root, slopeDegText); - const fields = document.createElement("div"); - fields.className = "d03-plan__draw-fields"; - fields.append(lengthField.root, slopeColumn); - const root = document.createElement("div"); - root.className = "d03-plan__draw-kind"; - root.append(segment.root, fields); - - function fieldsState(): void { - for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled; - lengthField.input.disabled = slopeField.input.disabled = disabled; - fields.hidden = value.kind === "free"; - lengthField.root.hidden = value.kind === "slope"; - slopeColumn.hidden = value.kind === "length"; - } - degText(); - fieldsState(); - - return { - root, - get: () => ({ ...value }), - set(next) { - value = { ...value, ...next }; - segment.set(value.kind); - lengthField.input.value = String(value.lengthM); - slopeField.input.value = String(value.slopePct); - lengthField.setError(undefined); - slopeField.setError(undefined); - degText(); - fieldsState(); - }, - setDisabled(next) { - disabled = next; - fieldsState(); - }, - }; -} +// 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀) +export { + createDrawKindFields, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + DRAW_KIND_LABELS, + type DrawConstraint, + type DrawKindFields, + type DrawKindValue, +} from "../A00_Common/map/map_draw_kind"; export interface DrawPanel { root: HTMLElement; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 0ab8d4118..c08519723 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -185,34 +185,7 @@ margin: 0; } -/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */ -.d03-plan__draw-kind { - display: flex; - flex-direction: column; - gap: 6px; -} - -.d03-plan__draw-fields { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-8); - align-items: start; -} - -.d03-plan__draw-fields[hidden] { - display: none; -} - -.d03-plan__draw-fields .ui-field { - margin: 0; - min-width: 0; -} - -.d03-plan__draw-fields .ui-input-unit, -.d03-plan__draw-fields .ui-input { - width: 100%; - min-width: 0; -} +/* 599 그리기 칸 방식 칸 = 공용 `A00_Common/map/map_draw_kind.css`(626-3) */ /* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */ .d03-plan__line-tail { diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index be0296afe..92f3777ee 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -98,10 +98,14 @@ def test_d03_parts(): ], keys assert ".d03-plan__layers" in css and ".d03-plan__legend" in css # 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수 - assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2 - assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel - assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw - assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel + # 626-3 — 방식 칸은 공용 `A00_Common/map/map_draw_kind`(B05 평면도 씀) · D03 칸은 다시 내보냄 + kind = _src("A00_Common/map/map_draw_kind.ts") + kind_css = _src("A00_Common/map/map_draw_kind.css") + assert "createSegment({" in kind and kind.count("createInputField(") == 2 + assert "columns: 2" in kind and "export function createDrawKindFields(" in kind + assert "createDrawKindFields," in draw_panel and 'from "../A00_Common/map/map_draw_kind"' in draw_panel + assert ".map-draw-kind__fields" in kind_css and "createDrawKindFields(" in draw + assert '"길이·기울기 고정"' in kind and '"둘 다"' not in kind assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6) # 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌 assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools From 84e9151b7c5f13db762ea6b67b2b710658611dc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 21:58:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=EB=A1=9C=20=EA=B8=8B=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EB=8B=A4=EC=8B=9C=20=EA=B8=8B=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=9B=80=20=E2=86=92=20=ED=8F=89=EB=A9=B4?= =?UTF-8?q?=EC=84=A0=20=EB=8B=A4=EC=8B=9C=20=EC=B6=94=EC=A0=81=20=3D=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=20=ED=95=9C=20=EA=B1=B8=EC=9D=8C=20?= =?UTF-8?q?=E2=86=92=20[=EC=A0=80=EC=9E=A5]=20(PLAN=20626-3=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20531=20=C2=B7=20D3=20=C2=B7?= =?UTF-8?q?=20D4=20=C2=B7=20D9=20=C2=B7=20D10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구줄 「예정노선 그리기」 칸(공용 표 b05 · 연필 아이콘) → 떠 있는 판(단추 셋 + 공용 방식 칸) · 고치는 예정노선 맨 위 · 판 켠 동안 캔버스 누름을 먼저 받음(계획선 · 구조물 · 등고선 고르기 · Shift 재기 · 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠 그대로) - 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab · Enter · Esc · Backspace(셈 `logic/B05_Profile_ExpectedDraw` · 그림 D03 `paintPreview` 그대로) - 끝 → 확인창(직접 고친 평면 버림) → `POST /route/follow`(저장 0) → `restoreSnapshot(…, true)` 한 걸음(되돌리기 됨) · 지움 = 파일입력 예정노선으로 다시 추적 - 걸음 사진 · 노선 초안에 `expected`(같은 선 판정 · 세션 되살림 · 페이지 되돌리기) · [저장] = 손 편집과 같은 길 + 초안 `expected`(null 안 실음 · [] 지움) · 다시 열면 `expected_edited` - `/route/replan` · `as_initial` · `expected_route.csv` · 초기값 · 추천 저장본 안 건드림 · 시험 test_626_3_expected_edit(7) Co-Authored-By: Claude Opus 5.5 --- .../design/logic/design_pipeline_types.ts | 2 + A00_Common/map/map_toolbar.ts | 7 + B05_Profile/B05_Profile_Api_Replan.ts | 2 + B05_Profile/B05_Profile_UI_ExpectedDraw.ts | 442 ++++++++++++++++++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 35 +- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 5 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 5 +- B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts | 4 + .../B05_Profile_UI_RouteEdit_History.ts | 9 +- .../B05_Profile_UI_RouteEdit_Render.ts | 3 + .../B05_Profile_UI_Style_RouteEdit.css | 18 + B05_Profile/logic/B05_Profile_ExpectedDraw.ts | 116 +++++ resources/tester/test_626_3_expected_edit.py | 169 +++++++ ui_template/ui_template_map_tools.ts | 6 + 14 files changed, 817 insertions(+), 6 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_ExpectedDraw.ts create mode 100644 B05_Profile/logic/B05_Profile_ExpectedDraw.ts create mode 100644 resources/tester/test_626_3_expected_edit.py diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index 1c1e6cc14..6f03a4899 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -76,6 +76,8 @@ export interface RouteDraft { stationSnapStep?: number | null; /** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */ terrain: string | null; + /** 고친 예정노선(626-3 · B05 평면 그리기) — 없음 · null = 그대로 · [] = 지움 · 점 묶음 = [저장] 때 `expected_route_edit.csv`. */ + expected?: Vertex[] | null; } /** 묶음이 받는 입력 다섯. 없으면 null(= 저장분 그대로). cross · pipes · structures 모양은 주인 몫. */ diff --git a/A00_Common/map/map_toolbar.ts b/A00_Common/map/map_toolbar.ts index dbcbb2887..5e432337f 100644 --- a/A00_Common/map/map_toolbar.ts +++ b/A00_Common/map/map_toolbar.ts @@ -45,6 +45,13 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [ { key: "station-snap", does: "곡선 가운데(MC)를 간격 배수에 맞춤", pages: B05 }, { key: "snap-step", name: "측점 스냅 간격", does: "스냅 간격 고르기", pages: B05 }, ], + [ + { + key: "expected-draw", + does: "예정노선 그리기 판 켬끔 — 고친 예정노선 따라 평면선 다시 추적(626-3)", + pages: B05, + }, + ], [ { key: "recommend", diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 2e4f384ee..f4cd8997e 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -73,6 +73,8 @@ export interface RoutePlanResponse { project_id: string; /** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */ expected: Array<[number, number]>; + /** 고친 예정노선(626-2 · [저장] 때 `expected_route_edit.csv`) — 없으면 빈 목록 · 원본 `expected` 는 그대로. */ + expected_edited?: Array<[number, number]>; /** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */ planned: Array<[number, number]>; /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts new file mode 100644 index 000000000..4463a59fb --- /dev/null +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -0,0 +1,442 @@ +/* ============================================================================= + * B05_Profile_UI_ExpectedDraw.ts + * B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸. + * 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 · + * 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) · + * 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로. + * 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 · + * Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 · + * Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로. + * 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) → + * `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`. + * 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림). + * 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로. + * ========================================================================== */ + +import { keyIs } from "../A00_Common/key_registry"; +import { listenWhileAlive } from "@ui/ui_template_window_watch"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; +import { askConfirm } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; +import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; +import { + createDrawKindFields, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + type DrawConstraint, +} from "../A00_Common/map/map_draw_kind"; +import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw"; +import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan"; +import { + paintPreview, + slopeColor, + strokeCased, +} from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint"; +import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw"; +import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; +import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; +import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; +import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import { + leadBefore, + nearestOnLine, + pickNext, + respliceLine, +} from "./logic/B05_Profile_ExpectedDraw"; + +type Vertex = [number, number]; + +/** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */ +const SNAP_PX = 12; +const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다."; +const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기"; + +/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ +type FollowResponse = Pick; + +export interface ExpectedDrawParams { + projectId: string; + canvas: HTMLCanvasElement; + /** 도구줄 칸 · 판을 얹을 도구 줄 칸. */ + button: HTMLButtonElement; + host: HTMLElement; + toScreen: (vertex: Vertex) => [number, number]; + toMetric: (px: number, py: number) => Vertex; + /** 보이는 화면 px → 그리기 px(돌린 지도). */ + unrotate: (px: number, py: number) => [number, number]; + pxPerMeter: () => number; + uprightRad: () => number; + redraw: () => void; + /** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */ + onOpen: () => void; + /** 다시 추적한 평면 · 고친 예정노선([] = 지움) — 편집기 한 걸음. */ + onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void; +} + +export interface ExpectedDraw { + /** 노선을 읽음 — 파일입력 예정노선 · 저장본(없으면 빈). */ + load: (original: Vertex[], saved: Vertex[]) => void; + /** 이번 고침(걸음 사진 칸) — null = 연 때 그대로. */ + edit: () => Vertex[] | null; + setEdit: (edit: Vertex[] | null) => void; + /** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */ + line: () => Vertex[]; + paint: (context: CanvasRenderingContext2D) => void; + /** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */ + state: () => { + open: boolean; + stroke: string | null; + points: number; + preview: DrawPreview | null; + }; +} + +type Stroke = + | { mode: "new"; points: Vertex[] } + | { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] }; + +export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { + const { canvas } = params; + let original: Vertex[] = []; + let saved: Vertex[] = []; + let edit: Vertex[] | null = null; + let open = false; + let busy = false; + let stroke: Stroke | null = null; + let preview: DrawPreview | null = null; + let fan: Fan | null = null; + let hoverAt: { px: number; py: number; shift: boolean } | null = null; + let grid: DesignTerrainGrid | null = null; + let kind: DrawConstraint = "both"; // 기억 안 함(B9) + let lengthM = DRAW_DEFAULT_LENGTH_M; + let slopePct = DRAW_DEFAULT_SLOPE_PCT; + + const line = (): Vertex[] => + edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original; + const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN); + + // ── 판 ───────────────────────────────────────────────── + const panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) }); + panel.root.classList.add("b05-routeedit__expected-draw"); + panel.root.hidden = true; + const hint = createText(IDLE_HINT, "muted"); + const kindFields = createDrawKindFields({ + value: { kind, lengthM, slopePct }, + onChange: (value) => { + ({ kind, lengthM, slopePct } = value); + rehover(); + }, + }); + const row = document.createElement("div"); + row.className = "b05-routeedit__expected-row"; + row.append( + createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }), + createButton({ + label: "구간 다시 긋기", + variant: "ghost", + size: 28, + onClick: () => begin("respan"), + }), + createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }), + ); + panel.body.append(row, kindFields.root, hint); + params.host.append(panel.root); + params.button.addEventListener("click", () => setOpen(!open)); + + function setOpen(next: boolean): void { + open = next; + stroke = null; + preview = fan = null; + params.button.setAttribute("aria-pressed", String(open)); + params.button.classList.toggle("is-active", open); + panel.root.hidden = !open; + hint.textContent = IDLE_HINT; + canvas.style.removeProperty("cursor"); + if (open) { + params.onOpen(); + void loadDesignTerrainGrid(params.projectId).then((loaded) => (grid = loaded)); + } + params.redraw(); + } + + function begin(mode: Stroke["mode"]): void { + if (busy) return; + if (mode === "respan" && line().length < 2) + return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info"); + stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] }; + preview = fan = null; + hint.textContent = + (mode === "new" + ? "첫 점을 누릅니다 · " + : "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT; + canvas.style.cursor = "crosshair"; + params.redraw(); + } + + function stop(message = IDLE_HINT): void { + stroke = null; + preview = fan = null; + hint.textContent = message; + canvas.style.removeProperty("cursor"); + params.redraw(); + } + + // ── 셈 ────────────────────────────────────────────────── + /** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */ + function onLine(px: number, py: number): { atM: number; point: Vertex } | null { + const shown = line(); + if (shown.length < 2) return null; + const hit = nearestOnLine(shown, params.toMetric(px, py)); + const [sx, sy] = params.toScreen(hit.point); + return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null; + } + + /** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */ + function strokePoints(): Vertex[] { + if (!stroke) return []; + if (stroke.mode === "new") return stroke.points; + return stroke.start ? [stroke.start.point, ...stroke.through] : []; + } + + function previewAt(px: number, py: number, shift: boolean): DrawPreview | null { + const points = strokePoints(); + const from = points[points.length - 1]; + if (!stroke || !from) return null; + const cursor = params.toMetric(px, py); + const prev = + points.length > 1 + ? points[points.length - 2] + : stroke.mode === "respan" && stroke.start + ? leadBefore(line(), stroke.start.atM) + : null; + const end = stroke.mode === "respan" ? onLine(px, py) : null; + const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift); + fan = pick.fan; + const point = end ? end.point : pick.point; + const at = point ?? cursor; + const length = segmentLength(from, at); + return { + point: at, + lengthM: length, + slopePct: groundSlopePct(from, at, z), + note: end ? "예정노선에 붙음 — 끝" : pick.note, + blocked: !point || length <= 0, + snapped: !!end, + lengthOnly: pick.lengthOnly && !end, + onLine: null, + }; + } + + function rehover(): void { + if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift); + params.redraw(); + } + + function press(px: number, py: number, shift: boolean): void { + if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info")); + if (stroke.mode === "new" && !stroke.points.length) { + stroke.points.push(params.toMetric(px, py)); + return rehover(); + } + if (stroke.mode === "respan" && !stroke.start) { + stroke.start = onLine(px, py); + if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info"); + return rehover(); + } + preview = previewAt(px, py, shift); + if (!preview || preview.blocked) + return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); + if (stroke.mode === "respan" && preview.snapped) { + const end = onLine(px, py)!; + const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through); + return void follow(next); + } + if (stroke.mode === "new") stroke.points.push(preview.point); + else stroke.through.push(preview.point); + rehover(); + } + + function finish(): void { + if (!stroke) return; + if (stroke.mode === "respan") + return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다."); + if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info"); + void follow([...stroke.points]); + } + + /** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */ + async function follow(next: Vertex[]): Promise { + stop(); + const ok = await askConfirm({ + title: "평면선 다시 추적", + message: + (next.length ? "고친 예정노선" : "파일입력 예정노선") + + "을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + + "[저장] 전까지는 되돌리기로 물릴 수 있습니다.", + okLabel: "다시 추적", + }); + if (!ok) return; + busy = true; + try { + const points = next.length ? next : original; + const plan = await requestJson( + `/projects/${params.projectId}/route/follow`, + { method: "POST", body: JSON.stringify({ points, crs: "tm" }) }, + 60000, + ); + params.onFollow( + flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits), + next, + ); + } catch (error) { + showToast( + `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, + "error", + ); + } finally { + busy = false; + } + } + + async function clear(): Promise { + if (busy) return; + if (line() === original) return showToast("고친 예정노선이 없습니다.", "info"); + await follow([]); + } + + // ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ── + const at = (event: MouseEvent): [number, number] => { + const rect = canvas.getBoundingClientRect(); + return params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + }; + const claim = (event: Event): void => { + event.stopImmediatePropagation(); + event.preventDefault(); + }; + canvas.addEventListener( + "pointerdown", + (event) => { + if (!open || event.button !== 0) return; + claim(event); + press(...at(event), event.shiftKey); + }, + true, + ); + canvas.addEventListener( + "pointermove", + (event) => { + if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로 + event.stopImmediatePropagation(); + const [px, py] = at(event); + hoverAt = { px, py, shift: event.shiftKey }; + if (stroke) rehover(); + }, + true, + ); + for (const type of ["pointerup", "click", "dblclick", "contextmenu"]) + canvas.addEventListener( + type, + (event) => { + if (open && (event as MouseEvent).button !== 1) claim(event); + }, + true, + ); + canvas.addEventListener("pointerleave", () => { + hoverAt = null; + if (preview) ((preview = null), params.redraw()); + }); + + const onKey = (event: KeyboardEvent): void => { + if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return; + if (!stroke) return; + if (event.key === "Shift") { + if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover()); + return; + } + if (event.type !== "keydown") return; + if (keyIs(event, "plan.drawKind")) { + kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1) + kindFields.set({ kind }); + rehover(); + } else if (keyIs(event, "plan.drawFinish")) finish(); + else if (event.key === "Escape") stop(); + else if (keyIs(event, "plan.drawBack")) { + const points = stroke.mode === "new" ? stroke.points : stroke.through; + if (!points.pop() && stroke.mode === "respan") stroke.start = null; + rehover(); + } else return; + claim(event); + }; + listenWhileAlive(window, "keydown", canvas, onKey, true); + listenWhileAlive(window, "keyup", canvas, onKey, true); + + // ── 그림 ──────────────────────────────────────────────── + const map = { + toScreen: params.toScreen, + pxPerMeter: 1, + upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void { + const radians = params.uprightRad(); + if (!radians) return paint(); + context.save(); + context.translate(x, y); + context.rotate(radians); + context.translate(-x, -y); + paint(); + context.restore(); + }, + }; + const path = (context: CanvasRenderingContext2D, points: ReadonlyArray): void => { + context.beginPath(); + points.forEach((point, i) => { + const [sx, sy] = params.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + }; + + function paint(context: CanvasRenderingContext2D): void { + if (!open) return; + const colors = planColors(); + map.pxPerMeter = params.pxPerMeter(); + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + path(context, line()); // 고치는 예정노선 = 맨 위(D9) + strokeCased(context, colors.measure, 2.5, [8, 5]); + const points = strokePoints(); + if (points.length > 1) { + path(context, points); + strokeCased(context, colors.active, 3); + } + points.forEach((point, i) => { + const [sx, sy] = params.toScreen(point); + context.beginPath(); + context.arc(sx, sy, 3.5, 0, Math.PI * 2); + context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active; + context.fill(); + }); + const from = points[points.length - 1]; + if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null); + context.restore(); + } + + return { + load(nextOriginal, nextSaved) { + original = nextOriginal; + saved = nextSaved; + }, + edit: () => edit, + setEdit(next) { + edit = next; + }, + line, + paint, + state: () => ({ + open, + stroke: stroke?.mode ?? null, + points: strokePoints().length, + preview, + }), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 43681bd41..245e6fe86 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -45,6 +45,7 @@ import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; +import { createExpectedDraw } from "./B05_Profile_UI_ExpectedDraw"; import { createRouteMeasure } from "./B05_Profile_UI_RouteEdit_Measure"; import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope"; import { @@ -117,6 +118,26 @@ export async function mountRouteEditor( const { minorContourButton, stationSnapButton } = chrome; chrome.onRedraw(() => draw()); // 겹 켬끔 · 추천 보기 = 장면 새로(624-3) const context = canvas.getContext("2d")!; + /** 예정노선 그리기(626-3) — 고친 예정노선 따라 평면 다시 추적 = 편집기 한 걸음 · 배선은 `_ExpectedDraw` 몫. */ + const expectedDraw = createExpectedDraw({ + projectId, + canvas, + button: chrome.expectedDrawButton, + host: overlay.querySelector(".b05-routeedit__tools")!, + toScreen: (vertex) => toScreen(vertex), + toMetric: (px, py) => toMetric(px, py), + unrotate: (px, py) => rotation.unrotate(px, py), + pxPerMeter: () => pxPerMeter(), + uprightRad: () => rotation.uprightRad(), + redraw: () => draw(), + onOpen: () => ((picked = -1), syncCurveBar()), + onFollow: (flat, line) => + restoreSnapshot( + { ...flat, apexLock: [], picked: -1, rec: initial.tag(), expected: line }, + "예정노선을 따라 평면을 다시 추적했습니다.", + true, + ), + }); let expected: Vertex[] = []; /** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */ @@ -194,7 +215,10 @@ export async function mountRouteEditor( const { measure, measureControls } = createRouteMeasure({ projectId, stationIntervalM, - lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }), + lines: () => ({ + planned: plannedLine.length ? plannedLine : planned, + expected: expectedDraw.line(), + }), toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, box: measureBox, @@ -322,7 +346,8 @@ export async function mountRouteEditor( }), measure: measure.marks(), crossWidth: crossPreview.watched(), - expected, + expected: expectedDraw.line(), + overAll: (ctx) => expectedDraw.paint(ctx), // 예정노선 그리기 맨 위(626-3) plannedLine, planned, nodeInfo, @@ -357,7 +382,7 @@ export async function mountRouteEditor( /** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */ const routeHead = (): string => - routeHeadText(expected, plannedLine.length ? plannedLine : planned, planned.length); + routeHeadText(expectedDraw.line(), plannedLine.length ? plannedLine : planned, planned.length); const contourHint = (): string => contourHintText(contours, pickedContour); const offStation = (): number => @@ -458,6 +483,7 @@ export async function mountRouteEditor( apexLock, picked, rec: initial.tag(), + expected: expectedDraw.edit(), }; } @@ -466,6 +492,7 @@ export async function mountRouteEditor( // 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로. const locks = record ? routeLocksOf(planned, apexLock) : null; ({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot); + expectedDraw.setEdit(snapshot.expected ?? null); // 고친 예정노선도 그 걸음 것(626-3) if (locks) apexLock = lockColumn(planned, locks); applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3) } @@ -557,6 +584,7 @@ export async function mountRouteEditor( nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }), changed: () => (!!history && lineChanged(history)) || otherChanged(), snap: snap.draft, + expected: () => expectedDraw.edit(), initial, onApplied, }); @@ -576,6 +604,7 @@ export async function mountRouteEditor( ]); if (closed) return; expected = plan.expected as Vertex[]; + expectedDraw.load(expected, (plan.expected_edited ?? []) as Vertex[]); // 고친 예정노선 저장본(626-3) plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]); // 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다 // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 9f620e188..b9d2fc30d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -56,6 +56,8 @@ export interface RouteApplyParams { changed: () => boolean; /** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */ snap: () => SnapSetting; + /** 고친 예정노선(626-3) — null = 그대로(안 실음) · [] = 지움 · 점 묶음 = `expected_route_edit.csv`. */ + expected?: () => Vertex[] | null; /** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; @@ -98,8 +100,9 @@ export function bindRouteApply(params: RouteApplyParams): () => void { pending: async () => { const nodes = params.nodes(); if (nodes.planned.length < 2 || !params.changed()) return null; + const expected = params.expected?.(); return { - draft: routeDraftOf(nodes, settings()), + draft: { ...routeDraftOf(nodes, settings()), ...(expected ? { expected } : {}) }, initial: params.initial.current(), // 손으로 고친 노선 = 계획고 옮김만(344-2 · 349-0v) — 균형 종단은 서버 추천 [적용](`as_initial`) 길만. }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index b60a03db5..c96f2dd7a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -21,7 +21,7 @@ import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoL * (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위). * 도구줄 차례 = 공용 표(624-3 · `A00_Common/map/map_toolbar` 「b05」) — [?] | ↶ ↷ | 3D ⟲ ⟳ | 1m 등고선 · 측점 · 기울기 · - * 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` · + * 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 그리기(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` · * 끝 = 그래픽 카드 단추) · 범례 = 상태 글판 위. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · @@ -64,6 +64,8 @@ export interface RouteEditChrome { minorContourButton: HTMLButtonElement; /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ stationSnapButton: HTMLButtonElement; + /** 예정노선 그리기 켬끔(626-3 · 배선 = `B05_Profile_UI_ExpectedDraw`). */ + expectedDrawButton: HTMLButtonElement; /** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */ terrain: RouteTerrain; /** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */ @@ -288,6 +290,7 @@ export function createRouteEditChrome( measureButton, minorContourButton, stationSnapButton, + expectedDrawButton: mapTools.button("expected-draw")!, terrain, preview, layers, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts index 32b8e7bef..187306e6b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Draft.ts @@ -42,6 +42,9 @@ export function draftOfSnapshot(snapshot: RouteEditSnapshot, snap: SnapSetting): curveLock: [...snapshot.curveLock], curveArc: [...snapshot.curveArc], ...snap, + ...(snapshot.expected + ? { expected: snapshot.expected.map(([x, y]): [number, number] => [x, y]) } + : {}), }; } @@ -55,6 +58,7 @@ export function snapshotOfDraft(draft: RouteDraft): RouteEditSnapshot { curveArc: [...draft.curveArc], apexLock: draft.nodes.map(() => false), picked: -1, + expected: draft.expected ?? null, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index 83ea25628..b2ad1504b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -30,6 +30,8 @@ export interface RouteEditSnapshot { /** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */ apexLock: boolean[]; picked: number; + /** 고친 예정노선(626-3) — 없음 · null = 연 때 그대로 · [] = 지움(파일입력 예정노선) · 점 묶음 = 새로 그은 선. */ + expected?: Vertex[] | null; /** 이 걸음의 「현재 추천」 — `_Initial` 이 적고 되돌릴 때 되살린다(뜻은 그쪽만 안다). 노선과 짝. */ rec?: unknown; } @@ -68,12 +70,17 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { apexLock: [...snapshot.apexLock], picked: snapshot.picked, rec: snapshot.rec, + expected: snapshot.expected?.map(([x, y]): Vertex => [x, y]) ?? null, }; } +/** 고친 예정노선이 같은가(626-3 · 없음 = null). */ +const sameExpected = (a: RouteEditSnapshot, b: RouteEditSnapshot): boolean => + JSON.stringify(a.expected ?? null) === JSON.stringify(b.expected ?? null); + /** 두 사진이 같은 선(노드 · 곡선)인가 — 고정 · 고른 자리는 안 본다. */ export function sameLine(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { - if (a.planned.length !== b.planned.length) return false; + if (a.planned.length !== b.planned.length || !sameExpected(a, b)) return false; for (let index = 0; index < a.planned.length; index += 1) { if (a.planned[index][0] !== b.planned[index][0]) return false; if (a.planned[index][1] !== b.planned[index][1]) return false; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 921413e50..dd107d6b9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -127,6 +127,8 @@ export interface RouteEditScene { overStations?: (context: CanvasRenderingContext2D) => void; /** 예상노선 위 · 계획노선 아래에 얹을 것(추천 노선 보기 626-1). 회전 안에서 부른다. */ underRoute?: (context: CanvasRenderingContext2D) => void; + /** 그림 맨 위(예정노선 그리기 626-3 — 고치는 예정노선 · 그은 선 · 미리보기). 회전 안에서 부른다. */ + overAll?: (context: CanvasRenderingContext2D) => void; } /** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */ @@ -349,6 +351,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou drawPickedCurve(context, scene, style); drawPickHover(context, scene, line, style, NODE_R); drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위) + scene.overAll?.(context); // 예정노선 그리기(626-3) — 구조물 마커보다도 위 context.restore(); // 회전 끝 } diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index b4d0a90cf..f14502b92 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -487,3 +487,21 @@ html.is-bm-route .b05-routeedit__tools { .b05-routeedit__info.is-collapsed > .b05-routeedit__maplegend { display: none; } + +/* 626-3 예정노선 그리기 판 — 도구 줄 아래(떠 있는 판) · 단추 줄 */ +.b05-routeedit__expected-draw { + pointer-events: auto; + width: 300px; +} + +.b05-routeedit__expected-draw .ui-float-panel__body { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b05-routeedit__expected-row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} diff --git a/B05_Profile/logic/B05_Profile_ExpectedDraw.ts b/B05_Profile/logic/B05_Profile_ExpectedDraw.ts new file mode 100644 index 000000000..afcd50952 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_ExpectedDraw.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B05_Profile_ExpectedDraw.ts + * B05 평면 예정노선 그리기 셈(PLAN 626-3 · 요구 530-11 · 결정 D10) — 다음 점 고르기 · 구간 다시 긋기 갈음 · 선 위 자리. + * 다음 점 = D03 긋기와 같은 방식 넷(자유지정 · 길이 고정 · 기울기 고정 · 길이·기울기 고정 부채꼴 · Shift 사이 기울기) — + * 셈은 D03 `logic/D03_PlanAnalysis_Draw` · `logic/D03_PlanAnalysis_Fan` 그대로(파이썬 짝 없음 · 화면 고르기 전용). + * 구간 다시 긋기 = 선 위 두 자리(m) 사이를 그은 점으로 갈음(거꾸로 그었으면 뒤집어 끼움). + * 서버는 고친 예정노선을 다시 추적(`POST /route/follow`) · [저장] 때 `expected_route_edit.csv`. + * DOM · fetch · sessionStorage 없음. + * ========================================================================== */ + +import { + distanceAlong, + fixedLengthPoint, + fixedSlopePoint, + lineLength, + pointAtDistance, + segmentLength, + sliceLine, + type HeightAt, + type Vertex, +} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw"; +import { + compassFan, + fanBetween, + fanPick, + type Fan, +} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan"; + +/** 긋기 방식 — 공용 방식 칸(`A00_Common/map/map_draw_kind` `DrawConstraint`)과 같은 꼴(logic 은 화면 파일을 안 부름). */ +export type DrawConstraint = "free" | "length" | "slope" | "both"; + +/** 같은 점으로 볼 거리(m). */ +const SAME_M = 1e-6; +/** 구간 다시 긋기 첫 구간 정방향 — 시작 자리 앞 이만큼(m) 선 위 점을 앞 구간 시작으로 봄. */ +const LEAD_M = 5; + +export interface ExpectedPick { + /** 찍을 점 — 없으면 null(자리 없음). */ + point: Vertex | null; + /** 길이·기울기 고정 부채꼴(그 방식일 때만). */ + fan: Fan | null; + note: string | null; + /** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅). */ + lengthOnly: boolean; +} + +const one = (value: number): string => value.toFixed(1); + +/** 다음 점 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `shift` = 부채꼴 사이 고르기. */ +export function pickNext( + kind: DrawConstraint, + from: Vertex, + cursor: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, + prev: Vertex | null, + shift = false, +): ExpectedPick { + if (kind === "length") + return { + point: fixedLengthPoint(from, cursor, lengthM), + fan: null, + note: null, + lengthOnly: false, + }; + if (kind === "slope") { + const point = fixedSlopePoint(from, cursor, slopePct, heightAt); + const note = point ? null : `이 방향엔 ±${one(Math.abs(slopePct))} % 자리 없음`; + return { point, fan: null, note, lengthOnly: false }; + } + if (kind === "both") { + const fan = compassFan(from, lengthM, slopePct, heightAt, prev, cursor); + const point = shift + ? fanBetween(fan, from, cursor, lengthM) + : fanPick(fan, from, cursor, lengthM); + const note = point + ? null + : `반지름 ${one(lengthM)} m 원 위에 ±${one(Math.abs(slopePct))} % 자리 없음`; + return { point, fan, note, lengthOnly: shift || fan.flat }; + } + return { point: cursor, fan: null, note: null, lengthOnly: false }; +} + +/** 선 위 가장 가까운 자리(누가 m · 점). */ +export function nearestOnLine( + line: ReadonlyArray, + point: Vertex, +): { atM: number; point: Vertex } { + const atM = distanceAlong(line, point); + return { atM, point: pointAtDistance(line, atM) }; +} + +/** 구간 다시 긋기 첫 구간의 앞 구간 시작 — 선 위 시작 자리 조금 앞(시점이면 null). */ +export function leadBefore(line: ReadonlyArray, atM: number): Vertex | null { + return atM > SAME_M ? pointAtDistance(line, Math.max(0, atM - LEAD_M)) : null; +} + +/** 이웃한 같은 점 뺌. */ +function dedupe(points: Vertex[]): Vertex[] { + return points.filter((point, i) => !i || segmentLength(points[i - 1], point) > SAME_M); +} + +/** 구간 다시 긋기 — 선 `fromM` ~ `toM` 사이를 `through`(그은 속 점 · 시작 · 끝 자리 뺌)로 갈음. */ +export function respliceLine( + line: ReadonlyArray, + fromM: number, + toM: number, + through: ReadonlyArray, +): Vertex[] { + const total = lineLength(line); + const forward = fromM <= toM; + const [lo, hi] = forward ? [fromM, toM] : [toM, fromM]; + const middle = forward ? [...through] : [...through].reverse(); + return dedupe([...sliceLine(line, 0, lo), ...middle, ...sliceLine(line, hi, total)]); +} diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py new file mode 100644 index 000000000..b01e46094 --- /dev/null +++ b/resources/tester/test_626_3_expected_edit.py @@ -0,0 +1,169 @@ +"""626-3 B05 평면 예정노선 그리기 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 결정 D3 · D4 · D9 · D10). + +- 셈(TS `logic/B05_Profile_ExpectedDraw.ts` · tsc 로 옮겨 Node) — 구간 다시 긋기 갈음(앞뒤 그대로 · 거꾸로 그음 뒤집음 · + 시점 · 종점 자리) · 다음 점 방식 넷(D03 셈 그대로) · 선 위 자리 · 첫 구간 정방향 앞 점. +- 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 · + 결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` · + 걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 긋기 방식 칸 공용 · + 판 켜면 캔버스 누름을 먼저 받음(재기 · 계획선 고르기 막음) · RouteEdit 700 줄 안. + +./venv/Scripts/python.exe -m pytest resources/tester/test_626_3_expected_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +TS_FILES = [ + ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts", + ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts", + B05 / "logic" / "B05_Profile_ExpectedDraw.ts", +] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as d from "./B05_Profile/logic/B05_Profile_ExpectedDraw.js"; +const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]]; +const plane = (x, y) => 0.65 * x; +const out = {}; +out.forward = d.respliceLine(line, 12, 28, [[15, 5], [25, 5]]); +out.backward = d.respliceLine(line, 28, 12, [[25, 5], [15, 5]]); +out.head = d.respliceLine(line, 0, 20, [[10, 3]]); +out.tail = d.respliceLine(line, 30, 40, []); +out.near = d.nearestOnLine(line, [17, 4]); +out.leadStart = d.leadBefore(line, 0); +out.lead = d.leadBefore(line, 12); +out.free = d.pickNext("free", [0, 0], [3, 4], 20, 9, plane, null); +out.length = d.pickNext("length", [0, 0], [3, 4], 20, 9, plane, null); +const both = d.pickNext("both", [0, 0], [0, 50], 20, 9, plane, [0, -20]); +out.both = { point: both.point, nodes: both.fan.nodes.length, arcs: both.fan.arcs.length, lengthOnly: both.lengthOnly }; +const between = d.pickNext("both", [0, 0], [-3, 50], 20, 9, plane, [0, -20], true); +out.between = { point: between.point, lengthOnly: between.lengthOnly }; +out.none = d.pickNext("both", [0, 0], [0, 50], 20, 9, () => NaN, null); +out.slopeNone = d.pickNext("slope", [0, 0], [0, 50], 20, 9, () => 0, null); +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("expected_draw") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)] + + ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"] + + ["--ignoreConfig"], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def _src(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_구간_다시_긋기_앞뒤_그대로_가운데만_갈음(r): + assert r["forward"] == [[0, 0], [10, 0], [12, 0], [15, 5], [25, 5], [28, 0], [30, 0], [40, 0]] + assert r["backward"] == r["forward"] # 거꾸로 그어도 같은 선 + assert r["head"] == [[0, 0], [10, 3], [20, 0], [30, 0], [40, 0]] # 시점부터 · 같은 점 하나 + assert r["tail"] == [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]] # 속 점 없음 = 곧은 갈음 + + +def test_선_위_자리_첫_구간_앞_점(r): + assert r["near"] == {"atM": 17, "point": [17, 0]} + assert r["leadStart"] is None and r["lead"] == [7, 0] + + +def test_다음_점_방식_넷_D03_셈_그대로(r): + assert r["free"]["point"] == [3, 4] and r["free"]["fan"] is None + assert [round(v, 6) for v in r["length"]["point"]] == [12, 16] + both = r["both"] + assert both["nodes"] == 4 and both["arcs"] == 2 and not both["lengthOnly"] + assert abs((both["point"][0] ** 2 + both["point"][1] ** 2) ** 0.5 - 20) < 1e-6 + between = r["between"] + assert between["lengthOnly"] and abs(sum(v * v for v in between["point"]) ** 0.5 - 20) < 1e-6 + assert r["none"]["point"] is None and "자리 없음" in r["none"]["note"] + assert r["slopeNone"]["point"] is None and "자리 없음" in r["slopeNone"]["note"] + + +def test_다시_추적_follow_만_저장_0_초기값_추천_안_건드림(): + ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") + assert "/route/follow" in ui and "askConfirm(" in ui and "flattenServerPlan(" in ui + for banned in ( + "/route/replan", + "as_initial", + "expected_route.csv", + "/route/initial", + "replanRoute", + ): + assert banned not in ui, banned + # B05 화면 어디서도 as_initial: true 를 안 보냄(D8) + for path in B05.glob("B05_Profile_UI_*.ts"): + assert not re.search(r"as_initial:\s*true", _src(path)), path.name + + +def test_결과는_편집기_한_걸음_저장은_손_편집_같은_길(): + main = _src(B05 / "B05_Profile_UI_RouteEdit.ts") + assert "createExpectedDraw({" in main and "restoreSnapshot(" in main + assert re.search(r"onFollow: \(flat, line\) =>\s+restoreSnapshot\(", main) + assert ( + '"예정노선을 따라 평면을 다시 추적했습니다.",\n true,' in main + ) # 새 걸음(되돌리기 됨) + assert "expected: expectedDraw.edit()," in main # 걸음 사진 칸 + assert "expectedDraw.setEdit(snapshot.expected ?? null);" in main # 되돌리기 = 예정노선도 + assert "expected: () => expectedDraw.edit()," in main # [저장] 초안 칸 + assert "plan.expected_edited ?? []" in main # 다시 열면 고친 예정노선 + assert len(main.splitlines()) <= 700 + apply = _src(B05 / "B05_Profile_UI_RouteEdit_Apply.ts") + assert "...(expected ? { expected } : {})" in apply # null = 안 실음(그대로) · [] = 지움 + + +def test_걸음_사진_초안_같은_선에_예정노선(): + history = _src(B05 / "B05_Profile_UI_RouteEdit_History.ts") + assert "expected?: Vertex[] | null;" in history and "!sameExpected(a, b)" in history + draft = _src(B05 / "B05_Profile_UI_RouteEdit_Draft.ts") + assert "...(snapshot.expected" in draft and "expected: draft.expected ?? null," in draft + types = _src(ROOT / "A00_Common" / "design" / "logic" / "design_pipeline_types.ts") + assert "expected?: Vertex[] | null;" in types + server = _src(B05 / "B05_Profile_Route_Save.py") + assert "expected: list[tuple[float, float]] | None = None" in server + + +def test_방식_칸_공용_판_켜면_캔버스_누름_먼저_받음(): + ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") + assert 'from "../A00_Common/map/map_draw_kind"' in ui and "createDrawKindFields(" in ui + assert "paintPreview(" in ui and "pickNext(" in ui # D03 그림 · 셈 그대로 + assert '"pointerdown",' in ui and "event.stopImmediatePropagation()" in ui + assert re.search( + r"\n true,\n \);", ui + ) # 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저 + assert 'keyIs(event, "plan.drawKind")' in ui and 'keyIs(event, "plan.drawFinish")' in ui + assert "createFloatPanel(" in ui and 'createElement("button")' not in ui + tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts") + assert re.search(r'key: "expected-draw",\s+does: "[^"]+",\s+pages: B05', tools) + render = _src(B05 / "B05_Profile_UI_RouteEdit_Render.ts") + # 맨 위 = 구조물 마커 뒤 · 회전 끝 앞 + assert render.index("scene.overAll?.(context);") < render.index("context.restore(); // 회전 끝") diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index 47c7e28cd..ff1b8aa24 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -31,6 +31,12 @@ export const MAP_TOOLS = { footprint: { icon: "road", title: "절/성토선", toggle: true }, measure: { icon: "ruler", title: "거리 재기", toggle: true }, "station-snap": { icon: "magnet", title: "측점 스냅", toggle: true }, + "expected-draw": { + icon: "pencil", + title: + "예정노선 그리기 — 예정노선을 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선을 예정노선 따라 통째 다시 추적 (긋는 동안 계획선은 안 집힘)", + toggle: true, + }, } satisfies Record; export type MapToolAct = keyof typeof MAP_TOOLS;