/* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend.ts * 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 · * 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 · * 못 지킨 구간 · 오른쪽 위 [적용] · [닫기]. * 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`). * * 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은 * 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는 * 미리 보기를 버린다. 다시 계산(배수유역부터)은 여기서 안 돈다 — 편집 창 [확인]에서만. * 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독). * 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다. * 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달 * 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다. * ========================================================================== */ import { openModal } from "@ui/ui_template_modal"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; import type { RouteInitialCriteria, RouteInitialLogic, RouteInitialWeights, } from "./B05_Profile_Api_RouteInitial"; import { choiceLabel, criteriaLine, CRITERIA_KEYS, LOGIC_LABEL, metricsLines, TARGET_GRADE_KEY, canLockWeight, derivedWeightKey, inputWeight, moveWeight, relaxedLine, SIMILAR_HINT, similarLine, violationsLine, violationText, WEIGHT_KEYS, withProfileOf, type CriteriaKey, type InitialChoice, type InitialCriteria, type MapLayer, type WeightLocks, type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map"; import { bindRecommendHelp, recommendHelpHtml, syncRecommendHelp, } from "./B05_Profile_UI_RouteEdit_Recommend_Help"; import { balanceLine, candidatesTable, profileSvg, type BalanceProfile, } from "./B05_Profile_UI_RouteEdit_Recommend_Profile"; import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const; const PILL_TITLE: Record<(typeof PILLS)[number], string> = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)", gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단", earthwork: "절토 · 성토가 가장 적은 노선 + 종단", gentle: "종단기울기가 가장 낮은 노선 + 종단", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단", }; 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: "최소 반지름", 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 RecommendParams { /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ criteria: RouteInitialCriteria | null; /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */ defaults: Partial>; weights: RouteInitialWeights; /** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */ weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; /** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */ logic: RouteInitialLogic; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; current: Vertex[]; /** 서버에 갈래 하나를 묻고 편집값 한 벌로 펴 돌려준다(던지면 토스트). */ request: ( logic: RouteInitialLogic, weights: RouteInitialWeights | null, criteria: RouteInitialCriteria | null, withProfile: boolean, ) => Promise; /** [적용] — 미리 본 노선을 편집 창에 놓는다. */ onApply: (choice: InitialChoice) => void; } export function openRecommendModal(params: RecommendParams): void { // 알약에 없는 옛 갈래(안전 우선 등)로 적용했던 노선이면 예상노선 추적에서 시작한다. let logic: RouteInitialLogic = (PILLS as readonly string[]).includes(params.logic) ? params.logic : "follow"; const { locks } = params.weightMemory; let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) }; /** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */ const invalid = new Set(); let preview: InitialChoice | null = null; let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; let closed = false; let seq = 0; /** 이 모달에서 셈한 후보의 균형 종단(계획서 63-4) — 모달이 닫히면 버린다(캐시 · 저장 없음). */ const seen = new Map(); let ticker: number | undefined; let bodyEl: HTMLElement | null = null; let map: ReturnType | null = null; openModal({ title: "노선 추천", closeLabel: "닫기", // 제목줄 단추는 mount 에서 [적용] · [닫기] 묶음으로 갈아 끼운다(PLAN 77-3) dialogClass: "b05-routerec", // 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다. backdropClass: "b05-routerec-backdrop", // [닫기] · Esc · 바깥 누르기 모두 미리 보기를 버린다. beforeClose: () => { closed = true; window.clearInterval(ticker); map?.dispose(); return true; }, mount: (body, close) => { bodyEl = body; body.classList.add("b05-routerec__body"); body.innerHTML = `
예상노선(원본) 창을 연 노선 미리 보기
휠 = 확대 · 축소   끌기 = 이동   ↺ ↻ = 회전
${recommendHelpHtml()}`; // 알약 · 비중 막대 · 제약 칸 · 목표 기울기 칸은 공용 부품(createButton · createInputField · // PF toggleButton)으로 짓는다(PLAN 117) — 글 틀은 위에 자리만 남기고 여기서 채운다. const pillsBox = body.querySelector('[data-slot="pills"]')!; for (const key of PILLS) { const pill = createButton({ label: LOGIC_LABEL[key], variant: "pill" }); pill.classList.add("b05-routerec__pill"); pill.setAttribute("role", "radio"); pill.dataset.logic = key; pill.title = PILL_TITLE[key]; pillsBox.append(pill); } const barsBox = body.querySelector('[data-slot="bars"]')!; for (const key of WEIGHT_KEYS) { const bar = document.createElement("div"); bar.className = "b05-routerec__bar"; const numField = createInputField({ type: "number", min: 0, max: 100, compact: true }); numField.input.step = "1"; numField.input.inputMode = "numeric"; numField.input.classList.add("b05-routerec__num"); numField.input.dataset.weightNum = key; numField.input.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중 값`); const slider = document.createElement("input"); slider.type = "range"; slider.min = "0"; slider.max = "100"; slider.step = "1"; slider.dataset.weight = key; slider.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중`); const lock = toggleButton("고정", false, (active) => { locks[key] = active; invalid.clear(); render(); }); lock.classList.add("b05-routerec__lock"); lock.dataset.weightLock = key; lock.setAttribute("aria-label", `${WEIGHT_NAME[key]} 비중 고정`); const name = document.createElement("span"); name.textContent = WEIGHT_NAME[key]; bar.append(numField.input, slider, lock, name); barsBox.append(bar); } const criteriaBox = body.querySelector('[data-slot="criteria"]')!; for (const key of CRITERIA_KEYS) { const field = CRITERIA_FIELD[key]; const row = document.createElement("label"); row.title = field.title; const name = document.createElement("span"); name.textContent = field.label; const input = createInputField({ type: "number", min: 0, compact: true }); input.input.inputMode = "decimal"; input.input.step = "0.1"; input.input.dataset.criteria = key; input.input.placeholder = "기본값"; const unit = document.createElement("span"); unit.textContent = field.unit; row.append(name, input.input, unit); criteriaBox.append(row); } const targetField = createInputField({ type: "number", min: 0, max: 100, compact: true }); targetField.input.inputMode = "decimal"; targetField.input.step = "0.1"; targetField.input.dataset.criteria = TARGET_GRADE_KEY; targetField.input.placeholder = "상한"; body.querySelector('[data-slot="target-input"]')!.replaceWith(targetField.input); const q = (sel: string): T => body.querySelector(sel)!; // [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3). const actions = q(".b05-routerec__actions"); const applyButton = q('[data-act="apply"]'); const closeButton = q('[data-act="close"]'); body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions); map = createRecommendMap(q(".b05-routerec__mapwrap")); const pills = [...body.querySelectorAll("[data-logic]")]; const weightsBox = q(".b05-routerec__weights"); const rerun = q('[data-act="rerun"]'); const stateEl = q("[data-state]"); const line = (name: string): HTMLElement => q(`[data-line="${name}"]`); const criteriaInputs = body.querySelectorAll("[data-criteria]"); bindRecommendHelp(body); criteriaInputs.forEach((input) => { const value = params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY]; input.value = value === undefined ? "" : String(value); }); /** 칸에 지금 든 값만(빈 칸은 안 담김) — 서버에 실어 보낼 겹침. */ function currentCriteria(): RouteInitialCriteria { const out: RouteInitialCriteria = {}; criteriaInputs.forEach((input) => { const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; // 목표 기울기는 영선 노선을 물을 때만 보낸다. if (key === TARGET_GRADE_KEY && logic !== "gradeline") return; const raw = input.value.trim(); if (raw === "") return; const value = Number(raw); if (Number.isFinite(value)) out[key] = value; }); return out; } function drawMap(): void { const layers: MapLayer[] = [ { kind: "expected", points: params.expected }, { kind: "current", points: params.current }, ]; if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned }); map?.setLayers(layers); } function render(): void { pills.forEach((pill) => { const on = pill.dataset.logic === logic; pill.setAttribute("aria-checked", String(on)); pill.classList.toggle("is-active", on); pill.disabled = busy; }); weightsBox.hidden = logic !== "weighted"; const derived = derivedWeightKey(locks); for (const key of WEIGHT_KEYS) { const slider = q(`[data-weight="${key}"]`); const num = q(`[data-weight-num="${key}"]`); const lock = q(`[data-weight-lock="${key}"]`); // 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다. const shut = locks[key] || derived === key; slider.value = String(weights[key]); slider.disabled = busy || shut; // 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다. if (!invalid.has(key)) num.value = String(weights[key]); num.disabled = busy || shut; num.classList.toggle("is-invalid", invalid.has(key)); num.setAttribute("aria-invalid", String(invalid.has(key))); lock.setAttribute("aria-pressed", String(shut)); lock.classList.toggle("is-active", shut); lock.classList.toggle("is-auto", derived === key); // 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다. lock.disabled = busy || !canLockWeight(locks, key) || derived === key; lock.title = derived === key ? "나머지 둘이 고정돼 저절로 정해집니다" : locks[key] ? "고정 풀기" : "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다"; } q("[data-weight-sum]").textContent = `합 ${weights.expected + weights.earthwork + weights.grade}`; criteriaInputs.forEach((input) => { const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; // 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시. const known = key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key]; input.placeholder = known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값"; input.disabled = busy; }); q("[data-target-row]").hidden = logic !== "gradeline"; rerun.hidden = !dirty; rerun.disabled = busy || invalid.size > 0; applyButton.disabled = busy || dirty || !preview || invalid.size > 0; const shown = metricsLines(preview?.metrics ?? null); line("metrics").textContent = preview ? preview.metrics ? shown.route : "비교값 — 서버 답에 없습니다." : "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다."; line("metrics-work").textContent = preview ? shown.work : ""; line("metrics-work").hidden = !line("metrics-work").textContent; const balance = preview?.balance ?? null; line("balance").textContent = balanceLine(balance); line("balance").hidden = !balance; line("profile").innerHTML = profileSvg(balance); line("profile").hidden = !balance; line("candidates").innerHTML = candidatesTable([...seen]); line("candidates").hidden = seen.size === 0; const violations = violationsLine(preview?.metrics ?? null); line("violations").textContent = violations; line("violations").title = (preview?.metrics?.violations ?? []) .map((item) => `${violationText(item)} — ${item.reason}`) .join("\n"); line("violations").hidden = !violations; const relaxed = relaxedLine(preview); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; const similar = similarLine(preview); line("similar").textContent = similar; line("similar").hidden = !similar; line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; line("similar-hint").hidden = !similar; line("criteria").textContent = preview ? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline") : ""; line("criteria").hidden = !preview; syncRecommendHelp(body, logic); drawMap(); } async function run(): Promise { if (logic === "weighted" && invalid.size > 0) { showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error"); return; } const mine = ++seq; const sentWeights = logic === "weighted" ? { ...weights } : null; const criteria = currentCriteria(); const withProfile = withProfileOf(logic); busy = true; dirty = false; preview = null; // 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4). const started = Date.now(); const label = choiceLabel(logic, sentWeights); const tick = (): void => { const seconds = Math.round((Date.now() - started) / 1000); stateEl.textContent = `${label} 셈하는 중… ${seconds}초${ withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)" }`; }; window.clearInterval(ticker); tick(); ticker = window.setInterval(tick, 1000); render(); try { const choice = await params.request( logic, sentWeights, Object.keys(criteria).length ? criteria : null, withProfile, ); if (closed || mine !== seq) return; // 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다. 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) params.defaults[key] = resolved; } preview = choice; if (choice.balance) seen.set(label, choice.balance); else seen.delete(label); // 종단 없는 갈래(예상노선 추적) stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`; } catch (error) { if (closed || mine !== seq) return; stateEl.textContent = "셈하지 못했습니다."; showToast( error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.", "error", ); } finally { if (mine === seq) window.clearInterval(ticker); if (!closed && mine === seq) { busy = false; render(); } } } pills.forEach((pill) => { pill.addEventListener("click", () => { if (busy) return; logic = pill.dataset.logic as RouteInitialLogic; void run(); }); }); const remember = (): void => { params.weightMemory.weights = { ...weights }; }; body.querySelectorAll("[data-weight]").forEach((input) => { input.addEventListener("input", () => { weights = moveWeight( weights, locks, input.dataset.weight as WeightKey, Number(input.value), ); invalid.clear(); remember(); dirty = preview !== null; render(); }); }); // 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리. body.querySelectorAll("[data-weight-num]").forEach((input) => { input.addEventListener("input", () => { const key = input.dataset.weightNum as WeightKey; const raw = input.value.trim(); const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw)); if (result.ok) { weights = result.weights; invalid.clear(); remember(); dirty = preview !== null; // 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다. body.querySelectorAll("[data-weight-num]").forEach((other) => { if (other !== input) other.value = String(weights[other.dataset.weightNum as WeightKey]); }); } else { invalid.add(key); } render(); }); // 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70). input.addEventListener("blur", () => { if (!invalid.has(input.dataset.weightNum as WeightKey)) render(); }); }); // 고정 단추 — 눌림은 만들 때(`toggleButton`) 이미 배선했다. 셋 다 고정은 `render()` 가 // disabled 로 막아 클릭 자체가 안 먹는다. criteriaInputs.forEach((input) => { input.addEventListener("input", () => { dirty = preview !== null; render(); }); }); rerun.addEventListener("click", () => void run()); applyButton.addEventListener("click", () => { if (!preview) return; params.onApply(preview); closed = true; close(); }); closeButton.addEventListener("click", () => { closed = true; close(); }); render(); // 처음 고른 갈래(적용한 추천 · 없으면 예상노선 추적) — 열자마자 그 미리 보기를 받는다. void run(); }, }); // 편집 창 단축키(Ctrl+Z 등)가 뒤에서 노선을 되돌리지 않게 막는다. 칸 안의 글자 되돌리기는 그대로. (bodyEl as HTMLElement | null) ?.closest(".ui-modal__backdrop") ?.addEventListener("keydown", (event) => { const key = event as KeyboardEvent; if (key.ctrlKey || key.metaKey) key.stopPropagation(); }); }