- 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로)
- 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림
- 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로
- 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로
- 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸}
- 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임
- 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
291 lines
13 KiB
TypeScript
291 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, number>;
|
|
/** 지금 종류 그룹의 형상 키 전부(받기 끈 칸 포함) — 저장 병합 · 지난 정의 값 판정. */
|
|
shapeKeys: (type?: StructureType | null) => string[];
|
|
/** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */
|
|
setShapes: (options: Record<string, string | number> | 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<string, string> = {};
|
|
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<string, HTMLInputElement>();
|
|
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();
|
|
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<HTMLElement>("[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<string, number> = {};
|
|
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();
|
|
},
|
|
});
|
|
}
|