/* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts * D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) · * R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`) * 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음. * * 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만. * 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`). * ========================================================================== */ import { handleAtScreen, nodeAtScreen, segmentAtScreen, } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import { insertNode, moveHandle, moveNode, removeNode, type EditStep, type PlanEdit, } from "./logic/D03_PlanAnalysis_Edit"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; /** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */ const NODE_HIT_PX = 10; const HANDLE_HIT_PX = 8; const SEGMENT_HIT_PX = 10; /** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */ export interface EditTarget { year: number; edit: PlanEdit; line: EditedPolyline; minRadiusM: number; } export interface PlanEditEvents { /** 고치기 직전 한 번(되돌리기 쌓기). */ begin: () => void; /** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */ change: (year: number, edit: PlanEdit, final: boolean) => void; /** 막힌 까닭(하한 · 끝 잠금). */ blocked: (message: string) => void; } export interface PlanEditLayer extends PlanMapOverlay { setTargets: (targets: EditTarget[]) => void; targets: () => EditTarget[]; } type Grab = { year: number; index: number; which: "node" | "start" | "end" }; export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer { let targets: EditTarget[] = []; let grab: Grab | null = null; let moved = false; let stop = ""; const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1); function apply(target: EditTarget, step: EditStep, final: boolean): void { if ("blocked" in step) { stop = step.blocked; if (final) events.blocked(stop); return; } target.edit = step.edit; target.line = step.line; events.change(target.year, step.edit, final); } return { setTargets(next) { targets = next; }, targets: () => targets, draw(context, map) { context.save(); context.font = "11px system-ui, sans-serif"; context.textAlign = "left"; context.textBaseline = "bottom"; for (const target of targets) { for (const curve of target.line.curves) { for (const end of [curve.start, curve.end]) { const [x, y] = map.toScreen(end); context.beginPath(); context.arc(x, y, 4, 0, Math.PI * 2); context.fillStyle = "#f59e0b"; context.fill(); context.lineWidth = 1.5; context.strokeStyle = "#ffffff"; context.stroke(); } const [ax, ay] = map.toScreen(curve.apex); context.lineWidth = 3; context.strokeStyle = "rgba(0, 0, 0, 0.7)"; context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff"; context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); } interior(target.edit).forEach((node, i) => { const [x, y] = map.toScreen(node); context.fillStyle = "#ffffff"; context.fillRect(x - 4.5, y - 4.5, 9, 9); context.lineWidth = 2; const bad = target.line.nodes[i + 1]?.violations.length; context.strokeStyle = bad ? "#dc2626" : "#2563eb"; context.strokeRect(x - 4.5, y - 4.5, 9, 9); }); } context.restore(); }, grab(px, py, map) { grab = null; moved = false; stop = ""; for (const target of targets) { const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); if (node >= 0) { grab = { year: target.year, index: node + 1, which: "node" }; return true; } } for (const target of targets) { const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX); if (hit) { grab = { year: target.year, index: hit.node, which: hit.end }; return true; } } return false; }, move(point) { const target = grab && targets.find((entry) => entry.year === grab!.year); if (!grab || !target) return; if (!moved) events.begin(); moved = true; const step = grab.which === "node" ? moveNode(target.edit, grab.index, point, target.minRadiusM) : moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM); if (!("blocked" in step)) stop = ""; apply(target, step, false); }, release() { const target = grab && targets.find((entry) => entry.year === grab!.year); grab = null; if (!moved || !target) return; moved = false; events.change(target.year, target.edit, true); if (stop) events.blocked(stop); }, insert(px: number, py: number, map: ScreenMap) { for (const target of targets) { const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX); if (segment < 0) continue; events.begin(); apply( target, insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM), true, ); return; } }, remove(px: number, py: number, map: ScreenMap) { for (const target of targets) { const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); if (node < 0) continue; events.begin(); apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true); return; } }, }; }