/* ============================================================================= * B05_Profile_UI_RouteEdit_RecPreview.ts * 평면 노선 추천 미리보기(PLAN 626-1 · 요구 530 · 결정 D1 · D7 · D8) — 평면 도구줄 아이콘 하나 + 아래 판 * (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) · * 선만(위 캔버스 · 노선 아래 그림 · 집기 없음). * 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 · * 저장본 없으면(409) — 미리 셈 도는 중 = 진행 n/m(줄 마우스 글 · 알림 한 번) · 끝나면 다시 그림 · * 아니면 확인창 「지표면이 바뀌어 다시 셈해야 함」 [지금 셈](그 갈래만 `recompute` · 763-4) · * 지형이 바뀌면 받아 둔 선을 비움. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). * 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞). * 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { askConfirm } from "@ui/ui_template_modal"; 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 { fetchPrecomputeProgress, fetchRouteInitial, type RouteInitialLogic, } from "./B05_Profile_Api_RouteInitial"; import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model"; type Vertex = [number, number]; /** 서버 `NOT_PREPARED` 머리말(`B05_Profile_Router_RouteInitial.py`) — 저장본 없음 가름(763-4). */ const NOT_PREPARED_HEAD = "추천 저장본 없음"; /** 미리 셈 진행 묻기 간격(ms). */ const POLL_MS = 3000; /** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */ const MODES = [ "gradeline", "earthwork", "gentle", "weighted", ] as const satisfies readonly RouteInitialLogic[]; /** 갈래별 선 색(`--draw-map-rec-1~4`) · 점선 꼴(색만으로 못 가르는 눈도 가름). */ const REC_VARS = ["--draw-map-rec-1", "--draw-map-rec-2", "--draw-map-rec-3", "--draw-map-rec-4"]; const REC_FALLBACK = ["#ffe94d", "#d58bff", "#ff7ab8", "#a3e635"]; const REC_COLORS = REC_VARS.map((name, index) => `var(${name}, ${REC_FALLBACK[index]})`); const REC_DASHES = [ [10, 6], [3, 6], [14, 4, 3, 4], [6, 3], ]; export interface RecPreview { root: HTMLElement; /** 노선 그리기 안(돌림 안) — 고른 선들(갈래마다 색). */ draw: (context: CanvasRenderingContext2D, toScreen: (vertex: Vertex) => [number, number]) => void; /** 검증 창구 — 고른 갈래들 · 보이는 선 점 수(갈래별). */ state: () => { modes: string[]; points: Record }; } export function createRecPreview(opts: { projectId: string; /** 지금 설계 지형(저장본 고름 · D7). */ terrain: () => AnalysisTerrain; redraw: () => void; }): RecPreview { const lines = new Map(); const shown = new Map(); const turns = new Map(); const waiting = new Set(); let seenTerrain = opts.terrain(); const menu = createRoutePreviewMenu({ items: MODES.map((mode, index) => ({ value: mode, label: LOGIC_LABEL[mode], color: REC_COLORS[index], })), onPick: (mode) => void pick(mode as RouteInitialLogic), }); /** 미리 셈이 도는 중이면 진행 n/m 을 보이고 끝난 뒤 다시 고름 — 돌면 true(763-4). */ async function waitForPrecompute(mode: RouteInitialLogic, mine: number): Promise { const progress = await fetchPrecomputeProgress(opts.projectId); if (!progress?.running || mine !== turns.get(mode)) return false; const note = `추천 셈 중 ${progress.done}/${progress.total}`; menu.setItem(mode, { title: note }); if (!waiting.has(mode)) showToast(`${note} — 끝나면 그림`, "info"); waiting.add(mode); window.setTimeout(() => { if (mine === turns.get(mode) && menu.values().includes(mode)) void pick(mode); }, POLL_MS); return true; } async function pick(mode: RouteInitialLogic, recompute = false): Promise { const mine = (turns.get(mode) ?? 0) + 1; turns.set(mode, mine); shown.delete(mode); opts.redraw(); if (!menu.values().includes(mode)) return; const terrain = opts.terrain(); if (terrain !== seenTerrain) lines.clear(); // 지형 바뀜 = 옛 선 버림(763-4) seenTerrain = terrain; const key = `${mode}|${terrain}`; try { if (!lines.has(key) || recompute) { const body = (await fetchRouteInitial(opts.projectId, { mode, with_profile: true, terrain, ...(recompute ? { recompute: true } : {}), })) as { planned?: number[][] }; const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex); if (planned.length < 2) throw new Error("추천 선이 비었음"); lines.set(key, planned); } if (mine !== turns.get(mode)) return; waiting.delete(mode); menu.setItem(mode, { title: "" }); shown.set(mode, lines.get(key)!); opts.redraw(); } catch (error) { if (mine !== turns.get(mode)) return; const text = error instanceof Error ? error.message : String(error); if (!recompute && (await waitForPrecompute(mode, mine))) return; waiting.delete(mode); menu.setItem(mode, { checked: false, title: "" }); const now = text.startsWith(NOT_PREPARED_HEAD) && (await askConfirm({ title: "추천 노선", message: `「${LOGIC_LABEL[mode]}」 ${text}. 지금 셀까요? 몇 분 걸릴 수 있습니다.`, okLabel: "지금 셈", })); if (now) { menu.setItem(mode, { checked: true }); showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 셈 중 — 끝나면 그림`, "info"); return void pick(mode, true); } if (!text.startsWith(NOT_PREPARED_HEAD)) showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); } } return { root: menu.root, draw(context, toScreen) { MODES.forEach((mode, index) => { const line = shown.get(mode); if (line) paintRoutePreview(context, line, toScreen, { color: themeColor(REC_VARS[index], REC_FALLBACK[index]), dash: REC_DASHES[index], }); }); }, state: () => ({ modes: menu.values(), points: Object.fromEntries([...shown].map(([mode, line]) => [mode, line.length])), }), }; }