Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Guide.ts
T
eomsangdonandClaude Sonnet 5.5 1288e21e55 feat(756-1): 추천 선 진하게 · 스냅 한 아이콘 · 추천노선 여럿 고르기 · 켠 동안 등고선 띠 넓힘
- 예정노선 수정 켬에도 추천 선 불투명 그대로(Render)
- 스냅 + 스냅 간격 = 고르기 메뉴 하나(측점 No. · 10 m · 끔) · 도구줄 표 snap-step 줄 걷음
- 추천노선 = 여럿 고르기(공용 부품 multi · 줄 색 점 PickMenuItem.color · 끔 줄 없음) · 선 색 --draw-map-rec-1~4 · 예정노선 가이드 선은 켬끔 한 줄
- 켠 동안 등고선 띠 = LAS 는 자르지 않음 · 도엽은 계획선 + 예정노선으로 넓힘(캔버스 · GL · 도엽 다시 받기)
- 시험 624_2 · 634_2 · 626_1 · 636_2 · 390_4 · b05_routeedit_initial 을 새 꼴로 고침 · 새 시험 756_1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:58:28 +09:00

164 lines
6.6 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Guide.ts
* 예정노선 가이드 추천선 화면(PLAN 636-2 · 요구 546) — 도구줄 「추천노선」 = 「가이드 선(지점 잇기)」 · 「끔」.
* 지점 = 지점 목록 차례 · 지점 둘 이상일 때만 열림 · 높이 = 지형 칩 원천 격자 · 걸음 20 m · 기울기 ±14 % 고정(사용자 원문 ·
* 그리기 칸 값과 따로) · 급경사 문턱 = 급경사 범위 둘째 경계(기본 70 %).
* 셈 = 공용 `map_guide_route`(브라우저만 · 저장 안 함) · 켠 동안 지점 · 지형 · 급경사 범위가 바뀌면 다시 셈(끌기 중엔 멈춘 뒤 한 번).
* 메뉴 = 켬끔 한 줄(여럿 고르기 부품 · 「끔」 줄 없음 · 589-12).
* 그림 = 닿은 선 노랑 실선 · 못 닿은 구간 = 간 데 → 다음 지점 빨강 점선 + 알림 · 끝 지점 옆 짧은 글(길이 · 최대 기울기 · 급경사 %).
* 메뉴 · 선 그리기 = 공용 `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: () => {
[result, doneKey, doneGrid] = [null, "", null];
if (on()) compute(true);
opts.redraw();
},
});
const on = (): boolean => menu.values().includes("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() }),
};
}