/* ============================================================================= * B05_Profile_UI_ExpectedDraw.ts * B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 755 · 요구 530 · 535-1 · 536 · 546 · 589) — 예정노선 키트(`createPlanKit` 「b05」)를 엮음. * 왼쪽 칸 = 키트 「그리기」 하나(머리 「예정노선 수정」 켬끔 · 받은 노선 켬끔 · 길이 비교 · [받은 노선으로]) — * 자리 = `B05_Profile_UI_Panel_Plan`. 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 위치 칸 숨김). * 켬 = 지도 누름 · 키를 먼저 받음(키트 `bindPlanInput` capture) · 다른 계획노선 그림 어둡게(`--draw-map-dim` · 장면 `overAll.dim`) · * 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만). * 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2) — 고친 뒤엔 고친 꼭짓점. * 켠 동안(755) = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 되돌리기 = 이 판 사진첩(Ctrl+Z · 도구줄 단추) · * 재기 켬 = D03 재기 겹(고치는 판 따라 · 두 점 곧은 거리 · `createPlanMeasure`). * 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) → * `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`). * [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로. * 켠 채 판을 고쳤으면(755-3) = [저장] · [확정] 막고 알림(`holdDesignSave`) · 떠남 = 공용 떠남 물음 · 탭 닫기 = 브라우저 물음. * 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`). * 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄. * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; import { guardProjectLeave } from "../A00_Common/b_project_switch"; import { holdDesignSave } from "../A00_Common/design/design_save"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { createButton, showToast } from "@ui/ui_template_elements"; import { askConfirm } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { createToggle } from "@ui/ui_template_toggle"; 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 { strokeCased } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint"; import { bindPlanInput, createPlanKit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit"; import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import { createPlanCoords, fillPlanPanel } from "./B05_Profile_UI_Panel_Plan"; import { createPlanContextLayer } from "./B05_Profile_UI_PlanContext"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; import { polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; 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 OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다."; const HELD = "예정노선 수정 중 — 「예정노선 수정」을 끈 뒤 [저장]합니다(켠 동안 고친 판은 끌 때 평면선에 들어감)."; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; 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; /** 도구줄 재기 단추 켬 — 켠 동안 D03 재기 겹이 누름을 받음(755-2). */ measuring: () => boolean; /** 켠 동안 사진첩이 바뀜 — 도구줄 되돌리기 단추 맞춤. */ onHistory: () => 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; /** 아래 그림 불투명(켠 동안 `--draw-map-dim`). */ dim: () => number; /** 켠 동안 되돌리기 — 이 판 사진첩(끔 = null · 페이지 사진첩). */ history: () => ExpectedHistory | null; } export interface ExpectedHistory { undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean; } const sameLine = (a: ReadonlyArray, b: ReadonlyArray): boolean => JSON.stringify(a) === JSON.stringify(b); const meters = (line: ReadonlyArray): string => Math.round(polylineLengthM(line)).toLocaleString(); export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { const { canvas } = params; const viewport = params.chrome.overlay.querySelector(".b05-routeedit__canvas-wrap")!; let original: Vertex[] = []; /** 받은 원본 꼭짓점(635-2 · 조밀화 전) — 못 받으면 빈(= 조밀 점 `original`). */ let source: 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; /** 고치는 판(예정노선 꼴 · 켠 동안 끊김 허용) · 판을 지은 꼭짓점. */ let network: PlanNetwork = { lines: [], pieces: [] }; let base: Vertex[] = []; /** 켠 동안 사진첩(755-1) — `commit` 한 번 = 한 걸음 · 판을 다시 지으면 비움. */ let undoStack: PlanNetwork[] = []; let redoStack: PlanNetwork[] = []; /** 재기 겹이 받던 때(켬끔 바뀜 = 잰 것 지움). */ let measured = false; const line = (): Vertex[] => edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original; const received = (): Vertex[] => (source.length > 1 ? source : original); const changed = (): boolean => line() !== original && original.length > 1; const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN); const fromLine = (): PlanNetwork => { const shown = line(); base = (shown === original ? received() : shown).map(([x, y]): Vertex => [x, y]); const vertices = base.map(([x, y]): Vertex => [x, y]); undoStack = []; redoStack = []; return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; }; const measuring = (): boolean => open && params.measuring(); /** 켠 채 판을 고침 — [저장] · 떠남이면 잃음(755-3). */ const pending = (): boolean => open && undoStack.length > 0; holdDesignSave(canvas, () => (pending() ? HELD : null)); guardProjectLeave(canvas, { dirty: pending, save: async () => (showToast(HELD, "warning"), false), message: HELD, }); // 속성 판 경위도 = 서버 변환(635-5) const coords = createPlanCoords(params.projectId, () => kit.props.render()); const others = createPlanContextLayer(params.projectId, params.redraw); // 재기 = 예정노선 페이지 재기 한 벌(첫 누름 선 위 = 선 따라 · 밖 = 두 점 곧은 거리 · 높이 = 설계 지형 격자) const measure = createPlanMeasure( () => z, () => network, ); // ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ── const kit = createPlanKit({ page: "b05", network: () => network, commit, show: (next) => { network = next; params.redraw(); }, heightAt: () => z, lonLat: coords.lonLat, editable: () => open && !busy, idle: () => open && !measuring(), onMode: () => params.redraw(), onSelect: () => (kit.props.render(), params.redraw()), snaps: others.joints, onKey: (event) => keyIs(event, "plan.undo") ? (step("undo"), true) : keyIs(event, "plan.redo") ? (step("redo"), true) : measuring() && event.key === "Escape" ? (measure.clear(), true) : false, cards: {}, }); const { draw, edit: editor, props, menu } = kit; const layers = kit.layers([], []); const measureLayers = kit.layers([], [measure.layer]); /** 지금 겹 — 재기 켬이면 재기 겹(고르기 · 노드 끌기 쉼) · 켬끔이 바뀌면 잰 것 지움 · 긋기 끝. */ const liveLayers = (): PlanMapLayer[] => { const on = measuring(); if (on !== measured) { measured = on; measure.clear(); if (on && draw.mode() !== "idle") draw.setMode("idle"); } return on ? measureLayers : layers; }; props.panel.classList.add("b05-routeedit__expected-props"); viewport.append(props.panel); menu.mount(viewport); // ── 그리기 칸 — 머리 「예정노선 수정」 · 받은 노선 · 길이 비교 · [받은 노선으로] ── const drawCard = kit.cards.find((card) => card.contains(draw.panel))!; const toggle = createToggle({ label: "예정노선 수정", size: 20, onChange: (on) => void setOpen(on), }); toggle.root.addEventListener("click", (event) => event.stopPropagation()); // 칸 접기로 안 올라감 drawCard.firstElementChild!.append(toggle.root); const originalToggle = createToggle({ label: "받은 노선", checked: true, size: 20, onChange: (on) => ((showOriginal = on), params.redraw()), }); const resetButton = createButton({ label: "받은 노선으로", variant: "ghost", size: 24, onClick: () => void follow(received(), []), }); const compare = createText("", "muted"); const hint = createText(OFF_HINT, "muted"); const jointHint = createText("", "muted"); const row = document.createElement("div"); row.className = "b05-routeedit__expected-row"; row.append(originalToggle.root, resetButton); drawCard.append(row, compare, jointHint, hint); fillPlanPanel(drawCard); function syncCard(): void { const edited = changed(); resetButton.disabled = !edited || busy; compare.textContent = edited ? `받은 노선 ${meters(received())} m → 고친 노선 ${meters(line())} m` : ""; compare.hidden = !edited; jointHint.textContent = others.note(line()) ?? ""; jointHint.hidden = !jointHint.textContent; hint.hidden = open; // 켠 동안 설명 걷음(589-7) } async function setOpen(next: boolean): Promise { if (draw.mode() !== "idle") draw.setMode("idle"); if (!next && open && !(await finish())) return toggle.set(true); open = next; asked = false; network = fromLine(); editor.select([]); measure.clear(); draw.setEditable(open); toggle.set(open); if (open) { params.onOpen(); void loadDesignTerrainGrid(params.projectId).then( (loaded) => ((grid = loaded), params.redraw()), ); } syncCard(); props.render(); params.onHistory(); params.redraw(); } /** 꼴 한 걸음(예정노선 `commit`) — 켠 동안 판만 · 이 판 사진첩에 쌓음(평면선 = 끌 때 한 번). */ function commit(next: PlanNetwork): void { undoStack.push(network); redoStack = []; network = next; kit.sync(); // 지운 고름 · 판 맞춤(예정노선 페이지 `refresh` 와 같음) params.onHistory(); params.redraw(); } function step(which: "undo" | "redo"): void { const [from, to] = which === "undo" ? [undoStack, redoStack] : [redoStack, undoStack]; const last = from.pop(); if (!last) return; if (draw.mode() !== "idle") draw.setMode("idle"); to.push(network); network = last; editor.select([]); kit.sync(); params.onHistory(); params.redraw(); } /** 끌 때 한 번 — 끊겼으면 알림 · 안 끔 · 바뀌었으면 다시 추적(실패 · 물음 취소 = 안 끔). 참 = 꺼도 됨. */ async function finish(): Promise { if (busy) return false; const drawn = network.lines.filter((entry) => entry.vertices.length > 1); if (drawn.length > 1) { showToast( `예정노선이 ${drawn.length} 토막으로 끊겨 있습니다 — 선 끝에서 이어 긋거나 [잇기]로 이은 뒤 끕니다.`, "info", ); return false; } if (!drawn.length) { if (base.length > 1) showToast("예정노선이 비어 고친 것을 버렸습니다 — 평면선 그대로.", "info"); return true; } const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]); return sameLine(points, base) || (await follow(points, points)); } /** 확인창(켠 뒤 처음만) → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). 참 = 됨. */ async function follow(points: Vertex[], expected: Vertex[]): Promise { if (busy || points.length < 2) return false; if (!asked) { const ok = await askConfirm({ title: "평면선 다시 추적", message: "고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + "받은 노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.", okLabel: "다시 추적", }); if (!ok) return false; asked = true; } busy = true; syncCard(); 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), expected, ); return true; } catch (error) { showToast( `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); return false; } finally { busy = false; syncCard(); params.redraw(); } } // ── 누름 · 키 — 켠 동안 먼저 받아 예정노선 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ── 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, }; }; bindPlanInput({ canvas, at: (event) => { const rect = canvas.getBoundingClientRect(); return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top); }, screen: map, layers: liveLayers, redraw: params.redraw, capture: { active: () => open, busy: () => busy }, }); const onKey = (event: KeyboardEvent): void => { if (!open) return; if (!kit.key(event)) return; event.stopImmediatePropagation(); event.preventDefault(); params.redraw(); }; listenWhileAlive(window, "keydown", canvas, onKey, true); // ── 그림 ──────────────────────────────────────────────── function paint(context: CanvasRenderingContext2D): void { others.paint(context, map()); // 다른 세트 = 흐린 겹(켬끔 상관없이 보기만) 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.lineCap = "round"; context.lineJoin = "round"; // 받은 노선 = 분홍 점선 + 검은 테(589-6 · 계획 주황 · 예정 민트 · 추천 노랑 · 등고선과 가름 · 테마 안 따름) strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]); context.restore(); } if (!open) return; const screen = map(); for (const layer of liveLayers()) { 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, compare: compare.textContent, joint: jointHint.textContent, undo: undoStack.length, redo: redoStack.length, measuring: measuring(), }), measure: () => measure.result(), others: others.probe, line, network: () => network, original: () => original, source: () => source, edit: () => edit, screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)), }, }); return { load(nextOriginal, nextSaved) { original = nextOriginal; saved = nextSaved; network = fromLine(); syncCard(); void others.load().then(syncCard); void requestJson<{ expected_source?: Vertex[] }>( `/projects/${params.projectId}/route/plan/source`, {}, 30000, ) .then((body) => { source = body.expected_source ?? []; if (!open) network = fromLine(); syncCard(); }) .catch(() => undefined); // 못 받음 = 조밀 점 그대로 }, edit: () => edit, setEdit(next) { edit = next; syncCard(); if (draw.mode() !== "idle") return; // 긋는 중 = 긋기 판 그대로 network = fromLine(); editor.sync(); props.render(); syncCard(); }, line, paint, dim: () => (open ? parseFloat(themeColor("--draw-map-dim", "0.35")) || 0.35 : 1), history: () => open ? { undo: () => step("undo"), redo: () => step("redo"), canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, } : null, }; }