feat(636-2): 추천노선 메뉴 · 선 그리기 공용(map_route_preview) · 예정노선 가이드 선(지점 잇기 · 20 m · ±14 % · 급경사 문턱 = 범위 둘째 경계 · 보기만)(요구 546)

- B05 추천 미리보기 = 공용 메뉴 · 그리기 부름(저장본 넷 · 동작 그대로)
- 도구 표 recommend 줄 = 두 페이지
- test_626_1 = 메뉴 검사를 공용 파일로 옮김(B05 파일이 createPickMenu 를 직접 안 부름)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 11:55:21 +09:00
co-authored by Claude Opus 5.5
parent 1ee98dd8ca
commit 527e50180b
8 changed files with 297 additions and 29 deletions
+56
View File
@@ -0,0 +1,56 @@
/* =============================================================================
* map_route_preview.ts
* 공용 「추천노선」 미리보기(PLAN 626-1 → 636-2 공용 · 요구 530 · 546) — B05 평면 · 예정노선(D03)이 같이 씀.
* 메뉴 = 도구줄 아이콘 하나(compass · 호버 「추천노선」) + 아래 판(항목 하나만 · 맨 끝 「끔」 · 부품 `createPickMenu`).
* 그리기 = 지도 위 선(노랑 `--draw-map-sketch` · 검은 테 · 기본 점선) — 위성 · 어두운 바탕 모두 보임.
* 항목 · 선 셈은 페이지 몫: B05 = 노선 추천 저장본 넷(`B05_Profile_UI_RouteEdit_RecPreview`) ·
* 예정노선 = 가이드 선(`D03_PlanAnalysis_UI_Guide`).
* ========================================================================== */
import { createPickMenu, type PickMenu, type PickMenuItem } from "@ui/ui_template_context_menu";
import { themeColor } from "@ui/ui_template_palette";
type Vertex = [number, number] | [number, number, number];
/** 「추천노선」 메뉴 — 고름 = `onPick(값)` · 「끔」 = `onPick(null)`. */
export function createRoutePreviewMenu(opts: {
items: PickMenuItem[];
onPick: (value: string | null) => void;
}): PickMenu {
const menu = createPickMenu({
icon: "compass",
title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤)
items: opts.items,
offLabel: "끔",
onPick: opts.onPick,
});
menu.root.dataset.act = "recommend";
return menu;
}
/** 미리보기 선 하나 — 검은 테(5 px) 위 색 선(3 px) · 기본 노랑 점선. */
export function paintRoutePreview(
context: CanvasRenderingContext2D,
line: readonly Vertex[],
toScreen: (vertex: [number, number]) => [number, number],
style: { dash?: number[]; color?: string } = {},
): void {
if (line.length < 2) return;
context.save();
context.setLineDash(style.dash ?? [10, 6]);
context.lineJoin = "round";
context.beginPath();
line.forEach((vertex, index) => {
const [x, y] = toScreen([vertex[0], vertex[1]]);
if (index) context.lineTo(x, y);
else context.moveTo(x, y);
});
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
context.lineWidth = 5;
context.stroke();
context.strokeStyle = style.color ?? themeColor("--draw-map-sketch", "#ffe94d");
context.lineWidth = 3;
context.stroke();
context.restore();
}
+2 -2
View File
@@ -75,8 +75,8 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
{
key: "recommend",
title: "추천노선",
does: "노선 추천 저장본 선 하나만 보기 · 끔(626-1 · 예정노선 = 636-2)",
pages: B05,
does: "B05 = 노선 추천 저장본 선 하나만 · 예정노선 = 가이드 선(지점 잇기 · 636-2) · 끔",
pages: BOTH,
},
],
[{ key: "terrain", title: "수치지형도/라이다", does: "지형 원천 보기 · 고르기", pages: BOTH }],
@@ -5,11 +5,11 @@
* 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 ·
* 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김).
* 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞).
* 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀).
* ========================================================================== */
import { createPickMenu } from "@ui/ui_template_context_menu";
import { showToast } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview";
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
@@ -41,14 +41,10 @@ export function createRecPreview(opts: {
const lines = new Map<string, Vertex[]>();
let shown: Vertex[] | null = null;
let turn = 0;
const menu = createPickMenu({
icon: "compass",
title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤)
const menu = createRoutePreviewMenu({
items: MODES.map((mode) => ({ value: mode, label: LOGIC_LABEL[mode] })),
offLabel: "끔",
onPick: (mode) => void pick(mode as RouteInitialLogic | null),
});
menu.root.dataset.act = "recommend";
async function pick(mode: RouteInitialLogic | null): Promise<void> {
const mine = ++turn;
@@ -81,24 +77,7 @@ export function createRecPreview(opts: {
return {
root: menu.root,
draw(context, toScreen) {
if (!shown) return;
context.save();
context.setLineDash([10, 6]);
context.lineJoin = "round";
context.beginPath();
shown.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index) context.lineTo(x, y);
else context.moveTo(x, y);
});
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
context.lineWidth = 5;
context.stroke();
context.strokeStyle = themeColor("--draw-map-sketch", "#ffe94d");
context.lineWidth = 3;
context.stroke();
context.restore();
if (shown) paintRoutePreview(context, shown, toScreen);
},
state: () => ({ mode: menu.value(), points: shown?.length ?? 0 }),
};
@@ -0,0 +1,160 @@
/* =============================================================================
* 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)} %`;
planLabel(context, map, x, y - 22, text, "bold 12px");
},
};
return {
root: menu.root,
layer,
state: () => ({ on: on(), open: !!open, ...summary() }),
};
}
@@ -124,6 +124,8 @@ export interface PlanMapEvents {
onMeasure: () => void;
/** 도구줄 끝 지형 칸(624-4 · 칩 + LAS 올리기 = `D03_PlanAnalysis_UI_LidarUpload`). */
terrain: HTMLElement;
/** 도구줄 「추천노선」 = 가이드 선 메뉴(636-2 · `D03_PlanAnalysis_UI_Guide`). */
recommend?: HTMLElement;
}
export interface PlanTools {
@@ -248,6 +250,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
slopes: markButton("slopes"),
measure: measureButton,
terrain: events.terrain,
recommend: events.recommend,
}),
});
const toolbar = tools.root;
+13 -1
View File
@@ -50,6 +50,7 @@ import {
type TerrainStart,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanGuide } from "./D03_PlanAnalysis_UI_Guide";
import { bindPlanInput, createPlanKit } from "./D03_PlanAnalysis_UI_Kit";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import { createPlanTerrain } from "./D03_PlanAnalysis_UI_LidarUpload";
@@ -210,8 +211,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
work,
onChange: () => (void backdrop.reterrain(), refresh()),
});
// 가이드 추천선(636-2) — 지점 차례 · 지형 칩 원천 · 보기만
const guide = createPlanGuide({
points: () => points,
grid: () => terrain.surface(surface)?.grid ?? null,
redraw: () => map.redraw(),
});
const map = createPlanMap({
terrain: terrain.root,
recommend: guide.root,
onUndo: undo,
onRedo: redo,
on3d: open3d,
@@ -314,7 +322,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
/** 겹 차례 = 키트(바탕 겹 아래 · 재기 · 보기 가운데 = 고르기 위 · 핀 그림 · 우클릭 메뉴 맨 위). */
const layerList = (): PlanMapLayer[] =>
kit.layers(backdrop.layers, [...(measuring ? [measure.layer] : []), viewWatch]);
kit.layers(
[...backdrop.layers, guide.layer],
[...(measuring ? [measure.layer] : []), viewWatch],
);
/** 점들이 받은 범위 안인가. */
const inside = (list: Vertex[]): boolean =>
@@ -568,6 +579,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
heightAt: z,
layers: backdrop.state,
terrain: terrain.state,
guide: guide.state,
};
// 키 = 등록부(588-3 · 한글 자판도 자판 자리로) · 되돌리기 · 다시하기 · 재기 끔 = 키트 `onKey` 먼저
const onKey = (event: KeyboardEvent): void => {
@@ -42,7 +42,9 @@ def test_미리보기_넷_하나만_저장본만() -> None:
src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts")
modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1)
assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"]
assert "recompute" not in src and "createPickMenu({" in src and 'offLabel: "끔"' in src
assert "recompute" not in src and "createRoutePreviewMenu({" in src
shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기
assert "createPickMenu({" in shared and 'offLabel: "끔"' in shared
assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7)
chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts")
assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome
@@ -0,0 +1,56 @@
"""PLAN 636-2 — 「추천노선」 메뉴 · 선 그리기 공용 · 예정노선 가이드 선(요구 546).
- 공용 `map_route_preview` = 메뉴(compass · 「끔」 · data-act) + 노랑 · 검은 테 선 — B05 · 예정노선이 같이 씀.
- 도구 표 `recommend` 줄 = 두 페이지 · 호버 「추천노선」.
- 예정노선 = 「가이드 선(지점 잇기)」 하나 · 20 m · ±14 % 고정 · 급경사 문턱 = 범위 둘째 경계 · 저장 · 서버 없음.
- B05 = 저장본 넷 그대로 · 가이드 항목 없음.
./venv/Scripts/python.exe -m pytest resources/tester/test_636_2_guide_screen.py -q
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
read = lambda *parts: ROOT.joinpath(*parts).read_text(encoding="utf-8") # noqa: E731
def test_공용_메뉴_그리기() -> None:
src = read("A00_Common", "map", "map_route_preview.ts")
assert 'icon: "compass"' in src and 'offLabel: "끔"' in src
assert 'dataset.act = "recommend"' in src and "--draw-map-sketch" in src
for name in (
"B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts",
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Guide.ts",
):
page = read(name)
assert "createRoutePreviewMenu({" in page and "paintRoutePreview(" in page, name
assert "createPickMenu" not in page, name
def test_도구_표_두_페이지() -> None:
src = read("A00_Common", "map", "map_toolbar.ts")
row = src[src.index('key: "recommend"') :]
row = row[: row.index("}")]
assert 'title: "추천노선"' in row and "pages: BOTH" in row
assert "recommend: events.recommend" in read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Map.ts")
def test_예정노선_가이드_고정값_보기만() -> None:
src = read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Guide.ts")
assert "GUIDE_STEP_M = 20" in src and "GUIDE_MAX_GRADE = 0.14" in src
assert 'label: "가이드 선(지점 잇기)"' in src
assert "guideRoute(" in src and "guideTerrainFromGrid(" in src and "readSlopeBands()" in src
assert "count >= 2" in src # 지점 둘 이상일 때만
for banned in ("fetch(", "writeState", "sessionStorage", "constraint"):
assert banned not in src, banned
page = read("D03_PlanAnalysis", "D03_PlanAnalysis_UI_Page.ts")
assert (
"recommend: guide.root" in page and "guide.layer" in page and "guide: guide.state" in page
)
assert len(page.splitlines()) <= 700
def test_B05_저장본_넷_그대로() -> None:
src = read("B05_Profile", "B05_Profile_UI_RouteEdit_RecPreview.ts")
assert '"gradeline",\n "earthwork",\n "gentle",\n "weighted",' in src.replace("\r\n", "\n")
assert "가이드 선(지점 잇기)" not in src and "guideRoute" not in src