/* ============================================================================= * D03_PlanAnalysis_UI_Guide.ts * 예정노선 가이드 추천선 화면(PLAN 636-2 · 요구 546) — 도구줄 「추천노선」 = 「가이드 선(지점 잇기)」 · 「끔」. * 지점 = 지점 목록 차례 · 지점 둘 이상일 때만 열림 · 높이 = 지형 칩 원천 격자 · 걸음 20 m · 기울기 ±14 % 고정(사용자 원문 · * 그리기 칸 값과 따로) · 급경사 문턱 = 급경사 범위 둘째 경계(기본 70 %). * 셈 = 공용 `map_guide_route`(브라우저만 · 저장 안 함) · 켠 동안 지점 · 지형 · 급경사 범위가 바뀌면 다시 셈(끌기 중엔 멈춘 뒤 한 번). * 그림 = 닿은 선 노랑 실선 · 못 닿은 구간 = 간 데 → 다음 지점 빨강 점선 + 알림 · 끝 지점 옆 짧은 글(길이 · 최대 기울기 · 급경사 %). * 메뉴 · 선 그리기 = 공용 `map_route_preview`(B05 추천 미리보기와 같음). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { mapToolTip } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import { guideRoute, guideTerrainFromGrid, type GuideResult, } from "../A00_Common/map/logic/map_guide_route"; import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview"; import { readSlopeBands } from "../A00_Common/map/map_slope_bands"; import type { DtmGrid } from "../common_util/common_util_surface_sampler"; import { planLabel } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; type Vertex = [number, number]; /** 사용자 원문(요구 546) — 걸음 · 기울기 고정. */ const GUIDE_STEP_M = 20; const GUIDE_MAX_GRADE = 0.14; /** 끌기 · 연이은 고침이 멈춘 뒤 셈(ms). */ const SETTLE_MS = 150; export interface GuideState { on: boolean; open: boolean; segments: number; reached: number; lengthM: number; maxGradePct: number; steepPct: number; steepLimitPct: number; ms: number | null; } /** 급경사 문턱 = 범위 경계를 작은 차례로 늘어놓은 둘째(기본 26.8 · 70 · … → 70). */ function steepLimit(): number { const edges = [...new Set(readSlopeBands().flatMap((band) => [band.low, band.high]))]; edges.sort((a, b) => a - b); return edges[1] ?? edges[0] ?? 70; } export function createPlanGuide(opts: { points: () => Vertex[]; /** 지형 칩 원천 격자(없으면 null). */ grid: () => DtmGrid | null; redraw: () => void; }): { root: HTMLElement; layer: PlanMapLayer; state: () => GuideState } { let result: GuideResult | null = null; let shownFor: Vertex[] = []; let doneKey = ""; let doneGrid: DtmGrid | null = null; let steepPct = 70; let timer = 0; let open: boolean | null = null; const menu = createRoutePreviewMenu({ items: [{ value: "guide", label: "가이드 선(지점 잇기)" }], onPick: (value) => { [result, doneKey, doneGrid] = [null, "", null]; if (value) compute(true); opts.redraw(); }, }); const on = (): boolean => menu.value() === "guide"; function compute(picked = false): void { window.clearTimeout(timer); const points = opts.points().map(([x, y]) => [x, y] as Vertex); const grid = opts.grid(); if (!grid) { menu.set(null); result = null; showToast("가이드 선 — 지형이 아직 없음", "info"); return opts.redraw(); } steepPct = steepLimit(); [doneKey, doneGrid] = [JSON.stringify([points, steepPct]), grid]; result = guideRoute(points, guideTerrainFromGrid(grid), { step: GUIDE_STEP_M, maxGrade: GUIDE_MAX_GRADE, steepPct, }); shownFor = points; const missed = result.segments.filter((segment) => !segment.reached).length; if (missed) showToast(`가이드 선 — ${missed} 구간 ±14 % 로 못 닿음(점선 = 곧은 이음)`, "info"); else if (picked) showToast(`가이드 선 — ${Math.round(result.ms)} ms`, "info"); opts.redraw(); } /** 지점 둘 미만 = 막힘(켜져 있으면 끔). */ function syncOpen(count: number): void { const next = count >= 2; if (next === open) return; open = next; menu.root.disabled = !next; mapToolTip(menu.root, next ? null : "지점 둘 이상일 때"); if (!next && on()) { menu.set(null); result = null; } } function summary(): Omit { const segments = result?.segments ?? []; const lengthM = segments.reduce((sum, segment) => sum + segment.length, 0); const steep = segments.reduce((sum, segment) => sum + segment.steepRatio * segment.length, 0); return { segments: segments.length, reached: segments.filter((segment) => segment.reached).length, lengthM, maxGradePct: Math.max(0, ...segments.map((segment) => segment.maxGrade)) * 100, steepPct: lengthM ? (steep / lengthM) * 100 : 0, steepLimitPct: steepPct, ms: result ? result.ms : null, }; } const layer: PlanMapLayer = { draw(context, map) { const points = opts.points(); syncOpen(points.length); if (!on()) return; const key = JSON.stringify([points.map(([x, y]) => [x, y]), steepLimit()]); if (key !== doneKey || opts.grid() !== doneGrid) { window.clearTimeout(timer); timer = window.setTimeout(() => compute(), SETTLE_MS); } if (!result) return; const warn = themeColor("--draw-map-warn", "#ff5a4d"); result.segments.forEach((segment, index) => { paintRoutePreview(context, segment.vertices, map.toScreen, { dash: [] }); const goal = shownFor[index + 1]; const last = segment.vertices[segment.vertices.length - 1] ?? shownFor[index]; if (!segment.reached && goal && last) paintRoutePreview(context, [last, goal], map.toScreen, { color: warn }); }); const end = shownFor[shownFor.length - 1]; if (!end) return; const s = summary(); const [x, y] = map.toScreen(end); const text = `가이드 ${(s.lengthM / 1000).toFixed(2)} km · 최대 ${s.maxGradePct.toFixed(1)} % · 급경사 지남 ${s.steepPct.toFixed(0)} %`; // 끝 지점 핀 아래 · 화면 오른쪽 반이면 오른쪽 맞춤(지점 이름 · 화면 끝에 안 걸림) const right = x > context.canvas.clientWidth / 2; planLabel(context, map, x, y + 16, text, "bold 12px", right ? "right" : "left"); }, }; return { root: menu.root, layer, state: () => ({ on: on(), open: !!open, ...summary() }), }; }