/* ============================================================================= * B05_Profile_UI_ExpectedDraw.ts * B05 평면 예정노선 변경(PLAN 626-3 · 요구 530-11 · 12 · 535-1 · 536 · 결정 D3 · D4 · D9) — 도구줄 「예정노선 변경」 켬끔. * 켬 = 예정노선이 맨 위 · 예정노선 페이지(D03) 긋기 · 고르기 · 고치기를 **그대로**(같은 함수 · 단추 · 단축키): * 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) · * 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) · * 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] · * [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김. * 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 · * 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만). * 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → * `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과 * 같은 길 + 초안 `expected`(`expected_route_edit.csv`). 선이 없으면(다 지움) 평면 그대로 · 새로 그을 때까지 기다림. * 예정노선 세 벌 = 파일입력(`expected_route.csv` · 안 덮음 · 도구줄 「예정노선(원본)」 켬끔 = 옅은 점선) · 저장본 * (`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림). * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { 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 { themeColor } from "@ui/ui_template_palette"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan"; import { createPlanDraw } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw"; import { createPlanEdit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit"; import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; import { createPlanMenu } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu"; import type { PlanPoints } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Points"; import { createPlanProps } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Props"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits"; type Vertex = [number, number]; const HINT = "[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다."; const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다."; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; /** 지점 없음(B05 예정노선 = 한 선 · 메뉴의 지점 항목 숨김). */ const NO_POINTS: PlanPoints = { panel: document.createElement("div"), input: { draw: () => undefined }, pins: { draw: () => undefined }, render: () => undefined, pinAt: () => null, place: () => undefined, remove: () => undefined, name: () => "", full: () => true, }; export interface ExpectedDrawParams { projectId: string; canvas: HTMLCanvasElement; /** 평면 뼈대 — 도구줄 칸(변경 켬끔 · 원본 켬끔) · 긋기 판 자리(도구 줄) · 속성 판 · 메뉴 자리(지도 칸). */ chrome: Pick; toScreen: (vertex: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; /** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */ rotation: () => Pick; pxPerMeter: () => number; view: () => ViewState; 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; } const sameLine = (a: ReadonlyArray, b: ReadonlyArray): boolean => JSON.stringify(a) === JSON.stringify(b); export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { const { canvas } = params; const { expectedDrawButton: button, expectedOriginalButton: originalButton } = params.chrome; const viewport = params.chrome.overlay.querySelector(".b05-routeedit__canvas-wrap")!; let original: Vertex[] = []; let saved: Vertex[] = []; let edit: Vertex[] | null = null; let open = false; let showOriginal = true; let busy = false; /** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */ let asked = false; let grid: DesignTerrainGrid | null = null; /** 고치는 판(D03 꼴 · 한 선) · 긋기 시작 때 판(끝나면 바뀜을 봄). */ let network: PlanNetwork = { lines: [], pieces: [] }; let strokeBase: PlanNetwork | null = null; 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 fromLine = (): PlanNetwork => { const shown = line(); const vertices = shown.map(([x, y]): Vertex => [x, y]); return { lines: shown.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; }; // ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ───────── const draw = createPlanDraw({ base: () => [], network: () => network, heightAt: () => z, commit, onMode, oneLine: true, }); const show = (next: PlanNetwork): void => { network = next; params.redraw(); }; // `heightAt` = 625-3(놓인 노드 부호 색) 덧 칸 — 합치기 앞뒤 둘 다 맞게 칸 묶음을 따로 둠 const editParams = { network: () => network, base: (): Vertex[] => [], commit, show, commitPoints: (_points: Vertex[], next: PlanNetwork) => commit(next), showPoints: (_points: Vertex[], next: PlanNetwork) => show(next), enabled: () => open && draw.mode() === "idle", editable: () => open && !busy, heightAt: () => z, onSelect: (): void => (props.render(), params.redraw()), }; const editor = createPlanEdit(editParams); const props = createPlanProps({ network: () => network, edit: editor, editable: () => !busy, yearStart: () => new Date().getFullYear(), commit, heightAt: () => z, epsg: () => 0, points: () => [], addPoint: () => undefined, commitPoints: (_points, next) => commit(next), respan: draw.respan, oneLine: true, }); const menu = createPlanMenu({ network: () => network, points: () => [], marks: NO_POINTS, edit: editor, draw, editable: () => open && !busy, respan: draw.respan, oneLine: true, }); const layers: PlanMapLayer[] = [draw.layer, editor.layer, menu.layer]; props.panel.classList.add("b05-routeedit__expected-props"); viewport.append(props.panel); menu.mount(viewport); // ── 켬끔 판 ───────────────────────────────────────────── const panel = createFloatPanel({ title: "예정노선 변경", onClose: () => setOpen(false) }); panel.root.classList.add("b05-routeedit__expected-draw"); panel.root.hidden = true; const hint = createText(HINT, "muted"); panel.body.append(draw.panel, hint); params.chrome.overlay.querySelector(".b05-routeedit__tools")!.append(panel.root); button.addEventListener("click", () => setOpen(!open)); const paintOriginalButton = (): void => { originalButton.setAttribute("aria-pressed", String(showOriginal)); originalButton.classList.toggle("is-active", showOriginal); }; paintOriginalButton(); originalButton.addEventListener("click", () => { showOriginal = !showOriginal; paintOriginalButton(); params.redraw(); }); function setOpen(next: boolean): void { if (draw.mode() !== "idle") draw.setMode("idle"); open = next; asked = false; network = fromLine(); editor.select([]); draw.setEditable(open); button.setAttribute("aria-pressed", String(open)); button.classList.toggle("is-active", open); panel.root.hidden = !open; hint.textContent = network.lines.length ? HINT : EMPTY_HINT; if (open) { params.onOpen(); void loadDesignTerrainGrid(params.projectId).then( (loaded) => ((grid = loaded), params.redraw()), ); } props.render(); params.redraw(); } /** 꼴 한 걸음(D03 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */ function commit(next: PlanNetwork): void { network = next; props.render(); params.redraw(); if (draw.mode() === "idle") void apply(); } /** 긋기 들어감 = 판 기억 · 긋기 끝 = 바뀌었으면 다시 추적. */ function onMode(): void { if (draw.mode() === "draw") { strokeBase ??= network; return params.redraw(); } const base = strokeBase; strokeBase = null; if (base && JSON.stringify(base.lines) !== JSON.stringify(network.lines)) void apply(); params.redraw(); } function revert(): void { network = fromLine(); editor.sync(); draw.sync(); props.render(); params.redraw(); } /** 다시 추적 — 한 선 · 점 2 이상일 때만 · 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. */ async function apply(): Promise { if (busy || !open) return; const drawn = network.lines.filter((entry) => entry.vertices.length > 1); hint.textContent = drawn.length ? HINT : EMPTY_HINT; if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로 if (drawn.length > 1) { showToast("예정노선은 한 선입니다.", "info"); return revert(); } const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]); if (sameLine(points, line())) return; if (!asked) { const ok = await askConfirm({ title: "평면선 다시 추적", message: "고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + "예정노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.", okLabel: "다시 추적", }); if (!ok) return revert(); asked = true; } busy = true; params.redraw(); try { 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), points, ); } catch (error) { showToast( `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); revert(); } finally { busy = false; params.redraw(); } } // ── 누름 · 키 — 켠 동안 먼저 받아 D03 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ── const map = (): ScreenMap => { const rotation = params.rotation(); return { toScreen: params.toScreen, toMetric: params.toMetric, pxPerMeter: params.pxPerMeter(), view: params.view(), stations: false, // 측점 눈금 = 계획선 몫 slopes: markShown("slopes"), uprightRad: rotation.uprightRad(), upright(context, x, y, paint) { const radians = rotation.uprightRad(); if (!radians) return paint(); context.save(); context.translate(x, y); context.rotate(radians); context.translate(-x, -y); paint(); context.restore(); }, rerotate: rotation.rerotate, unrotate: rotation.unrotate, }; }; const at = (event: MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return params.rotation().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); const [px, py] = at(event); if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey))) canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음 params.redraw(); }, true, ); canvas.addEventListener( "pointermove", (event) => { if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로 event.stopImmediatePropagation(); const [px, py] = at(event); const screen = map(); for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey); params.redraw(); }, true, ); canvas.addEventListener( "pointerup", (event) => { if (!open || event.button !== 0) return; claim(event); const screen = map(); for (const layer of layers) layer.release?.(screen); params.redraw(); }, true, ); canvas.addEventListener( "contextmenu", (event) => { if (!open) return; claim(event); const [px, py] = at(event); if (!busy) layers.some((layer) => layer.press?.(px, py, map(), 2, event.shiftKey)); params.redraw(); }, true, ); for (const type of ["click", "dblclick"]) canvas.addEventListener(type, (event) => open && claim(event), true); canvas.addEventListener("pointerleave", () => { for (const layer of layers) layer.leave?.(); if (open) params.redraw(); }); const onKey = (event: KeyboardEvent): void => { if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return; if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩 if (!(draw.key(event) || editor.key(event))) return; claim(event); params.redraw(); }; listenWhileAlive(window, "keydown", canvas, onKey, true); // ── 그림 ──────────────────────────────────────────────── function paint(context: CanvasRenderingContext2D): void { if (showOriginal && original.length > 1 && line() !== original) { context.save(); context.beginPath(); original.forEach((point, i) => { const [sx, sy] = params.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.globalAlpha = 0.7; context.setLineDash([2, 5]); context.lineWidth = 2; context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af"); // 원본 = 옅은 점선 context.stroke(); context.restore(); } if (!open) return; const screen = map(); for (const layer of layers) { context.save(); layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기 context.restore(); } } // 검증 창구 — 켬 · 긋기 모드 · 고친 판 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px Object.assign(canvas, { expectedProbe: { state: () => ({ open, mode: draw.mode(), lines: network.lines.map((entry) => entry.vertices.length), preview: draw.preview(), selected: editor.selected()?.kind ?? null, busy, }), line, original: () => original, edit: () => edit, screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)), }, }); return { load(nextOriginal, nextSaved) { original = nextOriginal; saved = nextSaved; network = fromLine(); }, edit: () => edit, setEdit(next) { edit = next; if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적 network = fromLine(); editor.sync(); props.render(); }, line, paint, }; }