/* ============================================================================= * B05_Profile_UI_RouteEdit_Initial.ts * 계획노선 편집 창의 **노선 추천 이음**(계획서 20 · 23-2) — 도구 막대 [노선 추천] 단추 → * 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [확인]에 실을 지금 추천. * * 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를 * **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시 * 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의 * `initial_route` 한 곳에서 읽는다. 종단 · 배수유역 · 횡단 다시 계산은 여기서 안 돈다 — * 노선이 바뀐 뒤 [확인]에서만(`_Apply`). 비교값 · 기준값은 서버가 준 것을 보이기만 한다. * * 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정) * — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 · * `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채 * 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { fetchRouteInitial, type RouteInitialCriteria, type RouteInitialLogic, type RouteInitialWeights, } from "./B05_Profile_Api_RouteInitial"; import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, DEFAULT_WEIGHTS, initialRouteSnapshot, normalizeRouteInitial, readInitialChoice, sameBase, serverBase, writeInitialChoice, type CriteriaKey, type InitialBase, type InitialChoice, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { openRecommendModal } from "./B05_Profile_UI_RouteEdit_Recommend"; export interface InitialPanelParams { overlay: HTMLElement; projectId: string; /** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */ planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ apply: (snapshot: RouteEditSnapshot, message: string) => void; } export interface InitialPanel { /** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */ ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void; /** [확인]에 실어 보낼 지금 추천. */ current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; }; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ tag: () => InitialChoice | null; restoreTag: (tag: unknown) => void; /** [취소] — 이 창에서 바꾼 추천을 창을 열 때 것으로 되돌린다. */ cancel: () => void; /** [확인] 뒤 결과 캐시를 비웠다 — 추천은 새 노선의 출발점이라 다시 적는다. */ keep: () => void; } const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot => JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot; export function createInitialPanel(params: InitialPanelParams): InitialPanel { const { overlay, projectId } = params; const recommendButton = overlay.querySelector('[data-act="recommend"]')!; const resetButton = overlay.querySelector('[data-act="reset"]')!; const nowLine = overlay.querySelector("[data-rec-now]")!; /** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */ let openingChoice = readInitialChoice(projectId); let choice: InitialChoice | null = openingChoice; /** 노드 없이 이름만 아는 현재 추천 — 서버가 적어 둔 것(없으면 예상노선 추적). */ let base: InitialBase = { logic: "follow", weights: null, criteria: null }; /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */ const defaults: Partial> = {}; let opening: RouteEditSnapshot | null = null; let edited = false; let busy = false; /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */ let planRead: Promise | null = null; function loadPlan(): Promise { planRead ??= fetchRoutePlan(projectId).catch((error) => { planRead = null; // 다음에 다시 읽는다. throw error; }); return planRead; } function render(): void { const now = choice ?? base; nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`; recommendButton.disabled = busy || !opening; resetButton.disabled = busy || !opening; } /** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */ async function request( logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, ...(weights ? { weights } : {}), ...(criteria ? { criteria } : {}), }); return normalizeRouteInitial(response, logic, weights, criteria); } recommendButton.addEventListener("click", () => { void (async () => { if (busy || !opening) return; busy = true; render(); const plan = await loadPlan().catch(() => null); // 지도 바탕만 못 깔 뿐 추천은 된다. busy = false; if (!recommendButton.isConnected) return; // 그 사이 창이 닫혔다. render(); openRecommendModal({ criteria: choice?.criteriaOverrides ?? base.criteria, defaults, weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), request, onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); render(); params.apply( copy(picked.snapshot), `노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 다시 계산은 [확인]에서.`, ); }, }); })(); }); // [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다. resetButton.addEventListener("click", () => { void (async () => { if (busy || !opening) return; busy = true; render(); try { const plan = await loadPlan().catch(() => null); // 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다. const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening); if (!snapshot) { showToast("전처리 초기값 노선을 읽지 못했습니다.", "error"); return; } if (!resetButton.isConnected) return; choice = { logic: "follow", weights: null, snapshot: copy(snapshot), metrics: null, criteria: null, criteriaOverrides: null, }; writeInitialChoice(projectId, choice); params.apply( copy(snapshot), "초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [확인]에서.", ); } finally { busy = false; if (resetButton.isConnected) render(); } })(); }); render(); return { ready(snapshot, wasEdited, server) { opening = copy(snapshot); edited = wasEdited; // 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음) // 캐시를 버린다. if (server) { base = serverBase(server); if (choice && !sameBase(choice, base)) choice = null; openingChoice = choice; writeInitialChoice(projectId, choice); } render(); }, current: () => { const now = choice ?? base; const criteria = choice ? choice.criteriaOverrides : base.criteria; return { mode: now.logic, weights: now.weights, criteria }; }, tag: () => choice, restoreTag(tag) { if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. choice = tag as InitialChoice | null; writeInitialChoice(projectId, choice); render(); }, cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; }