/* ============================================================================= * B05_Profile_UI_ExpectedDraw.ts * B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸. * 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 · * 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) · * 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로. * 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 · * Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 · * Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로. * 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) → * `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`. * 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림). * 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로. * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; import { createButton, showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { askConfirm } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { createDrawKindFields, DRAW_DEFAULT_LENGTH_M, DRAW_DEFAULT_SLOPE_PCT, type DrawConstraint, } from "../A00_Common/map/map_draw_kind"; import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw"; import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan"; import { paintPreview, slopeColor, strokeCased, } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint"; import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw"; import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits"; import { leadBefore, nearestOnLine, pickNext, respliceLine, } from "./logic/B05_Profile_ExpectedDraw"; type Vertex = [number, number]; /** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */ const SNAP_PX = 12; const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다."; const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기"; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; export interface ExpectedDrawParams { projectId: string; canvas: HTMLCanvasElement; /** 도구줄 칸 · 판을 얹을 도구 줄 칸. */ button: HTMLButtonElement; host: HTMLElement; toScreen: (vertex: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; /** 보이는 화면 px → 그리기 px(돌린 지도). */ unrotate: (px: number, py: number) => [number, number]; pxPerMeter: () => number; uprightRad: () => number; redraw: () => void; /** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */ onOpen: () => void; /** 다시 추적한 평면 · 고친 예정노선([] = 지움) — 편집기 한 걸음. */ onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void; } export interface ExpectedDraw { /** 노선을 읽음 — 파일입력 예정노선 · 저장본(없으면 빈). */ load: (original: Vertex[], saved: Vertex[]) => void; /** 이번 고침(걸음 사진 칸) — null = 연 때 그대로. */ edit: () => Vertex[] | null; setEdit: (edit: Vertex[] | null) => void; /** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */ line: () => Vertex[]; paint: (context: CanvasRenderingContext2D) => void; /** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */ state: () => { open: boolean; stroke: string | null; points: number; preview: DrawPreview | null; }; } type Stroke = | { mode: "new"; points: Vertex[] } | { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] }; export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { const { canvas } = params; let original: Vertex[] = []; let saved: Vertex[] = []; let edit: Vertex[] | null = null; let open = false; let busy = false; let stroke: Stroke | null = null; let preview: DrawPreview | null = null; let fan: Fan | null = null; let hoverAt: { px: number; py: number; shift: boolean } | null = null; let grid: DesignTerrainGrid | null = null; let kind: DrawConstraint = "both"; // 기억 안 함(B9) let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; const line = (): Vertex[] => edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original; const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN); // ── 판 ───────────────────────────────────────────────── const panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) }); panel.root.classList.add("b05-routeedit__expected-draw"); panel.root.hidden = true; const hint = createText(IDLE_HINT, "muted"); const kindFields = createDrawKindFields({ value: { kind, lengthM, slopePct }, onChange: (value) => { ({ kind, lengthM, slopePct } = value); rehover(); }, }); const row = document.createElement("div"); row.className = "b05-routeedit__expected-row"; row.append( createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }), createButton({ label: "구간 다시 긋기", variant: "ghost", size: 28, onClick: () => begin("respan"), }), createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }), ); panel.body.append(row, kindFields.root, hint); params.host.append(panel.root); params.button.addEventListener("click", () => setOpen(!open)); function setOpen(next: boolean): void { open = next; stroke = null; preview = fan = null; params.button.setAttribute("aria-pressed", String(open)); params.button.classList.toggle("is-active", open); panel.root.hidden = !open; hint.textContent = IDLE_HINT; canvas.style.removeProperty("cursor"); if (open) { params.onOpen(); void loadDesignTerrainGrid(params.projectId).then((loaded) => (grid = loaded)); } params.redraw(); } function begin(mode: Stroke["mode"]): void { if (busy) return; if (mode === "respan" && line().length < 2) return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info"); stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] }; preview = fan = null; hint.textContent = (mode === "new" ? "첫 점을 누릅니다 · " : "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT; canvas.style.cursor = "crosshair"; params.redraw(); } function stop(message = IDLE_HINT): void { stroke = null; preview = fan = null; hint.textContent = message; canvas.style.removeProperty("cursor"); params.redraw(); } // ── 셈 ────────────────────────────────────────────────── /** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */ function onLine(px: number, py: number): { atM: number; point: Vertex } | null { const shown = line(); if (shown.length < 2) return null; const hit = nearestOnLine(shown, params.toMetric(px, py)); const [sx, sy] = params.toScreen(hit.point); return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null; } /** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */ function strokePoints(): Vertex[] { if (!stroke) return []; if (stroke.mode === "new") return stroke.points; return stroke.start ? [stroke.start.point, ...stroke.through] : []; } function previewAt(px: number, py: number, shift: boolean): DrawPreview | null { const points = strokePoints(); const from = points[points.length - 1]; if (!stroke || !from) return null; const cursor = params.toMetric(px, py); const prev = points.length > 1 ? points[points.length - 2] : stroke.mode === "respan" && stroke.start ? leadBefore(line(), stroke.start.atM) : null; const end = stroke.mode === "respan" ? onLine(px, py) : null; const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift); fan = pick.fan; const point = end ? end.point : pick.point; const at = point ?? cursor; const length = segmentLength(from, at); return { point: at, lengthM: length, slopePct: groundSlopePct(from, at, z), note: end ? "예정노선에 붙음 — 끝" : pick.note, blocked: !point || length <= 0, snapped: !!end, lengthOnly: pick.lengthOnly && !end, onLine: null, }; } function rehover(): void { if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift); params.redraw(); } function press(px: number, py: number, shift: boolean): void { if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info")); if (stroke.mode === "new" && !stroke.points.length) { stroke.points.push(params.toMetric(px, py)); return rehover(); } if (stroke.mode === "respan" && !stroke.start) { stroke.start = onLine(px, py); if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info"); return rehover(); } preview = previewAt(px, py, shift); if (!preview || preview.blocked) return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); if (stroke.mode === "respan" && preview.snapped) { const end = onLine(px, py)!; const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through); return void follow(next); } if (stroke.mode === "new") stroke.points.push(preview.point); else stroke.through.push(preview.point); rehover(); } function finish(): void { if (!stroke) return; if (stroke.mode === "respan") return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다."); if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info"); void follow([...stroke.points]); } /** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */ async function follow(next: Vertex[]): Promise { stop(); const ok = await askConfirm({ title: "평면선 다시 추적", message: (next.length ? "고친 예정노선" : "파일입력 예정노선") + "을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + "[저장] 전까지는 되돌리기로 물릴 수 있습니다.", okLabel: "다시 추적", }); if (!ok) return; busy = true; try { const points = next.length ? next : original; const plan = await requestJson( `/projects/${params.projectId}/route/follow`, { method: "POST", body: JSON.stringify({ points, crs: "tm" }) }, 60000, ); params.onFollow( flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits), next, ); } catch (error) { showToast( `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); } finally { busy = false; } } async function clear(): Promise { if (busy) return; if (line() === original) return showToast("고친 예정노선이 없습니다.", "info"); await follow([]); } // ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ── const at = (event: MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return params.unrotate(event.clientX - rect.left, event.clientY - rect.top); }; const claim = (event: Event): void => { event.stopImmediatePropagation(); event.preventDefault(); }; canvas.addEventListener( "pointerdown", (event) => { if (!open || event.button !== 0) return; claim(event); press(...at(event), event.shiftKey); }, true, ); canvas.addEventListener( "pointermove", (event) => { if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로 event.stopImmediatePropagation(); const [px, py] = at(event); hoverAt = { px, py, shift: event.shiftKey }; if (stroke) rehover(); }, true, ); for (const type of ["pointerup", "click", "dblclick", "contextmenu"]) canvas.addEventListener( type, (event) => { if (open && (event as MouseEvent).button !== 1) claim(event); }, true, ); canvas.addEventListener("pointerleave", () => { hoverAt = null; if (preview) ((preview = null), params.redraw()); }); const onKey = (event: KeyboardEvent): void => { if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return; if (!stroke) return; if (event.key === "Shift") { if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover()); return; } if (event.type !== "keydown") return; if (keyIs(event, "plan.drawKind")) { kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1) kindFields.set({ kind }); rehover(); } else if (keyIs(event, "plan.drawFinish")) finish(); else if (event.key === "Escape") stop(); else if (keyIs(event, "plan.drawBack")) { const points = stroke.mode === "new" ? stroke.points : stroke.through; if (!points.pop() && stroke.mode === "respan") stroke.start = null; rehover(); } else return; claim(event); }; listenWhileAlive(window, "keydown", canvas, onKey, true); listenWhileAlive(window, "keyup", canvas, onKey, true); // ── 그림 ──────────────────────────────────────────────── const map = { toScreen: params.toScreen, pxPerMeter: 1, upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void { const radians = params.uprightRad(); if (!radians) return paint(); context.save(); context.translate(x, y); context.rotate(radians); context.translate(-x, -y); paint(); context.restore(); }, }; const path = (context: CanvasRenderingContext2D, points: ReadonlyArray): void => { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = params.toScreen(point); if (i) context.lineTo(sx, sy); else context.moveTo(sx, sy); }); }; function paint(context: CanvasRenderingContext2D): void { if (!open) return; const colors = planColors(); map.pxPerMeter = params.pxPerMeter(); context.save(); context.lineCap = "round"; context.lineJoin = "round"; path(context, line()); // 고치는 예정노선 = 맨 위(D9) strokeCased(context, colors.measure, 2.5, [8, 5]); const points = strokePoints(); if (points.length > 1) { path(context, points); strokeCased(context, colors.active, 3); } points.forEach((point, i) => { const [sx, sy] = params.toScreen(point); context.beginPath(); context.arc(sx, sy, 3.5, 0, Math.PI * 2); context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active; context.fill(); }); const from = points[points.length - 1]; if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null); context.restore(); } return { load(nextOriginal, nextSaved) { original = nextOriginal; saved = nextSaved; }, edit: () => edit, setEdit(next) { edit = next; }, line, paint, state: () => ({ open, stroke: stroke?.mode ?? null, points: strokePoints().length, preview, }), }; }