163 lines
6.6 KiB
TypeScript
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() }),
|
|
};
|
|
}
|