/* ============================================================================= * B05_Profile_UI_RouteEdit_Initial.ts * 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 → * 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는 * 밖(`_Panel_Recommend`)에서 받는다. * * 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를 * **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) — * 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은 * 페이지 [저장]에서(`_Apply` · 346-3) — * 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 · * 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다. * * 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정) * — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고, 그 「채운 칸」만 캐시 · * `initial_choice.json` 에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder 는 그 칸을 처음 비운 채 * 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다. * ========================================================================== */ import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; import { fetchPrecomputeProgress, fetchRouteInitial, type RouteInitialCriteria, type RouteInitialLogic, type RouteInitialWeights, } from "./B05_Profile_Api_RouteInitial"; import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, choiceWithProfile, DEFAULT_WEIGHTS, NO_WEIGHT_LOCKS, 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"; import { saveRecommendedRoute } from "./B05_Profile_UI_RouteEdit_Apply"; import { applyResetText } from "./B05_Profile_UI_RouteReset"; export interface InitialPanelParams { /** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */ controls: { recommendButton: HTMLButtonElement; nowLine: HTMLElement; }; projectId: string; /** 지금 편집기 노드 — 추천 창이 「지금 노선」으로 같이 그린다. */ planned: () => Array<[number, number]>; /** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */ planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ apply: (snapshot: RouteEditSnapshot, message: string) => void; } export interface InitialPanel { /** 노선을 읽었다 — 창을 연 편집값 · 서버가 적어 둔 현재 초기값(`current_initial`). */ ready: (opening: RouteEditSnapshot, server?: unknown) => void; /** [저장]에 실어 보낼 지금 추천. */ current: () => { mode: RouteInitialLogic; weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; with_profile: boolean; }; /** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로). * 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */ withProfile: () => boolean; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `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 { projectId } = params; const { recommendButton, nowLine } = params.controls; /** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */ 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 busy = false; /** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */ const weightMemory = { weights: null as RouteInitialWeights | null, locks: { ...NO_WEIGHT_LOCKS }, }; /** `/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; } /** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */ async function request( logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, withProfile: boolean, recompute: boolean, ): Promise { const response = await fetchRouteInitial(projectId, { mode: logic, ...(weights ? { weights } : {}), ...(criteria ? { criteria } : {}), with_profile: withProfile, ...(recompute ? { recompute: true } : {}), }); return normalizeRouteInitial(response, logic, weights, criteria, withProfile); } 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) }, weightMemory, logic: (choice ?? base).logic, // 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작) withProfile: choice?.withProfile ?? base.withProfile ?? true, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: params.planned().map(([x, y]) => [x, y]), request, progress: () => fetchPrecomputeProgress(projectId), confirmText: () => applyResetText(projectId), onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); render(); params.apply( copy(picked.snapshot), `노선 추천 「${choiceLabel(picked.logic, picked.weights)}」 적용 — 서버에 저장합니다.`, ); // 초기값 = 추천 노선(346-3) — 바로 저장 · 실패하면 평면에 실린 채 남아 [저장]이 다시 보냄. void saveRecommendedRoute( projectId, picked.snapshot, { mode: picked.logic, weights: picked.weights, criteria: picked.criteriaOverrides, with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420) }, choiceWithProfile(picked), ); }, }); })(); }); render(); return { ready(snapshot, server) { opening = copy(snapshot); // 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음) // 캐시를 버린다. 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, with_profile: choiceWithProfile(now), }; }, withProfile: () => choiceWithProfile(choice ?? base), 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), }; }