/* ============================================================================= * D03_PlanAnalysis_UI_Draw.ts * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음. * * [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) · * 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 * 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 — * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래. * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. * 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`. * 키 = 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 { compassCandidates, compassPoint, fixedLengthPoint, fixedSlopePoint, groundSlopePct, segmentLength, } from "./logic/D03_PlanAnalysis_Draw"; import { addBranch, appendVertex, attachHead, dropLastVertex, insertVertex, newLine, removeLine, reverseLine, samePoint, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { fmt, lineAt, paintNetwork, paintPreview, screenGap, slopeDeg, SNAP_PX, } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; /** 제약 칸 처음 값 — 길이 = 측점 간격(지식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"; export interface DrawPreview { point: Vertex; lengthM: number; slopePct: number | null; /** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */ note: string | null; blocked: boolean; snapped: boolean; /** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */ onLine: { line: number; segment: number } | null; } export interface PlanDrawParams { /** 기준 지점(D02 지점 목록 · 585-1). */ base: () => 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; stroke: () => { line: number; flip: boolean } | null; constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; } export function createPlanDraw(params: PlanDrawParams): PlanDraw { let mode: DrawMode = "idle"; let editable = false; let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null) let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음 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 drawButton = createButton({ label: "긋기", variant: "filled", size: 28, onClick: () => setMode(mode === "draw" ? "idle" : "draw"), }); const modeRow = document.createElement("div"); modeRow.className = "d03-plan__row"; modeRow.append(drawButton); 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); const ok = value.trim() !== "" && Number.isFinite(next); if (ok) slopePct = next; slopeField.setError(ok ? undefined : "숫자"); degText(); }, }); slopeField.input.dataset.role = "draw-slope"; const slopeDegText = createText("", "muted"); slopeDegText.dataset.role = "draw-slope-deg"; const degText = (): void => { slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`; }; degText(); const fieldRow = document.createElement("div"); fieldRow.className = "d03-plan__row"; fieldRow.append(lengthField.root, slopeField.root, slopeDegText); 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 { drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기"; drawButton.disabled = !editable; drawButton.setAttribute("aria-pressed", String(mode === "draw")); panel.dataset.mode = mode; hint.textContent = !editable ? "보기만 — 산림청 계정에서 긋습니다." : mode === "draw" ? active === null ? "지점 · 선 끝 · 선 위를 눌러 시작합니다." : "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" : "[긋기]로 지점 · 선에서 긋습니다 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다."; } fieldsState(); // ── 셈 ────────────────────────────────────────────────── const lastOf = (line: number): Vertex | null => { const vertices = params.network().lines[line]?.vertices ?? []; return vertices[vertices.length - 1] ?? null; }; /** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */ function snapAt( px: number, py: number, map: ScreenMap, ): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null { const network = params.network(); const head = active === null ? undefined : network.lines[active]?.vertices[0]; const offers = params .base() .filter((point) => !head || !samePoint(point, head)) .map((point) => ({ point, note: "지점에 붙음" })); network.lines.forEach((line, i) => { if (i !== active) for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" }); }); const best = offers .map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) })) .filter((offer) => offer.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; if (best) return { point: best.point, note: best.note, onLine: null }; const hit = lineAt(network, px, py, map, active); return hit ? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } } : 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); candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; const snap = snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; if (snap) { [point, note] = [snap.point, snap.note]; } else if (kind === "length") { point = fixedLengthPoint(from, cursor, lengthM); } else if (kind === "slope") { point = fixedSlopePoint(from, cursor, slopePct, z); if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`; } else if (kind === "both") { point = compassPoint(from, cursor, lengthM, slopePct, z); if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(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: !!snap, onLine: snap?.onLine ?? null, }; } // ── 모드 ──────────────────────────────────────────────── /** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */ function stop(): void { let network = params.network(); const line = active; const entry = line === null ? null : network.lines[line]; if (line !== null && entry) { if (entry.vertices.length < 2) { const dropped = removeLine(network, line); if (typeof dropped !== "string") network = dropped; } else if (flip) { const turned = reverseLine(network, line); if (typeof turned !== "string") network = attachHead(turned, line); } else { const tail = entry.vertices[entry.vertices.length - 1]; const next = network.lines.findIndex( (other, i) => i !== line && !other.branch && other.vertices.length > 1 && samePoint(other.vertices[0], tail), ); if (next >= 0) network = attachHead(network, next); } if (network !== params.network()) params.commit(network); } active = null; flip = false; strokeStart = null; preview = null; candidates = []; } function setMode(next: DrawMode): void { if (mode === "draw") stop(); mode = editable ? next : "idle"; panelState(); params.onMode(); } /** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래. 참 = 시작함. */ function begin(px: number, py: number, map: ScreenMap): boolean { const network = params.network(); const points = params.base(); const gap = (point: Vertex): number => screenGap(map, point, px, py); const nearest = (items: T[]): T | undefined => items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0]; const onBase = (list: Vertex[], point: Vertex): boolean => list.some((p) => samePoint(p, point)); strokeStart = network; // 1) 선 끝 — 꼬리 · 갈래 없는 선 머리(지점에 놓인 끝 빼고 — 지점 누름 = 새 선 · 갈래) const tips: Array<{ line: number; head: boolean; gap: number }> = []; network.lines.forEach((line, i) => { const tail = line.vertices[line.vertices.length - 1]; const head = line.vertices[0]; if (tail && line.vertices.length > 1 && !onBase(points, tail)) tips.push({ line: i, head: false, gap: gap(tail) }); if (line.vertices.length > 1 && !line.branch && !onBase(points, head)) tips.push({ line: i, head: true, gap: gap(head) }); }); const tip = nearest(tips); if (tip) { if (tip.head) { const turned = reverseLine(network, tip.line); if (typeof turned === "string") { showToast(turned, "info"); return false; } params.commit(turned); } [active, flip] = [tip.line, tip.head]; return true; } // 2) 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점) const base = nearest(points.map((point) => ({ point, gap: gap(point) }))); if (base) { const { point } = base; const owner = network.lines.findIndex( (line) => line.vertices.length > 1 && line.vertices.some((v) => samePoint(v, point)), ); if (owner >= 0) { const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point)); params.commit(addBranch(network, owner, vertex)); } else params.commit(newLine(network, point)); [active, flip] = [params.network().lines.length - 1, false]; return true; } // 3) 선 위 · 꺾임점 = 갈래 const hit = lineAt(network, px, py, map); if (hit) { let next = network; let vertex = hit.vertex; if (vertex === null) { next = insertVertex(next, hit.line, hit.segment, hit.point); vertex = hit.segment + 1; } params.commit(addBranch(next, hit.line, vertex)); [active, flip] = [params.network().lines.length - 1, false]; return true; } strokeStart = null; showToast("지점 · 선 끝 · 선 위를 눌러 시작합니다.", "info"); return false; } 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; let next = params.network(); if (preview.onLine) next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point); params.commit(appendVertex(next, line, preview.point)); if (ended) { showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success"); setMode("idle"); } return true; } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); paintNetwork(context, map, network, active, params.heightAt(), () => { const from = active === null ? null : lastOf(active); if (from && preview) paintPreview( context, map, from, preview, kind === "both" ? { lengthM, candidates } : null, ); }); }, press(px, py, map, button) { if (!editable) return false; if (mode === "draw" && button === 0) { if (active === null) { const started = begin(px, py, map); panelState(); params.onMode(); return started; } preview = previewAt(px, py, map); return addPoint(); } return false; }, hover(px, py, map) { preview = mode === "draw" ? previewAt(px, py, map) : null; }, leave() { preview = null; }, }; 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 === "draw" && 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 = network.lines[active].vertices.length < 2 ? removeLine(network, active) : dropLastVertex(network, active); if (typeof step === "string") showToast(step, "info"); else if (step !== network) params.commit(step); if (active >= params.network().lines.length) { active = null; setMode("idle"); } return true; } return false; }, sync() { if (active !== null && active >= params.network().lines.length) { active = null; flip = false; mode = "idle"; } panelState(); }, preview: () => preview, stroke: () => (active === null ? null : { line: active, flip }), 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)]; degText(); } fieldsState(); }, }; }