/* ============================================================================= * B05_Profile_UI_Structures_Groups.ts * 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든 * 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄. * * · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인 * `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 · * 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림. * · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 · * 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘). * · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄). * · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠. * · 그룹 형상 칸(48 정의 그룹 `형상` · 길이 · 높이 · 전 · 후 받기 켬끔) — 값은 구조물 옵션 * `{그룹 id}__{칸}` 으로 저장(패널이 옵션에 얹음) · 계산은 안 함(48-5). * ========================================================================== */ import { openItemPicker } from "@ui/ui_template_item_picker"; import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import type { StructureType } from "./B05_Profile_Api_Structures"; import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; import { field, numberInput } from "./B05_Profile_UI_Structures_Fields"; import { GROUP_JOIN, type GroupItems, type SummaryItem, } from "../common_util/common_util_summary_item"; export interface GroupBoxes { /** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */ root: HTMLElement; /** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */ render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void; get: () => GroupItems; /** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */ set: (items: GroupItems) => void; /** 한 그룹 값 — null = 해제. */ setOne: (groupId: string, item: SummaryItem | null) => void; /** [고르기] · [해제] · 형상 칸 고침 배선 — 패널이 한 번 부른다. */ bind: (handlers: GroupHandlers) => void; /** 형상 칸 값 `{그룹 id}__{칸}: 수` — 빈 칸은 뺌. 옵션에 얹어 저장. */ shapes: () => Record; /** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */ shapeKeys: (type?: StructureType | null) => string[]; /** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */ setShapes: (options: Record | undefined) => void; } interface GroupHandlers { pick: (groupId: string) => void; clear: (groupId: string) => void; change?: () => void; } export function createGroupBoxes(): GroupBoxes { const root = document.createElement("div"); root.className = "b05-structure__optgroups"; root.dataset.b05Groups = ""; root.hidden = true; let items: GroupItems = {}; let handlers: GroupHandlers | null = null; let shapes: Record = {}; let current: StructureType | null = null; const keysOf = (type: StructureType | null | undefined): string[] => (type?.groups ?? []).flatMap((g) => SHAPE_FIELDS.map((f) => shapeKey(g.id, f.suffix))); const shapeInputs = new Map(); const rows = new Map(); /** 옮긴 칸 · 제자리 표지 */ const moved: Array<{ node: HTMLElement; mark: Comment }> = []; /** 칸이 다 빠져 가린 줄 · 소그룹 상자 */ const emptied: HTMLElement[] = []; /** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */ let bodies: HTMLElement[] = []; let lockSource: HTMLElement | null = null; const syncLock = (): void => { const locked = !!lockSource?.inert; bodies.forEach((body) => { body.inert = locked; body.classList.toggle("is-locked", locked); }); }; const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null; const restore = (): void => { for (const { node, mark } of moved.splice(0)) { if (mark.isConnected) mark.replaceWith(node); else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림 } emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied")); }; const paintStale = (known: Set): HTMLElement | null => { const stale = Object.keys(items).filter((id) => !known.has(id)); if (!stale.length) return null; const note = document.createElement("p"); note.className = "b05-structure__owner-note"; note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`; return note; }; return { root, render(type, scopes, facility) { restore(); rows.clear(); shapeInputs.clear(); current = type; const groups = type?.groups ?? []; const boxes: HTMLElement[] = []; bodies = []; // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) const off = hiddenFormKeys( facility, (type?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), ); for (const group of groups) { const keys = hiddenFormKeys(facility, group.options); const box = document.createElement("fieldset"); box.className = "b05-structure__group b05-structure__optgroup"; box.dataset.b05Group = group.id; const legend = document.createElement("legend"); legend.textContent = group.name; const body = document.createElement("div"); body.className = "b05-structure__grid"; for (const scope of scopes) { const marked = [...scope.querySelectorAll("[data-option-keys]")]; // 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게 const shown = (node: HTMLElement): boolean => { for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement) if (at.hidden) return false; return true; }; const fits = marked.filter( (node) => shown(node) && (node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)), ); // 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴 for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) { const parent = node.parentElement; const mark = document.createComment(`옵션 그룹 ${group.id}`); node.before(mark); body.append(node); moved.push({ node, mark }); const own = (node.dataset.optionKeys ?? "").split(" "); node.classList.toggle( "is-option-hidden", own.every((key) => off.has(key)), ); // 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게) for (let at = parent; at && at !== scope; at = at.parentElement) { if (at.querySelector("input, select, textarea")) break; at.classList.add("is-group-emptied"); emptied.push(at); } } } for (const shape of shapeFields(group.shape)) { const key = shapeKey(group.id, shape.suffix); const input = numberInput("0.1", "0"); input.value = shapes[key] ?? ""; input.dataset.shapeKey = key; input.title = shape.meaning; input.addEventListener("change", () => { shapes = { ...shapes, [key]: input.value.trim() }; handlers?.change?.(); }); shapeInputs.set(key, input); body.append(field(shape.label, input)); } if (!body.childElementCount) { const none = document.createElement("p"); none.className = "b05-structure__owner-note"; none.textContent = "옵션 없음 — 집계 항목만 지정"; body.append(none); } const row = buildSummaryRow(); row.set(items[group.id] ?? null); row.bind({ pick: () => handlers?.pick(group.id), clear: () => handlers?.clear(group.id), }); rows.set(group.id, row); box.append(legend, body, row.root); boxes.push(box); bodies.push(body); } const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null; root.replaceChildren(...boxes, ...(stale ? [stale] : [])); root.hidden = !type || (!boxes.length && !stale); // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 const facilityRoot = scopes.find((scope) => scope.classList.contains("b05-drainage__facility"), ); if (facilityRoot && facilityRoot !== lockSource) { lockSource = facilityRoot; lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] }); } syncLock(); }, get: () => ({ ...items }), set(next) { items = { ...next }; for (const [id, row] of rows) row.set(items[id] ?? null); }, setOne(groupId, item) { if (item) items = { ...items, [groupId]: item }; else { const { [groupId]: _gone, ...rest } = items; items = rest; } rows.get(groupId)?.set(item); }, bind(next) { handlers = next; }, shapes() { const out: Record = {}; for (const key of shapeInputs.keys()) { const text = (shapes[key] ?? "").trim(); if (text !== "" && Number.isFinite(Number(text))) out[key] = Number(text); } return out; }, shapeKeys: (type) => keysOf(type === undefined ? current : type), setShapes(options) { shapes = Object.fromEntries( Object.entries(options ?? {}) .filter(([key]) => key.includes(GROUP_JOIN)) .map(([key, value]) => [key, String(value)]), ); for (const [key, input] of shapeInputs) input.value = shapes[key] ?? ""; }, }; } /** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 * 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로. * 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` · * 추가 전이면 null(원본 보기만). */ export function bindSummaryPickers(opts: { summary: SummaryRow; groups: GroupBoxes; projectId: string | null; editingId: () => string | null; selectedPipeChainage: () => number | null; commitSummary: () => void; /** 형상 칸 고침 반영 — 고치는 중이면 곧바로(새 항목은 [추가]가 확정). */ liveCommit: () => void; }): void { const base = (): string | null => { const pipe = opts.selectedPipeChainage(); return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null); }; // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 const pick = (current: SummaryItem | null, structureId: string | null) => openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId }); opts.summary.bind({ pick: () => void pick(opts.summary.get(), base()).then((result) => { if (result.action === "cancel") return; opts.summary.set(result.action === "apply" ? result.item : null); opts.commitSummary(); }), clear: () => { opts.summary.set(null); opts.commitSummary(); }, }); opts.groups.bind({ change: opts.liveCommit, pick: (groupId) => { const id = base(); void pick( opts.groups.get()[groupId] ?? null, id ? `${id}${GROUP_JOIN}${groupId}` : null, ).then((result) => { if (result.action === "cancel") return; opts.groups.setOne(groupId, result.action === "apply" ? result.item : null); opts.commitSummary(); }); }, clear: (groupId) => { opts.groups.setOne(groupId, null); opts.commitSummary(); }, }); }