feat(design): 구조물 명세 공용 틀 + 횡단배수 배관(표 01) 컨테이너 폼
- 공용 틀(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
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/* =============================================================================
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* =============================================================================
|
||||
* design_structure_pipe.ts
|
||||
* 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) ·
|
||||
* 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
|
||||
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」
|
||||
* (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`).
|
||||
* 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림.
|
||||
* 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
|
||||
|
||||
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
||||
const BASIN = "집수정";
|
||||
const SOIL_FORMS: SpecChoice[] = [
|
||||
{ value: "돌쌓기(찰)", label: "돌 쌓기(찰)" },
|
||||
{ value: "돌쌓기(메)", label: "돌 쌓기(메)" },
|
||||
{ value: "콘크리트", label: "콘크리트" },
|
||||
{ value: "돌망태", label: "돌망태" },
|
||||
{ value: "통나무·목재틀", label: "통나무/목재틀" },
|
||||
{ value: "바자", label: "바자" },
|
||||
];
|
||||
const BASIN_FORMS: SpecChoice[] = [
|
||||
{ value: "I", label: "I형" },
|
||||
{ value: "L", label: "L형" },
|
||||
{ value: "U", label: "ㄷ형" },
|
||||
];
|
||||
|
||||
/** 형태 · 길이 · 높이 · 전 · 후 — 필수 보호공은 구조 갈래마다 옛 키 · 선택 흙막이는 인스턴스 키. */
|
||||
function guardItems(keys?: Record<string, Record<string, string>>): SpecItem[] {
|
||||
const by = (field: string, choices?: Record<string, SpecChoice[]>) =>
|
||||
keys
|
||||
? {
|
||||
item: "structure",
|
||||
map: Object.fromEntries(
|
||||
Object.entries(keys).map(([structure, k]) => [
|
||||
structure,
|
||||
{ key: k[field], choices: choices?.[structure] },
|
||||
]),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
id: "form",
|
||||
label: "형태",
|
||||
kind: "select",
|
||||
choices: SOIL_FORMS,
|
||||
by: by("form", { [BASIN]: BASIN_FORMS, [SOIL]: SOIL_FORMS }),
|
||||
},
|
||||
{ id: "length", label: "길이 (m)", kind: "number", split: "length", step: 1, by: by("length") },
|
||||
{ id: "height", label: "높이 (m)", kind: "number", step: 0.1, by: by("height") },
|
||||
{
|
||||
id: "before",
|
||||
label: "기준측점 전 (m)",
|
||||
kind: "number",
|
||||
split: "before",
|
||||
step: 0.5,
|
||||
by: by("before"),
|
||||
},
|
||||
{
|
||||
id: "after",
|
||||
label: "기준측점 후 (m)",
|
||||
kind: "number",
|
||||
split: "after",
|
||||
step: 0.5,
|
||||
by: by("after"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => ({
|
||||
form: `${side}_revet_form`,
|
||||
length: `${side}_revet_length_m`,
|
||||
height: `${side}_revet_height_m`,
|
||||
before: `${side}_revet_before_m`,
|
||||
after: `${side}_revet_after_m`,
|
||||
});
|
||||
|
||||
export const PIPE_SPEC: StructureSpec = {
|
||||
typeId: "pipe",
|
||||
containers: [
|
||||
{
|
||||
id: "pipe",
|
||||
title: "관",
|
||||
required: true,
|
||||
link: "grv6lny", // 정의 그룹 「관」(46장) — 집계표 잇기 자리 그대로
|
||||
items: [
|
||||
{
|
||||
id: "diameter",
|
||||
label: "관경",
|
||||
kind: "select",
|
||||
key: "pipe_diameter_mm",
|
||||
asNumber: true,
|
||||
choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{ id: "length", label: "관 길이 (m)", kind: "auto" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inlet",
|
||||
title: "관 보호공 (유입)",
|
||||
required: true,
|
||||
link: "gnbf9m9",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key: "inlet_type",
|
||||
choices: [
|
||||
{ value: BASIN, label: "집수정" },
|
||||
{ value: SOIL, label: "흙막이", also: { inlet_structure: "revet" } },
|
||||
],
|
||||
},
|
||||
...guardItems({
|
||||
[BASIN]: {
|
||||
form: "inlet_structure",
|
||||
length: "inlet_basin_length_m",
|
||||
height: "inlet_basin_height_m",
|
||||
before: "inlet_basin_before_m",
|
||||
after: "inlet_basin_after_m",
|
||||
},
|
||||
[SOIL]: revetKeys("inlet"),
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "outlet",
|
||||
title: "관 보호공 (유출)",
|
||||
required: true,
|
||||
link: "g9lq9uk",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key: "outlet_type",
|
||||
choices: [{ value: SOIL, label: "흙막이" }],
|
||||
},
|
||||
...guardItems({ [SOIL]: revetKeys("outlet") }),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "soil_add",
|
||||
title: "흙막이 추가",
|
||||
required: false,
|
||||
prefix: "soil_add",
|
||||
link: "gsoiladd",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [{ value: SOIL, label: "흙막이" }],
|
||||
},
|
||||
...guardItems(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chute_add",
|
||||
title: "도수로/돌붙임",
|
||||
required: false,
|
||||
prefix: "chute_add",
|
||||
link: "gchuteadd",
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// 표 01 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: ["pipe_kind", "wing_wall_type", "inlet_basin_form", "inlet_basin_material"],
|
||||
};
|
||||
|
||||
/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */
|
||||
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = { pipe: PIPE_SPEC };
|
||||
@@ -0,0 +1,42 @@
|
||||
/* =============================================================================
|
||||
* design_structure_pipe_calc.ts
|
||||
* 배관 명세 계산 — 관 길이(자동) · 보호공 높이 안내. 계산 자리 = ② TS 를 서버 Node 로:
|
||||
* 관 길이는 횡단 기하 `computeStoredLayouts`(B06 · 서버 Node 진입점이 같은 함수로 `pipe_length_m` 를
|
||||
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
|
||||
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
|
||||
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
|
||||
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { PIPE_MATCH_M } from "./design_selection";
|
||||
|
||||
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
||||
let sectionsSource: (() => readonly CrossSection[] | null) | null = null;
|
||||
export function setPipeSectionsSource(source: () => readonly CrossSection[] | null): void {
|
||||
sectionsSource = source;
|
||||
}
|
||||
|
||||
/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된
|
||||
* `pipe_length_m` · 그것도 없으면 null. */
|
||||
export function pipeLengthM(chainageM: number | null): number | null {
|
||||
const sections = sectionsSource?.() ?? null;
|
||||
if (chainageM === null || !sections?.length) return null;
|
||||
const holders = sections.filter(
|
||||
(s) => Math.abs((s.culvert?.chainage_m ?? Infinity) - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!holders.length) return null;
|
||||
const owner = holders.reduce((a, b) =>
|
||||
Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a,
|
||||
);
|
||||
const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM;
|
||||
const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m;
|
||||
return typeof length === "number" && length > 0 ? length : null;
|
||||
}
|
||||
|
||||
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
|
||||
export function guardHeightHint(diameterMm: string): string {
|
||||
const d = Number(diameterMm || "1000") / 1000;
|
||||
return `비우면 관경 기준 ${pipeWallMinHeightM(d)}`;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/* =============================================================================
|
||||
* design_structure_spec.ts
|
||||
* 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌.
|
||||
*
|
||||
* · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[]
|
||||
* · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개)
|
||||
* · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후)
|
||||
* · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래)
|
||||
* · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` ·
|
||||
* 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터)
|
||||
* · 구조물 조합 잇기(`group_items`) 키 = 필수는 `link`(정의 그룹 id) · 선택은 `{link}{n}`
|
||||
* · 이름 = 정의 `종류.{종류}.이름` 이 있으면 그것(M02 바꿈) · 그룹 이름 · 없으면 명세 글
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface SpecChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
/** 이 값을 고르면 함께 적는 옵션(하류가 읽는 옛 칸 · 예: 흙막이 = inlet_structure revet). */
|
||||
also?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 상위 값 하나에 걸린 갈래 — 저장 키 · 선택지. */
|
||||
export interface SpecBranch {
|
||||
key: string;
|
||||
choices?: SpecChoice[];
|
||||
}
|
||||
|
||||
export type SplitRole = "length" | "before" | "after";
|
||||
|
||||
export interface SpecItem {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "select" | "number" | "auto";
|
||||
/** 저장 키(필수 컨테이너 · `by` 없을 때). */
|
||||
key?: string;
|
||||
choices?: SpecChoice[];
|
||||
/** 같은 컨테이너 상위 항목 값 → 갈래. 갈래가 없으면(상위 비었음) 칸을 가림. */
|
||||
by?: { item: string; map: Record<string, SpecBranch> };
|
||||
/** 길이 = 전 + 후 맞물림 몫. */
|
||||
split?: SplitRole;
|
||||
/** 고르기 값을 수로 저장(관경 등 옛 저장이 수). */
|
||||
asNumber?: boolean;
|
||||
step?: number;
|
||||
/** 빈 칸 안내 — 함수면 지금 값으로 셈. */
|
||||
hint?: string | ((values: Record<string, string>) => string);
|
||||
}
|
||||
|
||||
export interface SpecContainer {
|
||||
id: string;
|
||||
title: string;
|
||||
required: boolean;
|
||||
/** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */
|
||||
prefix?: string;
|
||||
/** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */
|
||||
link: string;
|
||||
items: SpecItem[];
|
||||
}
|
||||
|
||||
export interface StructureSpec {
|
||||
typeId: string;
|
||||
containers: SpecContainer[];
|
||||
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
|
||||
retired: string[];
|
||||
}
|
||||
|
||||
/** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */
|
||||
export type Instance = Record<string, string>;
|
||||
export type SpecState = Record<string, Instance[]>;
|
||||
|
||||
/** 항목의 지금 갈래 — `by` 없으면 항목 자체. 상위가 비었으면 null(가림 · 저장 안 함). */
|
||||
export function branchOf(item: SpecItem, values: Instance): SpecBranch | null {
|
||||
if (!item.by)
|
||||
return item.key || item.choices ? { key: item.key ?? "", choices: item.choices } : null;
|
||||
return item.by.map[values[item.by.item] ?? ""] ?? null;
|
||||
}
|
||||
|
||||
const instanceKey = (c: SpecContainer, n: number, item: SpecItem): string =>
|
||||
`${c.prefix}_${n}_${item.id}`;
|
||||
|
||||
/** 항목 저장 키 — 자동 항목 · 갈래 없음 = null. */
|
||||
export function keyOf(
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
n: number,
|
||||
values: Instance,
|
||||
): string | null {
|
||||
if (item.kind === "auto") return null;
|
||||
if (!c.required) return instanceKey(c, n, item);
|
||||
return branchOf(item, values)?.key || null;
|
||||
}
|
||||
|
||||
/** 지금 고를 수 있는 값 — 갈래 선택지가 앞섬. */
|
||||
export function choicesOf(item: SpecItem, values: Instance): SpecChoice[] {
|
||||
return branchOf(item, values)?.choices ?? item.choices ?? [];
|
||||
}
|
||||
|
||||
/** 인스턴스 잇기 키 — 필수 = 그룹 id · 선택 = `{link}{n}`. */
|
||||
export const linkOf = (c: SpecContainer, n: number): string =>
|
||||
c.required ? c.link : `${c.link}${n}`;
|
||||
|
||||
const text = (value: unknown): string =>
|
||||
value === undefined || value === null ? "" : String(value);
|
||||
|
||||
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
|
||||
export function readState(
|
||||
spec: StructureSpec,
|
||||
options: Record<string, string | number>,
|
||||
): SpecState {
|
||||
const state: SpecState = {};
|
||||
for (const c of spec.containers) {
|
||||
if (c.required) {
|
||||
const values: Instance = {};
|
||||
const plain = c.items.filter((item) => !item.by);
|
||||
for (const item of [...plain, ...c.items.filter((item) => item.by)]) {
|
||||
const key = keyOf(c, item, 1, values);
|
||||
if (key) values[item.id] = text(options[key]);
|
||||
}
|
||||
state[c.id] = [values];
|
||||
continue;
|
||||
}
|
||||
const head = new RegExp(`^${c.prefix}_(\\d+)_`);
|
||||
const ns = [
|
||||
...new Set(
|
||||
Object.keys(options)
|
||||
.map((k) => head.exec(k)?.[1])
|
||||
.filter((n): n is string => !!n)
|
||||
.map(Number),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
state[c.id] = ns.map((n) =>
|
||||
Object.fromEntries(c.items.map((item) => [item.id, text(options[instanceKey(c, n, item)])])),
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */
|
||||
export function writeOptions(
|
||||
spec: StructureSpec,
|
||||
state: SpecState,
|
||||
): Record<string, string | number> {
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const c of spec.containers) {
|
||||
(state[c.id] ?? []).forEach((values, index) => {
|
||||
for (const item of c.items) {
|
||||
const key = keyOf(c, item, index + 1, values);
|
||||
const raw = (values[item.id] ?? "").trim();
|
||||
if (!key || raw === "") continue;
|
||||
if (item.kind === "number" || item.asNumber) {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100;
|
||||
continue;
|
||||
}
|
||||
out[key] = raw;
|
||||
if (c.required)
|
||||
Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also);
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 명세가 다루는 키 전부(필수 갈래 · also 포함) + 선택 컨테이너 머리 — 지난 정의 값 판정 · 저장 병합. */
|
||||
export function specKeys(spec: StructureSpec): { keys: string[]; prefixes: string[] } {
|
||||
const keys = new Set<string>();
|
||||
const prefixes: string[] = [];
|
||||
for (const c of spec.containers) {
|
||||
if (!c.required) {
|
||||
prefixes.push(`${c.prefix}_`);
|
||||
continue;
|
||||
}
|
||||
for (const item of c.items) {
|
||||
const branches = item.by ? Object.values(item.by.map) : [branchOf(item, {})];
|
||||
for (const branch of branches) {
|
||||
if (branch?.key) keys.add(branch.key);
|
||||
for (const ch of branch?.choices ?? item.choices ?? [])
|
||||
Object.keys(ch.also ?? {}).forEach((k) => keys.add(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { keys: [...keys], prefixes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 길이 = 전 + 후 맞물림 — 마지막에 고친 둘을 지키고 나머지 하나를 셈(정함 52).
|
||||
* `recent` = 고친 차례(최근이 앞) · 둘이 안 차면 길이 → 전 → 후 차례로 채움. 지킬 둘 중 빈 칸이 있으면 안 셈.
|
||||
*/
|
||||
export function solveSplit(
|
||||
values: Partial<Record<SplitRole, string>>,
|
||||
recent: SplitRole[],
|
||||
): Partial<Record<SplitRole, string>> {
|
||||
const keep = [...new Set([...recent, "length", "before", "after"] as SplitRole[])].slice(0, 2);
|
||||
const num = (role: SplitRole): number | null => {
|
||||
const raw = (values[role] ?? "").trim();
|
||||
const n = Number(raw);
|
||||
return raw !== "" && Number.isFinite(n) ? n : null;
|
||||
};
|
||||
const [a, b] = keep.map(num);
|
||||
if (a === null || b === null) return values;
|
||||
const free = (["length", "before", "after"] as SplitRole[]).find((r) => !keep.includes(r))!;
|
||||
const got = { length: num("length"), before: num("before"), after: num("after") };
|
||||
const result =
|
||||
free === "length"
|
||||
? (got.before ?? 0) + (got.after ?? 0)
|
||||
: Math.max((got.length ?? 0) - (free === "before" ? (got.after ?? 0) : (got.before ?? 0)), 0);
|
||||
return { ...values, [free]: String(Math.round(result * 100) / 100) };
|
||||
}
|
||||
|
||||
/** 이름 바꿈 표(정의 `종류.{종류}.이름`) — 키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`. */
|
||||
export type SpecNames = Record<string, string>;
|
||||
|
||||
export const containerTitle = (c: SpecContainer, names: SpecNames, groupName?: string): string =>
|
||||
names[c.id] ?? groupName ?? c.title;
|
||||
export const itemLabel = (c: SpecContainer, item: SpecItem, names: SpecNames): string =>
|
||||
names[`${c.id}.${item.id}`] ?? item.label;
|
||||
export const choiceLabel = (
|
||||
c: SpecContainer,
|
||||
item: SpecItem,
|
||||
ch: SpecChoice,
|
||||
names: SpecNames,
|
||||
): string => names[`${c.id}.${item.id}=${ch.value}`] ?? ch.label;
|
||||
|
||||
/** 명세의 이름 키 전부(정의 파일에 올릴 표) — `{키: 명세 글}`. */
|
||||
export function nameTable(spec: StructureSpec): SpecNames {
|
||||
const out: SpecNames = {};
|
||||
for (const c of spec.containers) {
|
||||
out[c.id] = c.title;
|
||||
for (const item of c.items) {
|
||||
out[`${c.id}.${item.id}`] = item.label;
|
||||
const all = [
|
||||
...(item.choices ?? []),
|
||||
...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []),
|
||||
];
|
||||
for (const ch of all) out[`${c.id}.${item.id}=${ch.value}`] = ch.label;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
createStandardSlot,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Panel";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
|
||||
import {
|
||||
bindDesignView,
|
||||
bindPipeOptions,
|
||||
@@ -94,6 +95,8 @@ export async function mountCrossView(
|
||||
const projectId = shared.projectId || null;
|
||||
let currentRouteId: number | null = null;
|
||||
let sectionDetail: SectionDetailResponse | null = null;
|
||||
// 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1)
|
||||
setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null);
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
|
||||
|
||||
Reference in New Issue
Block a user