/* ============================================================================= * 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, WEIGHT_KEYS, writeInitialChoice, 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 { /** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */ ready: (opening: RouteEditSnapshot, edited: boolean) => 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]; /** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */ const openingChoice = readInitialChoice(projectId); let choice: InitialChoice | null = openingChoice; let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) }; let opening: RouteEditSnapshot | null = null; let edited = false; let busy = false; function render(): void { const logic = choice?.logic ?? "follow"; nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`; 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): Promise { if (busy || !opening) return; busy = true; const sent = logic === "weighted" ? { ...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) { 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("follow", true); return; } params.apply( copy(choice.snapshot), `초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`, ); }); render(); return { ready(snapshot, wasEdited) { opening = copy(snapshot); edited = wasEdited; render(); }, current: () => ({ mode: choice?.logic ?? "follow", weights: choice?.weights ?? null }), cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; }