/* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만. * * 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 · * 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. * 「같은 해」 = 왼쪽 해 줄로만(`selectYear`). * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) · * 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함. * 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 · * 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 · * 선 밖으로 끌어냄 = 지움(합침). * 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크. * 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음). * 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로. * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함). * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, moveMark, moveNodesAt, moveVertex, removeLine, removeMark, removeVertex, samePoint, splitPiece, type PlanNetwork, type Vertex, type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; import { movePointsAt, nodeSegment, removeAll, removeSegments, segmentsInBox, type PlanNode, type PlanSegment, } from "./logic/D03_PlanAnalysis_Select"; import { lineAt, paintMark, planLabel, screenGap, SNAP_PX, strokeCased, } from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select"; export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select"; /** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; export interface PlanEditParams { network: () => PlanNetwork; /** 지점(긋기에 붙는 점 · 591-2). */ base: () => Vertex[]; commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; /** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */ commitPoints: (points: Vertex[], next: PlanNetwork) => void; showPoints: (points: Vertex[], next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ editable: () => boolean; /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */ onSelect: () => void; } /** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */ export interface PlanHit { mark: string | null; node: PlanNode | null; segment: PlanSegment | null; } export interface PlanEdit { layer: PlanMapLayer; selected: () => PlanSelection | null; /** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */ paths: () => Vertex[][]; /** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */ select: (pieces: string[], mark?: string | null) => void; /** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */ setScope: (scope: PlanScope) => void; selectNode: (node: PlanNode) => void; selectRegion: (line: string) => void; selectYear: (year: number) => void; selectAll: () => void; /** 고른 것 지움(Delete · 판 [지움]). */ dropSelected: () => void; dropNode: (node: PlanNode) => void; dropMark: () => void; dropRegion: (line: string) => void; /** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */ dropAll: () => void; /** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */ splitAt: (px: number, py: number, map: ScreenMap) => boolean; /** 이 자리에 무엇이 있나(우클릭 메뉴). */ hit: (px: number, py: number, map: ScreenMap) => PlanHit; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; /** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */ sync: () => void; /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ box: () => (ViewBox & { crossing: boolean }) | null; } type Drag = | { kind: "vertex"; line: number; vertex: number; /** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */ from: Vertex; follow: boolean; before: PlanNetwork; points: Vertex[]; last: [Vertex[], PlanNetwork] | null; } | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } /** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */ | { kind: "press"; x: number; y: number; node: PlanNode | null; on: { line: number; segment: number; point: Vertex } | null; grow: boolean; } /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { const sel = createPlanSelect(params.network, params.onSelect); let drag: Drag | null = null; /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; const lineIndexOf = (network: PlanNetwork, id: string): number => network.lines.findIndex((line) => line.id === id); /** 꼴 바꿈 하나 — 까닭 글이면 알림. */ function apply(next: PlanNetwork | string): void { if (!params.editable()) return; if (typeof next === "string") return void showToast(next, "info"); params.commit(next); sel.sync(); } function dropMark(): void { const network = params.network(); const piece = network.pieces.find((p) => p.id === sel.get()?.mark); if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3)); } function dropNode(node: PlanNode): void { const network = params.network(); const index = lineIndexOf(network, node.line); if (index >= 0) apply(removeVertex(network, index, node.vertex)); } function dropRegion(line: string): void { const network = params.network(); const index = lineIndexOf(network, line); if (index >= 0) apply(removeLine(network, index)); } function dropAll(): void { const network = params.network(); if (!network.lines.length) return; apply(removeAll(network)); showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info"); } /** Delete · 판 [지움] — 고름 꼴마다. */ function dropSelected(): void { const picked = sel.get(); if (!picked) return; if (picked.kind === "mark") return dropMark(); if (picked.kind === "node") return dropNode(picked.node!); if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments)); if (picked.kind === "line") return dropRegion(picked.line!); if (picked.kind === "all") return dropAll(); showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info"); } /** 화면 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; } /** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */ function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null { const near = params .base() .filter((p) => !samePoint(p, skip)) .map((p) => ({ p, gap: screenGap(map, p, px, py) })) .filter((item) => item.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; return near ? near.p : null; } function hit(px: number, py: number, map: ScreenMap): PlanHit { const network = params.network(); const mark = markAt(network, px, py, map); if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null }; const vertex = vertexAt(network, px, py, map); if (vertex) { const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex }; return { mark: null, node, segment: nodeSegment(network, node) }; } const on = lineAt(network, px, py, map); if (!on) return { mark: null, node: null, segment: null }; return { mark: null, node: null, segment: { line: network.lines[on.line].id, index: on.segment }, }; } /** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */ function markPlace( px: number, py: number, map: ScreenMap, ): { point: Vertex; next: PlanNetwork | null; piece: string | null } { const network = params.network(); const on = lineAt(network, px, py, map); if (!on) return { point: map.toMetric(px, py), next: null, piece: null }; const next = splitPiece(network, network.lines[on.line].id, on.atM); const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); return { point: on.point, next: added ? next : null, piece: added?.id ?? null }; } function splitAt(px: number, py: number, map: ScreenMap): boolean { if (!params.enabled() || !params.editable()) return false; const place = markPlace(px, py, map); if (!place.next || !place.piece) { showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"); return false; } params.commit(place.next); sel.pieces([place.piece], place.piece); return true; } /** 노드 끌기 시작 — 지점 자리면 지점도 같이. */ function startVertex(line: number, vertex: number): Drag { const before = params.network(); const from = before.lines[line].vertices[vertex]; const points = params.base(); const follow = points.some((p) => samePoint(p, from)); return { kind: "vertex", line, vertex, from, follow, before, points, last: null }; } /** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */ function pickedLines(network: PlanNetwork): Set { const picked = sel.get(); if (!picked) return new Set(); if (picked.kind === "all") return new Set(network.lines.map((line) => line.id)); const ids = picked.segments.map((seg) => seg.line); for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? ""); if (picked.line) ids.push(picked.line); return new Set(ids); } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const colors = planColors(); const picked = sel.get(); context.lineCap = "round"; context.lineJoin = "round"; for (const points of sel.paths()) { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = map.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-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 node = picked?.kind === "node" ? picked.node : null; const nodePoint = node ? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex] : null; const mark = network.pieces.find((p) => p.id === picked?.mark); const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null); if (ring) { const [sx, sy] = map.toScreen(ring); 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() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu) const network = params.network(); const mark = markAt(network, px, py, map); if (mark !== null) { const piece = network.pieces[mark]; sel.pieces([piece.id], piece.id); if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } const at = hit(px, py, map); if (at.segment) { if (shift) return (sel.segments([at.segment], true), true); const grow = sel.holds({ node: at.node ?? undefined, segment: at.node ? undefined : at.segment, }); if (!grow) { if (at.node) sel.node(at.node); else sel.segments([at.segment]); } const on = at.node ? null : lineAt(network, px, py, map); drag = { kind: "press", x: px, y: py, node: at.node, on, grow }; 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 === "press") { if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; const { node, on, grow } = drag; const network = params.network(); if (!params.editable()) return void (drag = null); if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex); else if (grow && on) { params.commit(insertVertex(network, on.line, on.segment, on.point)); drag = startVertex(on.line, on.segment + 1); } else return void (drag = null); } const cursor = map.toMetric(px, py); if (drag.kind === "vertex") { const to = pointNear(px, py, map, drag.from) ?? cursor; drag.last = drag.follow ? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)] : [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)]; params.showPoints(...drag.last); return; } if (drag.kind !== "mark") return; 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; splitAt(px, py, map); return true; }, leave() { markDrop = null; }, release(map) { const done = drag; drag = null; if (!done) return; if (done.kind === "press") { if (done.grow) sel.grow(); return; } if (done.kind === "box") { const { box, shift } = done; if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { if (!shift) sel.clear(); return; } const network = params.network(); const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); const found = segmentsInBox(network, toView, box, box.x1 < box.x0); const now = sel.get(); sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found); return; } if (!done.last) return; if (done.kind === "vertex") { params.showPoints(done.points, done.before); params.commitPoints(...done.last); } else { params.show(done.before); params.commit(done.last); } sel.sync(); }, }; /** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */ function setScope(scope: PlanScope): void { const network = params.network(); const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]); if (!seed) return; if (scope === "line") return sel.region(seed.line); if (scope === "piece") return sel.pieces([seed.id]); const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id); sel.pieces([seed.id, ...same], null, "year"); } return { layer, selected: sel.get, paths: sel.paths, select: (pieces, mark = null) => sel.pieces(pieces, mark), setScope, selectNode: sel.node, selectRegion: sel.region, selectYear(year) { const ids = params .network() .pieces.filter((p) => p.year === year) .map((p) => p.id); sel.pieces(ids, null, "year"); }, selectAll: sel.all, dropSelected, dropNode, dropMark, dropRegion, dropAll, splitAt, hit, key(event) { if (!sel.get() || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } if (event.key === "Escape") { sel.clear(); return true; } return false; }, sync: sel.sync, box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; }