/* ============================================================================= * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) · * 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). * 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림). * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위). * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; import { createButton } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; import { moveNodesAt, nameError, samePoint, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { addPoint, MAX_POINTS, movePointTo, parseLonLat, pointLabel, renamePoint, type PointsState, } from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; /** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */ const PIN_HEAD_PX = 18; export interface PlanPointsParams { points: () => Vertex[]; /** 지점 이름(차례 = `points` · 빈 값 = 「지점 N」). */ names: () => string[]; network: () => PlanNetwork; epsg: () => number; /** 한 걸음 — 지점 · 이름 · 노선 함께. */ commit: (points: Vertex[], names: string[], network: PlanNetwork) => void; /** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */ center: () => Vertex | null; /** 끌기 중 — 화면만. */ show: (points: Vertex[], network: PlanNetwork) => void; /** 쉴 때(긋기 · 재기 아님)만 핀을 끎. */ idle: () => boolean; editable: () => boolean; } export interface PlanPoints { /** 좌측 「지점」 칸 몸. */ panel: HTMLElement; input: PlanMapLayer; pins: PlanMapLayer; render: () => void; /** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */ pinAt: (px: number, py: number, map: ScreenMap) => number | null; place: (px: number, py: number, map: ScreenMap) => void; remove: (i: number) => void; name: (i: number) => string; full: () => boolean; } export function createPlanPoints(params: PlanPointsParams): PlanPoints { const list = document.createElement("div"); list.className = "d03-plan__points"; const addButton = createButton({ label: "추가", iconStart: "plus", variant: "ghost", size: 24, onClick: addAtCenter, }); addButton.dataset.role = "points-add"; const head = document.createElement("div"); head.className = "d03-plan__points-head"; head.append(addButton); const panel = document.createElement("div"); panel.append(head, list); let openIndex: number | null = null; // 펼친 줄(하나만) let syncEditor: (() => void) | null = null; let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) let drag: { index: number; points: Vertex[]; network: PlanNetwork; last: [Vertex[], PlanNetwork] | null; } | null = null; const name = (i: number): string => pointLabel(params.names(), i); const state = (): PointsState => ({ points: params.points(), names: params.names(), network: params.network(), }); const commitState = (next: PointsState): void => params.commit(next.points, next.names, next.network); /** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */ function pinAt(px: number, py: number, map: ScreenMap): number | null { const [cx, cy] = map.rerotate(px, py); let best: number | null = null; let bestGap = SNAP_PX; params.points().forEach((point, i) => { const [sx, sy] = map.rerotate(...map.toScreen(point)); const gap = Math.min( Math.hypot(cx - sx, cy - sy), Math.hypot(cx - sx, cy - sy + PIN_HEAD_PX), ); if (gap <= bestGap) [best, bestGap] = [i, gap]; }); return best; } function remove(i: number): void { openIndex = null; params.commit( params.points().filter((_, k) => k !== i), params.names().filter((_, k) => k !== i), params.network(), ); } /** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */ function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null { const node = params .network() .lines.flatMap((line) => line.vertices) .filter((point) => !skip || !samePoint(point, skip)) .map((point) => ({ point, gap: screenGap(map, point, px, py) })) .filter((item) => item.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; return node ? node.point : null; } /** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */ function place(px: number, py: number, map: ScreenMap): void { if (params.points().length >= MAX_POINTS) return; const at = nodeNear(px, py, map) ?? map.toMetric(px, py); params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network()); } const pins = pointLayer(params.points, params.names); const input: PlanMapLayer = { draw: () => undefined, press(px, py, map, button) { if (button !== 0 || !params.editable() || !params.idle()) return false; const index = pinAt(px, py, map); if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이) drag = { index, points: params.points(), network: params.network(), last: null }; return true; }, hover(px, py, map) { if (!drag) return; const from = drag.points[drag.index]; const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py); const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p)); drag.last = [points, moveNodesAt(drag.network, from, to)]; params.show(...drag.last); }, release() { const done = drag; drag = null; if (!done?.last) return; params.show(done.points, done.network); params.commit(done.last[0], params.names(), done.last[1]); }, }; /** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */ function addAtCenter(): void { const at = params.center(); if (!at || !params.editable() || params.points().length >= MAX_POINTS) return; openIndex = params.points().length; commitState(addPoint(state(), at)); } /** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */ function editorOf(i: number): { root: HTMLElement; sync: () => void } { const tm = tmParams(params.epsg()); const editable = params.editable(); const field = (label: string, role: string, placeholder?: string) => { const f = createInputField({ label, size: 24, placeholder }); f.input.dataset.role = role; f.input.disabled = !editable || (role !== "point-name" && !tm); return f; }; const nameField = field("이름", "point-name", `지점 ${i + 1}`); const lonField = field("경도", "point-lon"); const latField = field("위도", "point-lat"); const lonLatNow = (): [number, number] | null => { const p = params.points()[i]; return tm && p ? tmToLonLat(tm, p[0], p[1]) : null; }; const sync = (): void => { const set = (f: ReturnType, text: string): void => { if (document.activeElement !== f.input) f.input.value = text; }; set(nameField, params.names()[i] ?? ""); const ll = lonLatNow(); set(lonField, ll ? ll[0].toFixed(6) : ""); set(latField, ll ? ll[1].toFixed(6) : ""); }; nameField.input.addEventListener("input", () => nameField.setError(nameError(nameField.input.value.trim()) ?? undefined), ); nameField.input.addEventListener("change", () => { const text = nameField.input.value.trim(); const error = nameError(text); nameField.setError(error ?? undefined); if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); }); const moved = (): void => { const parsed = parseLonLat(lonField.input.value, latField.input.value); const bad = typeof parsed === "string"; lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined); latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined); if (bad || !tm) return; const now = lonLatNow(); if ( now && now[0].toFixed(6) === parsed[0].toFixed(6) && now[1].toFixed(6) === parsed[1].toFixed(6) ) return; commitState(movePointTo(state(), i, tm, parsed[0], parsed[1])); }; lonField.input.addEventListener("change", moved); latField.input.addEventListener("change", moved); const root = document.createElement("div"); root.className = "d03-plan__point-edit"; root.append(nameField.root, lonField.root, latField.root); sync(); return { root, sync }; } function render(): void { const tm = tmParams(params.epsg()); const editable = params.editable(); const points = params.points(); if (openIndex !== null && openIndex >= points.length) openIndex = null; addButton.hidden = !editable; addButton.disabled = points.length >= MAX_POINTS || !params.center(); const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`; const lonLatOf = (i: number): string | undefined => { const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null; return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined; }; if (key === shown) { // 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움 points.forEach((_, i) => { const row = list.children[i]?.querySelector(".ui-list-row"); const label = row?.querySelector(".ui-list-row__label"); const sub = row?.querySelector(".ui-list-row__sub"); if (label) label.textContent = name(i); if (sub) sub.textContent = lonLatOf(i) ?? ""; }); syncEditor?.(); return; } shown = key; syncEditor = null; list.replaceChildren( ...points.map((_, i) => { const drop = createIconButton({ icon: "trash", title: `${name(i)} 지우기`, size: 24, onClick: () => remove(i), }); drop.hidden = !editable; const row = createListRow({ label: name(i), sub: lonLatOf(i), stripe: pointColor(i), size: 28, trailing: drop, active: i === openIndex, }); row.dataset.point = String(i); row.tabIndex = 0; const toggle = (): void => { openIndex = openIndex === i ? null : i; render(); }; row.addEventListener("click", (event) => { if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle(); }); row.addEventListener("keydown", (event) => { if (event.key === "Enter" && event.target === row) toggle(); }); const item = document.createElement("div"); item.append(row); if (i === openIndex) { const editor = editorOf(i); syncEditor = editor.sync; item.append(editor.root); } return item; }), ); } return { panel, input, pins, render, pinAt, place, remove, name, full: () => params.points().length >= MAX_POINTS, }; }