Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Groups.ts
T
eomsangdonandClaude Opus 5.5 15e08b28cb feat(B05·B06·M02): 46-3·46-4 옵션 그룹 보조 컨테이너 — 구조물 하나 · 조합 여럿
- 구조물 기록 · 관 지점 기록에 group_items {그룹 id: summary_item} 칸(검사 parse_group_items)
- 폼: 종류의 그룹마다 상자(제목 = 그룹 이름) · 그 그룹 옵션 칸을 옮겨 담음 · 「집계 항목」 [고르기][해제] · 빈 그룹도 상자
- 정의가 사라진 그룹 연결 = 값 보존 · 경고 한 줄
- 복사본 = 그룹 연결마다 {구조물 id}__{그룹 id} 폴더(관 pipe@측점__그룹)
- 집계표 채움 = 정의된 그룹마다 한 건 더(그 그룹이 고른 열) · 미지정 그룹은 「미지정」 알림에 셈
- B06 관 초안 · B05 시설 보관소 · 병합도 그룹 칸을 이음
- 시험 test_structure_group_items.py · 코드 글자를 보던 시험 셋은 바뀐 자리로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
2026-10-05 13:39:12 +09:00

226 lines
10 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Groups.ts
* 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든
* 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄.
*
* · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인
* `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 ·
* 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림.
* · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 ·
* 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘).
* · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄).
* · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠.
* ========================================================================== */
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 {
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: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => 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: { pick: (id: string) => void; clear: (id: string) => void } | null = null;
const rows = new Map<string, SummaryRow>();
/** 옮긴 칸 · 제자리 표지 */
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<string>): 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();
const groups = type?.groups ?? [];
const boxes: HTMLElement[] = [];
bodies = [];
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
const off = hiddenFormKeys(
facility,
(type?.options ?? []).filter((o) => o.visible === false).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<HTMLElement>("[data-option-keys]")];
const fits = marked.filter((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);
}
}
}
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;
},
};
}
/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 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;
}): 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({
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();
},
});
}