- 진행 기록 `B05_Profile/route_recommend_job.json`(저장본 폴더 밖 — 저장본 glob 에 안 섞임) · `note_job` 재사용 · 갈래마다 갱신 · 끝 = running 거짓 - 진행 묻기 = 이 서버 메모리 먼저 · 없으면 기록(10 분 안 = 다른 서버가 셈 · 넘음 = 끊김 `interrupted`) - 추천 요청 409 + 「끊김」 기록 = 이어 셈 예약 한 번(기록을 곧바로 「도는 중」으로 · 뒤 요청은 안 겹침) - 미리 셈 한 갈래 409 = 그 갈래만 건너뜀(옛: 전부 멈춤) - 409 글 = 「추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함」(없는 [재계산] 단추 안내 걷음) - 화면(RecPreview) — 셈 중이면 줄 마우스 글 「추천 셈 중 n/m」 · 알림 한 번 · 3 초마다 다시 물어 끝나면 그림 · 아니면 확인창 [지금 셈](그 갈래만 recompute) · 지형 바뀌면 받아 둔 선 비움 - 시험 309_3 · 364_4 · 509_5 · 626_1 을 새 글 · 진행 꼴 · [지금 셈]에 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, number> };
|
|
}
|
|
|
|
export function createRecPreview(opts: {
|
|
projectId: string;
|
|
/** 지금 설계 지형(저장본 고름 · D7). */
|
|
terrain: () => AnalysisTerrain;
|
|
redraw: () => void;
|
|
}): RecPreview {
|
|
const lines = new Map<string, Vertex[]>();
|
|
const shown = new Map<RouteInitialLogic, Vertex[]>();
|
|
const turns = new Map<RouteInitialLogic, number>();
|
|
const waiting = new Set<RouteInitialLogic>();
|
|
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<boolean> {
|
|
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<void> {
|
|
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])),
|
|
}),
|
|
};
|
|
}
|