/* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만. * * 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위 * (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) · * 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함. * 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · * 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · * Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움. * 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 · * 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침). * 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함). * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; import { distanceAlong, lineLength, pointAtDistance, sliceLine, } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, moveMark, moveVertex, piecesInBox, removeLine, removeMark, removeVertex, reverseLine, samePoint, splitPiece, branchFromEnd, type PlanNetwork, type Vertex, type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; /** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; export type PlanScope = "piece" | "line" | "year"; const NEXT_SCOPE: Record = { piece: "line", line: "year", year: "piece" }; export interface PlanEditParams { network: () => PlanNetwork; /** 기준 지점(D02 · 585-1). */ base: () => Vertex[]; commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ editable: () => boolean; /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */ onSelect: () => void; } /** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */ export interface PlanSelection { pieces: string[]; scope: PlanScope; seed: string | null; mark: string | null; } export interface PlanEdit { layer: PlanMapLayer; selected: () => PlanSelection | null; /** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */ select: (pieces: string[], mark?: string | null) => void; /** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */ setScope: (scope: PlanScope) => void; /** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */ flipLine: () => void; dropLine: () => void; dropMark: () => void; /** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */ branchLine: () => void; /** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */ lineIndex: () => number; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; /** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */ sync: () => void; /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ box: () => (ViewBox & { crossing: boolean }) | null; } 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 } /** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */ | { kind: "grow"; piece: string; x: number; y: number; hit: { line: number; segment: number; point: Vertex } | null; } /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { let selected: PlanSelection | null = null; let drag: Drag | null = null; /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; const pieceIds = (network: PlanNetwork): Set => new Set(network.pieces.map((piece) => piece.id)); /** 씨 구간 → 넓이만큼 구간 묶음. */ function expand(seed: string, scope: PlanScope): string[] { const network = params.network(); const piece = network.pieces.find((p) => p.id === seed); if (!piece) return []; if (scope === "piece") return [seed]; return network.pieces .filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year)) .map((p) => p.id); } function choose(next: PlanSelection | null): void { selected = next && next.pieces.length ? next : null; params.onSelect(); } function select(pieces: string[], mark: string | null = null): void { choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark }); } function setScope(scope: PlanScope): void { const seed = selected?.seed ?? selected?.pieces[0]; if (!seed) return; choose({ pieces: expand(seed, scope), scope, seed, mark: null }); } function lineIndex(): number { const network = params.network(); if (!selected) return -1; const lines = new Set( selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line), ); if (lines.size !== 1) return -1; const [id] = lines; return network.lines.findIndex((line) => line.id === id); } /** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { const network = params.network(); const index = lineIndex(); if (!params.editable()) return; if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info"); const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); sync(); } function dropMark(): void { const network = params.network(); const piece = network.pieces.find((p) => p.id === selected?.mark); if (!piece || !params.editable()) return; params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); sync(); } /** Delete — 마크 · 선 통째 고름 = 그 선. */ function dropSelected(): void { if (selected?.mark) return dropMark(); const network = params.network(); const index = lineIndex(); const whole = index >= 0 && network.pieces .filter((p) => p.line === network.lines[index].id) .every((p) => selected!.pieces.includes(p.id)); if (!whole) return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info"); apply((net, i) => removeLine(net, i)); } /** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */ function sync(): void { if (!selected) return; const ids = pieceIds(params.network()); const pieces = selected.pieces.filter((id) => ids.has(id)); selected = pieces.length ? { ...selected, pieces, seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0], mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, } : null; } 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; } /** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */ function markPlace( px: number, py: number, map: ScreenMap, ): { point: Vertex; next: PlanNetwork | null; piece: string | null } { const network = params.network(); const hit = lineAt(network, px, py, map); if (!hit) return { point: map.toMetric(px, py), next: null, piece: null }; const next = splitPiece(network, network.lines[hit.line].id, hit.atM); const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); return { point: hit.point, next: added ? next : null, piece: added?.id ?? null }; } /** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */ function pressPiece( piece: string, shift: boolean, x: number, y: number, hit: { line: number; segment: number; point: Vertex } | null, ): void { if (shift && selected) { const has = selected.pieces.includes(piece); const pieces = has ? selected.pieces.filter((id) => id !== piece) : [...selected.pieces, piece]; return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null }); } if (selected?.pieces.includes(piece) && !selected.mark) { drag = { kind: "grow", piece, x, y, hit }; return; } select([piece]); } /** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */ const pickedLines = (network: PlanNetwork): Set => new Set( (selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""), ); const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const colors = planColors(); 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.8; context.lineWidth = 8; for (const id of selected?.pieces ?? []) { const piece = network.pieces.find((p) => p.id === id); const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices; if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m)); } context.globalAlpha = 1; // 꺾임점 손잡이(고른 구간이 든 선) const lines = pickedLines(network); context.lineWidth = 2; context.fillStyle = "#ffffff"; for (const line of network.lines) { if (!lines.has(line.id)) continue; 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); } } // 고른 마크 테 const mark = network.pieces.find((p) => p.id === selected?.mark); const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; if (mark && markLine) { const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m)); context.beginPath(); context.arc(sx, sy, 12, 0, Math.PI * 2); context.lineWidth = 3; context.strokeStyle = colors.active; context.stroke(); } if (markDrop) { const at = map.toScreen(markDrop.point); paintMark(context, at, markDrop.ok ? colors.active : colors.warn); planLabel(context, map, at[0], at[1] - 20, markDrop.text, "bold 12px"); } // 끌어 고르기 상자 — 보이는 화면 네모를 그림 px 로 되돌려 그림 if (drag?.kind === "box") { const { box } = drag; const crossing = box.x1 < box.x0; const color = themeColor(crossing ? "--draw-success" : "--draw-primary", "#26114a"); context.beginPath(); ( [ [box.x0, box.y0], [box.x1, box.y0], [box.x1, box.y1], [box.x0, box.y1], ] as const ).forEach(([x, y], i) => { const [sx, sy] = map.unrotate(x, y); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.closePath(); context.globalAlpha = 0.12; context.fillStyle = color; context.fill(); context.globalAlpha = 1; context.setLineDash(crossing ? [6, 4] : []); context.lineWidth = 1.5; context.strokeStyle = color; context.stroke(); context.setLineDash([]); } }, press(px, py, map, button, shift) { 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.id], 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)); const piece = pieceAt(network, id, atM); if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null); const lock = lockOf(network, vertex.line, vertex.vertex); if (editable && !shift) 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 piece = pieceAt(network, network.lines[hit.line].id, hit.atM); if (piece) pressPiece(piece, shift, px, py, hit); return true; } const [vx, vy] = map.rerotate(px, py); drag = { kind: "box", box: { x0: vx, y0: vy, x1: vx, y1: vy }, shift }; return true; }, hover(px, py, map) { if (!drag) return; if (drag.kind === "box") { [drag.box.x1, drag.box.y1] = map.rerotate(px, py); return; } if (drag.kind === "grow") { if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; const hit = drag.hit; if (!params.editable() || !hit) return void (drag = null); params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point)); const before = params.network(); drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; } 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; const atM = distanceAlong(vertices, cursor); const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; drag.last = off ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) : moveMark(drag.before, drag.index, atM); } params.show(drag.last); }, dragOver(px, py, map) { if (!params.enabled() || !params.editable()) return false; const place = markPlace(px, py, map); markDrop = { point: place.point, ok: !!place.next, text: place.next ? "여기서 구역 나눔" : lineAt(params.network(), px, py, map) ? "선 끝 · 다른 마커 1 m 안 — 못 놓음" : "선 위에 놓으세요", }; return true; }, drop(px, py, map) { markDrop = null; if (!params.enabled() || !params.editable()) return false; const place = markPlace(px, py, map); if (!place.next || !place.piece) return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true); params.commit(place.next); select([place.piece], place.piece); return true; }, leave() { markDrop = null; }, release(map) { const done = drag; drag = null; if (!done || done.kind === "locked") return; if (done.kind === "grow") { if (selected) { const scope = NEXT_SCOPE[selected.scope]; choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null }); } return; } if (done.kind === "box") { const { box, shift } = done; if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { if (!shift) choose(null); return; } const network = params.network(); const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); const ids = piecesInBox(network, toView, box, box.x1 < box.x0); select(shift && selected ? [...selected.pieces, ...ids] : ids); return; } if (!done.last) return; params.show(done.before); params.commit(done.last); sync(); }, }; return { layer, selected: () => selected, select, setScope, flipLine: () => apply((network, index) => reverseLine(network, index)), dropLine: () => apply((network, index) => removeLine(network, index)), dropMark, branchLine: () => apply((network, index) => branchFromEnd(network, index)), lineIndex, key(event) { if (!selected || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } if (event.key === "Escape") { choose(null); return true; } return false; }, sync, box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; }