/* ============================================================================= * B05_Profile_UI_Structures_Panel_Options.ts * 구조물 배치 패널의 **옵션 칸 그리기** — 타입의 옵션 스키마대로 입력 칸을 다시 그린다. * * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-29 · PLAN 21-4). * 그리는 규칙은 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다. * 새로 얹은 것 하나 — 서버가 `visible: false` 로 채운 옵션은 그리지 않는다(정의 · 옛 값은 그대로, * 저장 때 안 그린 칸은 보존한다 → `B05_Profile_UI_Structures_Summary.mergeStructureOptions`). * ========================================================================== */ import { isB05Option, type StructureType } from "./B05_Profile_Api_Structures"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { field, optionControl } from "./B05_Profile_UI_Structures_Fields"; import { columnsOf, gridPlace, orderedOptions, ruleDefaults, ruleStates, staleValues, } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { optionInputs: Array<{ key: string; required: boolean; input: HTMLInputElement | HTMLSelectElement; read: () => string | number; isEmpty: () => boolean; }>; optionRow: HTMLElement; /** B06/B07 은 상세(detail) 옵션까지 그린다. */ includeDetail: boolean; currentType: () => StructureType | null; facilityFormKind: (type: StructureType | null) => PipeFacility | null; liveCommit: () => void; syncOptionLock: () => void; syncRangeDisplay: () => void; syncSplitInputs: (source: "length" | "before_m" | "after_m", normalizeSource: boolean) => void; } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면 * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */ export function renderOptionFields( ctx: OptionRenderContext, values: Record = {}, ): void { const { optionInputs, optionRow } = ctx; const type = ctx.currentType(); // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 // 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04). optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). const shown = type && !ctx.facilityFormKind(type) ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) : []; optionRow.style.gridTemplateColumns = ""; if (!type || !shown.length) { optionRow.hidden = true; ctx.syncOptionLock(); return; } optionRow.hidden = false; const columns = columnsOf(type); if (columns !== 2) optionRow.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`; // 규칙(48-4)이 가린 칸은 `optionInputs` 에서 뺌 — 필수 검사 · 저장이 안 봄(옛 값은 병합이 지킴) const all: Array<{ entry: (typeof optionInputs)[number]; wrap: HTMLElement }> = []; shown.forEach((option) => { // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). // 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘. const input = optionControl(option, values[option.key]); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; const wrap = field(label, input, option.default_basis); wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 const place = gridPlace(type.spots?.[option.key], columns); wrap.style.gridRow = place?.row ?? ""; wrap.style.gridColumn = place?.column ?? ""; optionRow.append(wrap); // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) input.addEventListener("change", () => { applyRules(option.key); ctx.liveCommit(); }); all.push({ wrap, entry: { key: option.key, required: !!option.required, input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", }, }); // 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을 // 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다. if (option.key === "face_slope_ratio") { const warn = (): void => { const value = Number(input.value); const outside = input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5); input.title = outside ? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다." : "비워 두면 품셈 표대로 자동 판정합니다."; input.classList.toggle("is-outside-standard", outside); }; warn(); input.addEventListener("input", warn); input.addEventListener("change", warn); } // 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다 // (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이 // 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분. if (option.key === "length_m" || option.key === "before_m" || option.key === "after_m") { const source = (option.key === "length_m" ? "length" : option.key) as "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); ctx.syncSplitInputs(source, normalize); }; input.addEventListener("input", () => refresh(false)); input.addEventListener("change", () => refresh(true)); } }); /** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */ function applyRules(parent: string | null): void { const now = (): Record => Object.fromEntries(all.map(({ entry }) => [entry.key, entry.input.value])); if (parent) { for (const [key, value] of Object.entries(ruleDefaults(type!, parent, now()))) { const hit = all.find(({ entry }) => entry.key === key); if (hit) hit.entry.input.value = String(value); } } const next = ruleStates(type!, now()); optionInputs.length = 0; for (const { entry, wrap } of all) { const state = next.get(entry.key); const off = state?.visible === false; wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식 if (!off) optionInputs.push(entry); if (state && entry.input.tagName.toLowerCase() === "select") syncChoices(entry.input as HTMLSelectElement, state.choices); } } applyRules(null); ctx.syncOptionLock(); ctx.syncRangeDisplay(); } /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ function syncChoices(element: HTMLSelectElement, choices: string[]): void { const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value); const keep = element.value && !choices.includes(element.value) ? [element.value] : []; const want = [...choices, ...keep]; if (now.length === want.length && now.every((v, i) => v === want[i])) return; const value = element.value; const blank = element.children[0]; element.replaceChildren(blank, ...want.map((c) => new Option(c, c))); element.value = value; } /** 「지난 정의 값」 상자(48-3) — 지금 정의에 없는 키의 값을 읽기만 칸으로 보임(오류 없음 · 저장은 * 병합이 옛 값 그대로 지킴). 아는 키 = 정의 옵션 전부(안 씀 포함) + 시설 폼이 적는 칸. */ export function paintStale( box: HTMLElement, type: StructureType | null, values: Record | undefined, facility: PipeFacility | null, /** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */ shapeKeys: string[] = [], ): void { const known = [ ...shapeKeys, ...(type?.options ?? []).map((o) => o.key), ...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []), ]; const stale = type ? staleValues(values, known) : []; box.replaceChildren(); box.hidden = !stale.length; if (!stale.length) return; const legend = document.createElement("legend"); legend.textContent = "지난 정의 값"; const body = document.createElement("div"); body.className = "b05-structure__grid"; for (const [key, value] of stale) { const input = document.createElement("input"); input.type = "text"; input.readOnly = true; input.value = String(value); input.title = "지금 정의에 없는 칸 — 읽기만 · 계산에 안 쓰임"; body.append(field(key, input)); } box.append(legend, body); }