Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Guide.ts
T

163 lines
6.6 KiB
TypeScript

/* =============================================================================
* 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<GuideState, "on" | "open"> {
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() }),
};
}