/* ============================================================================= * design_structure_form.ts * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. * * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 * · 칸 표식 = `data-spec-item="{컨테이너}.{항목}"`(정의 규칙 · 그룹 상자가 안 건드리게 옵션 키 표식 안 씀) * ========================================================================== */ import { branchOf, choiceLabel, choicesOf, containerTitle, itemLabel, linkOf, readState, solveSplit, TEXT_MAX, writeOptions, type SpecContainer, type SpecItem, type SpecNames, type SpecState, type SplitRole, type StructureSpec, } from "./design_structure_spec"; import { group, grid, labeled, numberInput, stepper, } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row"; import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item"; /** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */ export interface SpecLinks { get: () => GroupItems; pick: (key: string) => void; /** [해제] — 패널이 풀고 반영. */ clear: (key: string) => void; /** 값만 옮김(빼기 뒤 당김) — 반영은 뒤이은 폼 변경 알림이 함. */ set: (key: string, item: SummaryItem | null) => void; } export interface SpecFormHooks { /** 자동 항목 값 글(빈 글 = 「—」). */ auto?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; /** 숫자 칸 빈 칸 안내. */ hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; onChange?: (c: SpecContainer, item: SpecItem) => void; } export interface SpecForm { root: HTMLElement; /** 명세 · 저장 값으로 다시 그림. `names` = 정의 이름 바꿈 · `groupNames` = 정의 그룹 이름. */ write: ( spec: StructureSpec, options: Record, names?: SpecNames, groupNames?: Record, ) => void; read: () => Record; state: () => SpecState; /** 한 항목 값을 바꿔 그림(B06 조정창 맞춤 등) — change 알림은 안 함. */ setValue: (containerId: string, itemId: string, value: string) => void; /** 필수 컨테이너 끝에 붙일 자리(B06 조정 행). */ slot: (containerId: string) => HTMLElement; /** 잇기 값이 바뀌었을 때 줄을 다시 그림. */ paintLinks: () => void; refresh: () => void; } export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm { const root = document.createElement("div"); root.className = "b05-structure__spec"; let spec: StructureSpec | null = null; let state: SpecState = {}; let names: SpecNames = {}; let groupNames: Record = {}; const recent = new Map(); const slots = new Map(); const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; type Instance = Record; const slotOf = (id: string): HTMLElement => { let slot = slots.get(id); if (!slot) { slot = document.createElement("div"); slot.className = "b05-structure__adjust-slot"; slots.set(id, slot); } return slot; }; const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => { if (item.kind === "select") { const select = document.createElement("select"); const choices = choicesOf(item, values); select.replaceChildren( new Option("— 안 정함 —", ""), ...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)), ); const now = values[item.id] ?? ""; // 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음) if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now)); select.value = now; return select; } if (item.kind === "text") { const input = document.createElement("input"); input.type = "text"; input.maxLength = TEXT_MAX; input.value = values[item.id] ?? ""; return input; } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { input.type = "text"; input.readOnly = true; input.value = hooks.auto?.(c, item, state) || "—"; input.title = "자동 계산 — 고치지 않음"; return input; } input.value = values[item.id] ?? ""; return stepper(input, item.step ?? 0.1); }; const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => { values[item.id] = raw; if (item.split) { const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)]; recent.set(values, order); const role = (r: SplitRole) => c.items.find((i) => i.split === r)!; const solved = solveSplit( { length: values[role("length").id], before: values[role("before").id], after: values[role("after").id], }, order, ); for (const r of ["length", "before", "after"] as SplitRole[]) values[role(r).id] = solved[r] ?? ""; } // 위 항목이 바뀌면 갈래(선택지 · 저장 키)가 달라짐 — 선택지 밖 값은 비움 for (const child of c.items.filter((i) => i.by?.item === item.id)) { const allowed = choicesOf(child, values); if (child.kind === "select" && !allowed.some((ch) => ch.value === values[child.id])) values[child.id] = ""; } paint(); hooks.onChange?.(c, item); }; const instanceBox = (c: SpecContainer, values: Instance, index: number): HTMLElement => { const title = containerTitle(c, names, groupNames[c.link]); const box = group(c.required ? title : `${title} ${index + 1}`); box.root.dataset.specContainer = c.id; const fields = c.items .filter((item) => item.kind === "auto" || !item.by || branchOf(item, values)) .map((item) => { const input = control(c, item, values); const wrap = labeled(itemLabel(c, item, names), input); wrap.dataset.specItem = `${c.id}.${item.id}`; const target = input.querySelector?.("input, select") ?? input; target.addEventListener("change", () => onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value), ); return wrap; }); box.body.append(grid(...fields)); if (c.required) box.body.append(slotOf(c.id)); if (links) { const key = linkOf(c, index + 1); const row = buildSummaryRow(); row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합"; row.root.dataset.specLink = key; row.set(links.get()[key] ?? null); row.bind({ pick: () => links.pick(key), clear: () => links.clear(key) }); rows.push({ key, set: row.set }); box.body.append(row.root); } if (!c.required) { const remove = document.createElement("button"); remove.type = "button"; remove.className = "b05-route__irregular-btn"; remove.textContent = "빼기"; remove.dataset.specRemove = c.id; remove.addEventListener("click", () => { const list = state[c.id]; // 잇기는 인스턴스 차례를 따름 — 뒤 것을 한 칸씩 당기고 마지막 키는 풂 if (links) { const now = links.get(); for (let n = index + 1; n < list.length; n++) links.set(linkOf(c, n), now[linkOf(c, n + 1)] ?? null); links.set(linkOf(c, list.length), null); } list.splice(index, 1); paint(); hooks.onChange?.(c, c.items[0]); }); box.root.querySelector("legend")!.append(" ", remove); } return box.root; }; function paint(): void { rows.length = 0; const boxes: HTMLElement[] = []; for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); if (c.required) continue; const add = document.createElement("button"); add.type = "button"; add.className = "b05-route__irregular-btn b05-structure__spec-add"; add.textContent = `+ ${containerTitle(c, names, groupNames[c.link])}`; add.title = "추가"; add.dataset.specAdd = c.id; add.addEventListener("click", () => { list.push({}); paint(); hooks.onChange?.(c, c.items[0]); }); boxes.push(add); } root.replaceChildren(...boxes); } return { root, write(next, options, nextNames = {}, nextGroups = {}) { spec = next; names = nextNames; groupNames = nextGroups; state = readState(next, options); recent.clear(); paint(); }, read: () => (spec ? writeOptions(spec, state) : {}), state: () => state, setValue(containerId, itemId, value) { const values = state[containerId]?.[0]; if (!values) return; values[itemId] = value; paint(); }, slot: slotOf, paintLinks() { const now = links?.get() ?? {}; rows.forEach((row) => row.set(now[row.key] ?? null)); }, refresh: paint, }; }