/* ============================================================================= * B05_Profile_UI_RouteEdit_Initial.ts * 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화] * · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값. * * 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에 * 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고, * [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시 * 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`). * 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { fetchRouteInitial, ROUTE_INITIAL_MISSING, type RouteInitialLogic, type RouteInitialWeights, } from "./B05_Profile_Api_RouteInitial"; import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, criteriaLine, DEFAULT_WEIGHTS, metricsLine, normalizeRouteInitial, readInitialChoice, rebalanceWeights, sameBase, serverBase, WEIGHT_KEYS, writeInitialChoice, type InitialBase, type InitialChoice, type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; const WEIGHT_NAME: Record = { expected: "예상", earthwork: "토공", grade: "기울기", }; 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 }; /** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */ 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 box = document.createElement("div"); box.className = "b05-routeedit__initial"; box.innerHTML = `
`; overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box); const nowLine = box.querySelector(".b05-routeedit__initial-now")!; const metricsEl = box.querySelector('[data-line="metrics"]')!; const criteriaEl = box.querySelector('[data-line="criteria"]')!; const weightsBox = box.querySelector(".b05-routeedit__weights")!; const openWeights = box.querySelector('[data-act="weights-open"]')!; const resetButton = overlay.querySelector('[data-act="reset"]')!; const buttons = [...box.querySelectorAll("button"), resetButton]; /** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */ let openingChoice = readInitialChoice(projectId); let choice: InitialChoice | null = openingChoice; /** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */ let base: InitialBase = { logic: "follow", weights: null }; let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) }; let opening: RouteEditSnapshot | null = null; let edited = false; let busy = false; function render(): void { const now = choice ?? base; const logic = now.logic; nowLine.textContent = `현재 초기값: ${choiceLabel(logic, now.weights)}`; metricsEl.textContent = choice && !choice.metrics ? "비교값 — 서버 답에 없습니다." : metricsLine(choice?.metrics ?? null); criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM()); box.dataset.logic = logic; for (const key of WEIGHT_KEYS) { box.querySelector(`[data-weight="${key}"]`)!.value = String(weights[key]); box.querySelector(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`; } box.querySelector("[data-weight-sum]")!.textContent = `합 ${weights.expected + weights.earthwork + weights.grade}`; buttons.forEach((button) => { button.disabled = busy || !opening; }); resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`; } /** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */ async function run( logic: RouteInitialLogic, allowFallback = false, useWeights: RouteInitialWeights | null = null, ): Promise { if (busy || !opening) return; busy = true; const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null; render(); nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`; try { const response = await fetchRouteInitial(projectId, { mode: logic, ...(sent ? { weights: sent } : {}), }); if (!box.isConnected) return; // 그 사이 창이 닫혔다. choice = normalizeRouteInitial(response, logic, sent); writeInitialChoice(projectId, choice); params.apply( copy(choice.snapshot), `초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`, ); } catch (error) { const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다."; // 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다. if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited && logic === "follow") { params.apply( copy(opening), "초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.", ); } else { showToast(message, "error"); } } finally { busy = false; if (box.isConnected) render(); } } box.querySelectorAll("[data-logic]").forEach((button) => { button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic)); }); openWeights.addEventListener("click", () => { weightsBox.hidden = !weightsBox.hidden; openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden)); }); box.querySelectorAll("[data-weight]").forEach((input) => { input.addEventListener("input", () => { weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value)); render(); }); }); // [초기화] — 마지막에 적용한 초기값으로. 노드가 없으면(서버가 이름만 앎) 그 갈래를 서버에 묻는다. resetButton.addEventListener("click", () => { if (!choice) { void run(base.logic, true, base.weights); return; } params.apply( copy(choice.snapshot), `초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`, ); }); render(); return { ready(snapshot, wasEdited, server) { opening = copy(snapshot); edited = wasEdited; // 「현재 초기값」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음) // 캐시를 버린다. 캐시는 같은 초기값의 노드를 들고 있을 때만 [초기화]가 쓴다. if (server) { base = serverBase(server); if (choice && !sameBase(choice, base)) choice = null; if (base.weights) weights = { ...base.weights }; openingChoice = choice; writeInitialChoice(projectId, choice); } render(); }, current: () => { const now = choice ?? base; return { mode: now.logic, weights: now.weights }; }, cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; }