/* ============================================================================= * B05_Profile_UI_RouteEdit_Initial.ts * 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 공통 제약 겹침 네 칸 · * 비중 슬라이더 · [초기화] · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값. * * 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에 * 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고, * [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시 * 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`). * 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독). * * 공통 제약 네 칸(종단기울기 % · 최소 반지름 m · 계류 이격 m · 지반 고름 m, 계획서 20 확정) * — 비우면 서버 기본값을 쓴다. 채운 칸만 요청에 실어 보내고(비운 칸은 서버가 알아서), 그 * 「채운 칸」만 캐시·`initial_choice.json`에 남아 다시 열 때 되살아난다. 빈 칸의 placeholder는 * 그 칸을 처음 비운 채 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { fetchRouteInitial, ROUTE_INITIAL_MISSING, type RouteInitialCriteria, type RouteInitialLogic, type RouteInitialWeights, } from "./B05_Profile_Api_RouteInitial"; import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, criteriaLine, CRITERIA_KEYS, DEFAULT_WEIGHTS, metricsLine, violationsLine, violationText, normalizeRouteInitial, readInitialChoice, rebalanceWeights, relaxedLine, sameBase, serverBase, WEIGHT_KEYS, writeInitialChoice, type CriteriaKey, type InitialBase, type InitialChoice, type InitialCriteria, type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; const WEIGHT_NAME: Record = { expected: "예상", earthwork: "토공", grade: "기울기", }; /** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */ const CRITERIA_FIELD: Record< CriteriaKey, { label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null } > = { max_grade_pct: { label: "기울기", unit: "%", title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)", of: (c) => c.maxGradePct, }, min_radius_m: { label: "최소R", unit: "m", title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값", of: (c) => c.minRadiusM, }, stream_offset_m: { label: "계류", unit: "m", title: "계류 이격(m) — 비우면 기본값 100m", of: (c) => c.streamOffsetM, }, grade_smooth_m: { label: "지반", unit: "m", title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m", of: (c) => c.gradeSmoothM, }, }; 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; }; /** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */ 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 = `
${CRITERIA_KEYS.map( (key) => ``, ).join("")}
`; 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 violationsEl = box.querySelector('[data-line="violations"]')!; const relaxedEl = box.querySelector('[data-line="relaxed"]')!; 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 criteriaInputs = box.querySelectorAll("[data-criteria]"); /** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */ let openingChoice = readInitialChoice(projectId); let choice: InitialChoice | null = openingChoice; /** 노드 없이 이름만 아는 현재 초기값 — 서버가 적어 둔 것(없으면 예상노선 추종). */ let base: InitialBase = { logic: "follow", weights: null, criteria: null }; let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) }; /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */ const defaults: Partial> = {}; let opening: RouteEditSnapshot | null = null; let edited = false; let busy = false; /** 화면 칸을 값으로 채운다(비운 칸은 빈 문자열 — placeholder 가 기본값을 보인다). */ function setCriteriaInputs(overrides: RouteInitialCriteria | null): void { criteriaInputs.forEach((input) => { const key = input.dataset.criteria as CriteriaKey; const value = overrides?.[key]; input.value = value === undefined ? "" : String(value); }); } /** 화면 칸에 지금 사용자가 넣은 값만(빈 칸은 안 담김) — 다음 요청에 실어 보낼 겹침. */ function currentCriteria(): RouteInitialCriteria { const out: RouteInitialCriteria = {}; criteriaInputs.forEach((input) => { const raw = input.value.trim(); if (raw === "") return; const value = Number(raw); if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value; }); return out; } 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()); // 제약 못 지킨 구간 · 상한 풂 — 서버가 준 목록 그대로(계획서 20). 없는 칸이면 숨긴다. violationsEl.textContent = violationsLine(choice?.metrics ?? null); violationsEl.title = (choice?.metrics?.violations ?? []) .map((item) => `${violationText(item)} — ${item.reason}`) .join("\n"); violationsEl.hidden = !violationsEl.textContent; relaxedEl.textContent = relaxedLine(choice); relaxedEl.hidden = !relaxedEl.textContent; 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}`; // 공통 제약 칸 — placeholder 는 그 칸을 처음 비운 채 물었을 때 서버가 준 진짜 기본값. criteriaInputs.forEach((input) => { const key = input.dataset.criteria as CriteriaKey; input.placeholder = defaults[key] !== undefined ? String(defaults[key]) : "기본값"; input.disabled = busy || !opening; }); buttons.forEach((button) => { button.disabled = busy || !opening; }); resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`; } /** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. * `useCriteria` 를 안 주면 화면 칸에 지금 든 값을 그대로 겹침으로 보낸다. */ async function run( logic: RouteInitialLogic, allowFallback = false, useWeights: RouteInitialWeights | null = null, useCriteria?: RouteInitialCriteria | null, ): Promise { if (busy || !opening) return; busy = true; const sent = logic === "weighted" ? { ...(useWeights ?? weights) } : null; const criteria = useCriteria !== undefined ? (useCriteria ?? {}) : currentCriteria(); render(); nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`; try { const sentCriteria = Object.keys(criteria).length ? criteria : null; const response = await fetchRouteInitial(projectId, { mode: logic, ...(sent ? { weights: sent } : {}), ...(sentCriteria ? { criteria: sentCriteria } : {}), }); if (!box.isConnected) return; // 그 사이 창이 닫혔다. choice = normalizeRouteInitial(response, logic, sent, sentCriteria); // 비운 칸(안 겹친 칸)을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다. for (const key of CRITERIA_KEYS) { if (criteria[key] !== undefined) continue; const resolved = choice.criteria ? CRITERIA_FIELD[key].of(choice.criteria) : null; if (resolved !== null) defaults[key] = resolved; } 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) { setCriteriaInputs(base.criteria); // 그 사이 손댄 칸이 있으면 서버가 기억한 값으로 되돌린다. void run(base.logic, true, base.weights, base.criteria); return; } params.apply( copy(choice.snapshot), `초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`, ); }); setCriteriaInputs(choice?.criteriaOverrides ?? null); 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 }; // 화면 칸도 그때 넣었던 값(캐시가 살아 있으면 캐시, 아니면 서버가 기억한 값)으로. setCriteriaInputs(choice?.criteriaOverrides ?? base.criteria); 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 }; }, cancel: () => writeInitialChoice(projectId, openingChoice), keep: () => writeInitialChoice(projectId, choice), }; }