/* ============================================================================= * D03_PlanAnalysis_UI_Draw.ts * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음. * * 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝. * 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷: * 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것). * 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움). * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { createSegment } from "@ui/ui_template_segment"; import { createText } from "@ui/ui_template_text"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { compassCandidates, compassPoint, distanceAlong, fixedLengthPoint, fixedSlopePoint, groundSlopePct, lineLength, pointAtDistance, segmentLength, stationChainages, } from "./logic/D03_PlanAnalysis_Draw"; import { addBranch, appendVertex, dropLastLine, dropLastVertex, insertVertex, reachedEnd, removeMark, splitPiece, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, PLAN_STATION_INTERVAL_M, type PlanMapLayer, type ScreenMap, } from "./D03_PlanAnalysis_UI_Map"; /** 종점 · 꺾임점에 붙는 반경(px). */ const SNAP_PX = 12; /** 선 위 누름으로 볼 반경(px). */ const LINE_HIT_PX = 8; /** 구간 길이 라벨을 보일 화면 길이(px). */ const LABEL_MIN_PX = 70; /** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ export const DRAW_DEFAULT_LENGTH_M = 20; export const DRAW_DEFAULT_SLOPE_PCT = 9; export type DrawConstraint = "free" | "length" | "slope" | "both"; export type DrawMode = "idle" | "draw" | "branch" | "mark"; export interface DrawPreview { point: Vertex; lengthM: number; slopePct: number | null; /** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */ note: string | null; blocked: boolean; snapped: boolean; } export interface PlanDrawParams { base: () => { start: Vertex; end: Vertex }; network: () => PlanNetwork; heightAt: () => (x: number, y: number) => number; commit: (next: PlanNetwork) => void; /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ onMode: () => void; } export interface PlanDraw { layer: PlanMapLayer; /** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */ panel: HTMLElement; mode: () => DrawMode; setMode: (mode: DrawMode) => void; setEditable: (editable: boolean) => void; /** 키 — 받았으면 참(Enter · Esc · Backspace). */ key: (event: KeyboardEvent) => boolean; /** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */ sync: () => void; /** 검증 창구 — 지금 미리보기 · 제약. */ preview: () => DrawPreview | null; constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; } const fmt = (value: number): string => value.toFixed(1); const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; const near = (a: [number, number], b: [number, number], px: number): boolean => Math.hypot(a[0] - b[0], a[1] - b[1]) <= px; export function createPlanDraw(params: PlanDrawParams): PlanDraw { let mode: DrawMode = "idle"; let editable = false; let active: number | null = null; // 그리는 선 차례 let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 let preview: DrawPreview | null = null; let candidates: Vertex[] = []; let kind: DrawConstraint = "free"; let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; // ── 좌측 칸 ───────────────────────────────────────────── const mainButton = createButton({ label: "본선 긋기", variant: "filled", size: 28, onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"), }); const branchButton = createButton({ label: "갈래", variant: "ghost", size: 28, onClick: () => setMode(mode === "branch" ? "idle" : "branch"), }); const markButton = createButton({ label: "구역 마크", variant: "ghost", size: 28, onClick: () => setMode(mode === "mark" ? "idle" : "mark"), }); const modeRow = document.createElement("div"); modeRow.className = "d03-plan__row"; modeRow.append(mainButton, branchButton, markButton); const kindSegment = createSegment({ options: [ { value: "free", label: "자유" }, { value: "length", label: "길이 고정" }, { value: "slope", label: "기울기 고정" }, { value: "both", label: "둘 다" }, ], value: kind, size: 24, ariaLabel: "긋기 제약", onChange: (value) => { kind = value as DrawConstraint; fieldsState(); }, }); kindSegment.root.dataset.role = "draw-constraint"; const lengthField = createInputField({ label: "길이", type: "number", value: String(lengthM), min: 1, unit: "m", size: 24, onInput: (value) => { const next = Number(value); if (next > 0) lengthM = next; lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); }, }); lengthField.input.dataset.role = "draw-length"; const slopeField = createInputField({ label: "기울기(+ 오르막)", type: "number", value: String(slopePct), unit: "%", size: 24, onInput: (value) => { const next = Number(value); if (value.trim() !== "" && Number.isFinite(next)) slopePct = next; slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자"); }, }); slopeField.input.dataset.role = "draw-slope"; const fieldRow = document.createElement("div"); fieldRow.className = "d03-plan__row"; fieldRow.append(lengthField.root, slopeField.root); const hint = createText("", "muted"); hint.classList.add("d03-plan__hint"); const panel = document.createElement("div"); panel.className = "d03-plan__draw"; panel.append(modeRow, kindSegment.root, fieldRow, hint); function fieldsState(): void { lengthField.input.disabled = kind === "free" || kind === "slope"; slopeField.input.disabled = kind === "free" || kind === "length"; } function panelState(): void { const network = params.network(); const done = reachedEnd(network, params.base().end); mainButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기"; mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0)); branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0; markButton.disabled = branchButton.disabled; branchButton.setAttribute("aria-pressed", String(mode === "branch")); markButton.setAttribute("aria-pressed", String(mode === "mark")); panel.dataset.mode = mode; hint.textContent = !editable ? "보기만 — 산림청 계정에서 긋습니다." : mode === "draw" ? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" : mode === "branch" ? "갈래를 시작할 선 위 점을 누르세요." : mode === "mark" ? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움" : done ? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요." : "[본선 긋기]로 시점에서 점을 찍어 갑니다."; } fieldsState(); // ── 셈 ────────────────────────────────────────────────── const lastOf = (line: number): Vertex | null => { const vertices = params.network().lines[line]?.vertices ?? []; return vertices[vertices.length - 1] ?? null; }; /** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */ function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { if (active === null) return null; const from = lastOf(active); if (!from) return null; const z = params.heightAt(); const cursor = map.toMetric(px, py); const end = params.base().end; candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; let point: Vertex | null = cursor; let note: string | null = null; let snapped = false; if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) { [point, note, snapped] = [end, "종점에 붙음", true]; } else if (kind === "length") { point = fixedLengthPoint(from, cursor, lengthM); } else if (kind === "slope") { point = fixedSlopePoint(from, cursor, slopePct, z); if (!point) note = `이 방향엔 ${signed(slopePct)} % 자리 없음`; } else if (kind === "both") { point = compassPoint(from, cursor, lengthM, slopePct, z); if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ${signed(slopePct)} % 자리 없음`; } const at = point ?? cursor; const length = segmentLength(from, at); return { point: at, lengthM: length, slopePct: groundSlopePct(from, at, z), note, blocked: !point || length <= 0, snapped, }; } /** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */ function lineAt( px: number, py: number, map: ScreenMap, ): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { const cursor = map.toMetric(px, py); let best: ReturnType = null; let bestPx = LINE_HIT_PX; params.network().lines.forEach((line, index) => { if (line.vertices.length < 2) return; const vertex = line.vertices.findIndex((v) => near(map.toScreen(v), [px, py], SNAP_PX)); const atM = vertex >= 0 ? lineLength(line.vertices.slice(0, vertex + 1)) : distanceAlong(line.vertices, cursor); const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); const [sx, sy] = map.toScreen(point); const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); if (gap > bestPx || (best && gap === bestPx)) return; // 누가가 든 구간(꺾임점 끼울 자리) let segment = 0; for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); if (walked >= atM) break; } best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; bestPx = gap; }); return best; } // ── 모드 ──────────────────────────────────────────────── function stop(): void { // 점 하나뿐인 갈래는 버림(본선 시점은 남김) const network = params.network(); if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2) params.commit(dropLastLine(network)); active = null; strokeStart = null; preview = null; candidates = []; } function setMode(next: DrawMode): void { if (mode === "draw") stop(); mode = editable ? next : "idle"; if (mode === "draw") { const network = params.network(); if (reachedEnd(network, params.base().end)) { showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info"); mode = "idle"; } else { active = 0; strokeStart = network; } } panelState(); params.onMode(); } function addPoint(): boolean { if (active === null || !preview) return false; if (preview.blocked) { showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); return false; } const line = active; const ended = preview.snapped; params.commit(appendVertex(params.network(), line, preview.point)); if (ended) { showToast("본선이 종점에 닿았습니다.", "success"); setMode("idle"); } return true; } const layer: PlanMapLayer = { draw(context, map) { const colors = planColors(); const { start, end } = params.base(); const network = params.network(); context.lineCap = "round"; context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; context.strokeStyle = index === active ? colors.active : colors.line; context.lineWidth = index === 0 ? 3 : 2.5; context.beginPath(); line.vertices.forEach((v, i) => { const [sx, sy] = map.toScreen(v); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); context.stroke(); drawStationTicks( context, line.vertices.map(([x, y]) => ({ x, y })), { intervalM: PLAN_STATION_INTERVAL_M, pxPerMeter: map.pxPerMeter, toScreen: (x, y) => map.toScreen([x, y]), uprightRad: map.uprightRad, chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), }, ); segmentLabels(context, map, line.vertices); }); // 꺾임점 context.fillStyle = "#ffffff"; for (const line of network.lines) for (const v of line.vertices.slice(1)) { const [sx, sy] = map.toScreen(v); context.beginPath(); context.arc(sx, sy, 3, 0, Math.PI * 2); context.fill(); } drawPieces(context, map, network); drawPreview(context, map, colors); // 시점(초록) · 종점(빨강) for (const [point, fill] of [ [start, "#16a34a"], [end, "#dc2626"], ] as const) { const [sx, sy] = map.toScreen(point); context.beginPath(); context.arc(sx, sy, 7, 0, Math.PI * 2); context.fillStyle = fill; context.fill(); context.lineWidth = 2; context.strokeStyle = "#ffffff"; context.stroke(); } }, press(px, py, map, button) { if (!editable) return false; if (mode === "draw" && button === 0) { preview = previewAt(px, py, map); return addPoint(); } if (mode === "branch" && button === 0) { const hit = lineAt(px, py, map); if (!hit) return false; let network = params.network(); let vertex = hit.vertex; if (vertex === null) { network = insertVertex(network, hit.line, hit.segment, hit.point); vertex = hit.segment + 1; } strokeStart = params.network(); params.commit(addBranch(network, hit.line, vertex)); mode = "draw"; active = params.network().lines.length - 1; panelState(); params.onMode(); return true; } if (mode === "mark") { const hit = lineAt(px, py, map); if (!hit) return false; const network = params.network(); const id = network.lines[hit.line].id; const next = button === 2 ? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter) : splitPiece(network, id, hit.atM); if (next !== network) params.commit(next); return true; } return false; }, hover(px, py, map) { preview = mode === "draw" ? previewAt(px, py, map) : null; }, leave() { preview = null; }, }; function segmentLabels( context: CanvasRenderingContext2D, map: ScreenMap, vertices: Vertex[], ): void { const z = params.heightAt(); for (let i = 1; i < vertices.length; i += 1) { const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; const slope = groundSlopePct(vertices[i - 1], vertices[i], z); const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); } } function drawPieces( context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork, ): void { const colors = planColors(); network.pieces.forEach((piece) => { const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; if (!vertices || vertices.length < 2) return; if (piece.from_m > 0) { const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); context.beginPath(); context.moveTo(sx, sy - 8); context.lineTo(sx + 6, sy); context.lineTo(sx, sy + 8); context.lineTo(sx - 6, sy); context.closePath(); context.fillStyle = colors.active; context.fill(); context.lineWidth = 1.5; context.strokeStyle = "#ffffff"; context.stroke(); } const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); }); } function drawPreview( context: CanvasRenderingContext2D, map: ScreenMap, colors: ReturnType, ): void { if (active === null || !preview) return; const from = lastOf(active); if (!from) return; const [ax, ay] = map.toScreen(from); const [bx, by] = map.toScreen(preview.point); if (kind === "both") { context.strokeStyle = colors.active; context.lineWidth = 1; context.setLineDash([3, 4]); context.beginPath(); context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2); context.stroke(); context.fillStyle = colors.active; for (const c of candidates) { const [cx, cy] = map.toScreen(c); context.beginPath(); context.arc(cx, cy, 3, 0, Math.PI * 2); context.fill(); } } context.strokeStyle = preview.blocked ? colors.warn : colors.active; context.lineWidth = 2; context.setLineDash([6, 4]); context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.stroke(); context.setLineDash([]); context.beginPath(); context.arc(bx, by, 5, 0, Math.PI * 2); context.stroke(); const value = `${fmt(preview.lengthM)} m · ` + (preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`); label( context, map, bx + 14, by - 14, preview.note ? `${value} · ${preview.note}` : value, "12px", "left", ); } function label( context: CanvasRenderingContext2D, map: ScreenMap, x: number, y: number, text: string, font: string, align: CanvasTextAlign = "center", ): void { map.upright(context, x, y, () => { context.font = `${font} system-ui, sans-serif`; context.textAlign = align; context.textBaseline = "middle"; context.lineWidth = 3; context.strokeStyle = planColors().halo; context.strokeText(text, x, y); context.fillStyle = "#ffffff"; context.fillText(text, x, y); }); } panelState(); return { layer, panel, mode: () => mode, setMode, setEditable(next) { editable = next; if (!editable) setMode("idle"); panelState(); }, key(event) { if (mode === "draw" && event.key === "Enter") { setMode("idle"); return true; } if (mode !== "idle" && event.key === "Escape") { if (mode === "draw" && strokeStart && strokeStart !== params.network()) params.commit(strokeStart); active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함 setMode("idle"); return true; } if (mode === "draw" && active !== null && event.key === "Backspace") { const network = params.network(); const step = active > 0 && network.lines[active].vertices.length < 2 ? dropLastLine(network) : dropLastVertex(network, active); if (typeof step === "string") showToast(step, "info"); else if (step !== network) params.commit(step); if (active >= params.network().lines.length) setMode("idle"); return true; } return false; }, sync() { if (active !== null && active >= params.network().lines.length) { active = null; mode = "idle"; } panelState(); }, preview: () => preview, constraint: () => ({ kind, lengthM, slopePct }), setConstraint(next, length, slope) { kind = next; kindSegment.set(next); if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)]; if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)]; fieldsState(); }, }; }