/* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts * D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만. * * 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂. * 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · * 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움. * 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음. * 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움]. * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; import { createText } from "@ui/ui_template_text"; import { distanceAlong, lineLength, pointAtDistance, sliceLine, } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, lineNames, moveMark, moveVertex, removeLine, removeMark, removeVertex, reverseLine, samePoint, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; export interface PlanEditParams { network: () => PlanNetwork; base: () => Vertex[]; commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ editable: () => boolean; /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */ onSelect: () => void; } export interface PlanSelection { line: string; piece: string | null; } export interface PlanEdit { layer: PlanMapLayer; /** 좌측 「고른 선」 칸. */ panel: HTMLElement; selected: () => PlanSelection | null; select: (line: string | null, piece?: string | null) => void; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; /** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */ sync: () => void; } type Drag = | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } | { kind: "locked"; note: string; told: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { let selected: PlanSelection | null = null; let drag: Drag | null = null; const info = createText("", "muted"); const flipButton = createButton({ label: "방향 바꾸기", variant: "ghost", size: 24, onClick: () => apply((network, index) => reverseLine(network, index)), }); flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; const dropButton = createButton({ label: "선 지움", variant: "ghost", size: 24, onClick: () => apply((network, index) => removeLine(network, index)), }); const row = document.createElement("div"); row.className = "d03-plan__row"; row.append(flipButton, dropButton); const panel = document.createElement("div"); panel.className = "d03-plan__pick"; panel.dataset.role = "plan-pick"; panel.append(info, row); const indexOf = (network: PlanNetwork): number => selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1; /** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { const network = params.network(); const index = indexOf(network); if (index < 0 || !params.editable()) return; const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); sync(); } function panelState(): void { const network = params.network(); const index = indexOf(network); panel.hidden = index < 0; if (index < 0) return; const line = network.lines[index]; info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`; flipButton.hidden = dropButton.hidden = !params.editable(); flipButton.disabled = !!line.branch; } function select(line: string | null, piece: string | null = null): void { selected = line ? { line, piece } : null; panelState(); params.onSelect(); } function sync(): void { if (selected && indexOf(params.network()) < 0) selected = null; if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece)) selected = { ...selected, piece: null }; panelState(); } const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6) ?.id ?? null; /** 화면 px 둘레 마크(조각 시작) — 조각 차례. */ function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null { let best: number | null = null; let bestPx = SNAP_PX; network.pieces.forEach((piece, index) => { const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; if (!vertices || !(piece.from_m > 0)) return; const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py); if (gap <= bestPx) [best, bestPx] = [index, gap]; }); return best; } /** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */ function vertexAt( network: PlanNetwork, px: number, py: number, map: ScreenMap, ): { line: number; vertex: number } | null { let best: { line: number; vertex: number } | null = null; let bestPx = SNAP_PX; network.lines.forEach((line, i) => line.vertices.forEach((point, v) => { if (v === 0 && line.branch) return; const gap = screenGap(map, point, px, py); if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap]; }), ); return best; } /** 잠금 까닭 — 지점에 놓인 점. */ function lockOf(network: PlanNetwork, line: number, vertex: number): string | null { const point = network.lines[line].vertices[vertex]; return params.base().some((p) => samePoint(p, point)) ? "지점 자리 점은 1단계(기준 지정)에서 옮깁니다." : null; } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const index = indexOf(network); if (index < 0) return; const colors = planColors(); const line = network.lines[index]; const path = (points: Vertex[]): void => { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = map.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.stroke(); }; context.lineCap = "round"; context.lineJoin = "round"; context.strokeStyle = colors.active; context.globalAlpha = 0.45; context.lineWidth = 9; path(line.vertices); const piece = network.pieces.find((p) => p.id === selected?.piece); if (piece) { context.globalAlpha = 0.85; context.lineWidth = 6; path(sliceLine(line.vertices, piece.from_m, piece.to_m)); } context.globalAlpha = 1; // 꺾임점 손잡이 context.lineWidth = 2; context.fillStyle = "#ffffff"; for (const point of line.vertices) { const [sx, sy] = map.toScreen(point); context.fillRect(sx - 4, sy - 4, 8, 8); context.strokeRect(sx - 4, sy - 4, 8, 8); } }, press(px, py, map, button) { if (!params.enabled()) return false; const network = params.network(); const editable = params.editable(); const mark = markAt(network, px, py, map); const vertex = vertexAt(network, px, py, map); if (button === 2) { if (!editable) return false; if (mark !== null) { const piece = network.pieces[mark]; params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); sync(); return true; } if (!vertex) return false; const next = removeVertex(network, vertex.line, vertex.vertex); if (typeof next === "string") showToast(next, "info"); else params.commit(next); sync(); return true; } if (mark !== null) { const piece = network.pieces[mark]; select(piece.line, piece.id); if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } if (vertex) { const id = network.lines[vertex.line].id; const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); select(id, pieceAt(network, id, atM)); const lock = lockOf(network, vertex.line, vertex.vertex); if (editable) drag = lock ? { kind: "locked", note: lock, told: false } : { kind: "vertex", ...vertex, before: network, last: null }; return true; } const hit = lineAt(network, px, py, map); if (hit) { const id = network.lines[hit.line].id; if (editable && selected?.line === id) { params.commit(insertVertex(network, hit.line, hit.segment, hit.point)); const before = params.network(); drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; } select(id, pieceAt(params.network(), id, hit.atM)); return true; } if (!selected) return false; select(null); return true; }, hover(px, py, map) { if (!drag) return; if (drag.kind === "locked") { if (!drag.told) showToast(drag.note, "info"); drag.told = true; return; } const cursor = map.toMetric(px, py); if (drag.kind === "vertex") { drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor); } else { const piece = drag.before.pieces[drag.index]; const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor)); } params.show(drag.last); }, release() { const done = drag; drag = null; if (!done || done.kind === "locked" || !done.last) return; params.show(done.before); params.commit(done.last); sync(); }, }; panelState(); return { layer, panel, selected: () => selected, select, key(event) { if (!selected || !params.enabled()) return false; if (event.key === "Delete" && params.editable()) { apply((network, index) => removeLine(network, index)); return true; } if (event.key === "Escape") { select(null); return true; } return false; }, sync, }; }