- 공용 틀(A00_Common/design/design_structure_*) — 명세(컨테이너 필수/선택 · 여러 개 · 항목 고르기/숫자/자동 · 상위 값에 따른 선택지 · 저장 키) · 그리기([추가]/[빼기]) · 길이 = 전 + 후 맞물림(마지막에 고친 둘을 지킴) - 배관 명세 = 관 · 관 보호공(유입) · 관 보호공(유출) · 흙막이 추가 · 도수로/돌붙임 · 저장 키는 pipe_points 옛 키 그대로 · 옛 관종 · 날개벽 · 집수정 형식은 화면에서 빼고 지난 정의 값으로 남김 - 컨테이너 인스턴스마다 「구조물 조합」 잇기(group_items · 그룹 [고르기] 길 그대로) · 명세 종류는 그룹 상자 · 종류 단위 줄 가림 · 종류 단위 잇기 줄은 기준 측점 바로 아래 - 관 길이 자동 = 횡단 기하 computeStoredLayouts 값(서버 Node 와 같은 함수) · 높이 빈 칸 안내 = 관경 기준 최소 높이 - 정의 판 6 — 종류.pipe.이름 표(이름 바꿈 키) · 검사기가 이름 표 받음 · 판 5 이력 보관 - 시험 test_52_structure_spec_pipe · 병합 시험 기대값(빠진 옛 칸은 남음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
253 lines
9.6 KiB
TypeScript
253 lines
9.6 KiB
TypeScript
/* =============================================================================
|
|
* 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,
|
|
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<string, string | number>,
|
|
names?: SpecNames,
|
|
groupNames?: Record<string, string>,
|
|
) => void;
|
|
read: () => Record<string, string | number>;
|
|
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<string, string> = {};
|
|
const recent = new Map<Instance, SplitRole[]>();
|
|
const slots = new Map<string, HTMLElement>();
|
|
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
|
|
type Instance = Record<string, string>;
|
|
|
|
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;
|
|
}
|
|
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.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,
|
|
};
|
|
}
|