Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4

This commit is contained in:
2026-10-05 23:25:37 +09:00
21 changed files with 4658 additions and 175 deletions
+253
View File
@@ -0,0 +1,253 @@
/* =============================================================================
* 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.title = "추가";
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,
};
}
+187
View File
@@ -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)}`;
}
+239
View File
@@ -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) {
if (!c.required) out[c.id] = c.title; // 필수 컨테이너 이름 = 정의 그룹 이름(M02 그룹 표에서 바꿈)
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;
}
+3
View File
@@ -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 기본값으로 채운다.
@@ -96,6 +96,8 @@ export interface StructureType {
spots?: Record<string, DefinedSpot> | null;
/** 상위 → 하위 규칙(48 정의 `규칙` 중 이 종류 것). */
rules?: DefinedRule[] | null;
/** 구조물 명세 이름 바꿈(52 정의 `종류.이름` · `{컨테이너}[.{항목}[={값}]]: 이름`). */
spec_names?: Record<string, string>;
}
/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */
+78 -152
View File
@@ -7,10 +7,9 @@
* 시설 종류는 구조물 배치의 "종류" 드롭다운이, 위치(시작·기준·종료 측점)는 위치
* 칸이 담당하므로 여기에는 부속 옵션 필드만 있다.
*
* 배관 부속(2026-08-17 UI 개편): 관종·관경(기본 1000) 한 행 + 유입구·유출구
* 소그룹. 유입구는 집수정/기슭막이 택일이고, 유출구는 기슭막이뿐이다(현재 다른
* 선택지 없음). 기슭막이 조각은 형태·길이·높이·바닥 보호공을 한 벌로 묶어
* 유입·유출·독립 구조물(D4 기슭막이)이 **같은 인터페이스**를 쓴다.
* 배관 부속(PLAN 52-1): 사용자 구조물 표 01 명세 폼(`A00_Common/design/design_structure_pipe`)
* — 관 · 관 보호공(유입 · 유출) · 흙막이 추가 · 도수로/돌붙임 컨테이너. 저장 키는 옛 키 그대로 ·
* 옛 관종 · 날개벽 · 집수정 형식 칸은 화면에서 빠지고 값은 남음(지난 정의 값).
*
* 보호공(2026-08-17 사용자 지시): 구 "돌붙임 있음/없음 + 표면처리(찰/메)" 두 축을
* `돌붙임(찰)·돌붙임(메)·도수로` 한 축으로 합쳤다. 도수로·산비탈수로(B4)는 유출부에서
@@ -38,26 +37,23 @@ import type {
import {
BOX_SIZE_CUSTOM,
BOX_SIZE_PRESETS,
createBasinSplitFields,
createRevetmentFields,
createRevetSideGroup,
createWingFields,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
OUTLET_REVET_KEYS,
putNumber,
stepper,
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
linkFields,
@@ -206,7 +202,17 @@ export interface FacilityOptionsForm {
options?: Record<string, string | number>,
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */
designFlow?: number | null,
/** 종류 정의 — 배관 명세 이름 바꿈(`spec_names`) · 그룹 이름(PLAN 52). */
definition?: {
spec_names?: Record<string, string>;
groups?: Array<{ id: string; name: string }>;
} | null,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
bindLinks: (links: SpecLinks) => void;
paintLinks: () => void;
/** 고른 관 측점 — 관 길이(자동) 계산에 씀. */
setChainage: (chainageM: number | null) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */
readOptions: () => Record<string, string | number>;
/** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존).
@@ -244,87 +250,56 @@ export function createFacilityOptionsForm(
root.className = "b05-drainage__facility";
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 기본값(관종 파형강관 · 관경 1000 · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴(PLAN 46-7).
// ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ─────────
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
const pipeRow = grid(
labeled("관종", pipeMaterial, "pipe_kind"),
labeled("관경", pipeDiameter, "pipe_diameter_mm"),
);
const wingRow = grid(labeled("관보호공 날개벽", wingWall, "wing_wall_type"));
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 「안 정함」이면 기슭막이 칸을 보이되 값은 안 실음.
const inletType = document.createElement("select");
inletType.replaceChildren(
new Option("— 안 정함 —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
"돌집수정 ㄴ형",
"돌집수정 ㄷ형",
]);
// 집수정도 재질·표면처리를 형태 한 축으로 합친다(사용자 지시 1).
const basinMaterial = optionalSelect("선택", [
"콘크리트",
"조립식 주철맨홀",
"돌쌓기(찰)",
"돌쌓기(메)",
]);
const basinMaterialField = labeled("형태", basinMaterial, "inlet_basin_material");
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1", "0");
// 기준측점 전 · 후 — 옛 횡단 조정창에만 있던 칸을 폼으로(계획서 24-4a).
const basinSplit = createBasinSplitFields(basinLength);
basinSplit.onChange(() => emit());
const basinRows = [
grid(
labeled("형식", basinForm, "inlet_basin_form"),
labeled("길이 (m)", stepper(basinLength, 1), "inlet_basin_length_m"),
),
basinSplit.row,
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, { height });
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
// 비어 있으면 아무 자리도 차지하지 않는다.
const inletSlot = document.createElement("div");
inletSlot.className = "b05-structure__adjust-slot";
const outletSlot = inletSlot.cloneNode() as HTMLDivElement;
inletGroup.body.append(
grid(
labeled("구조", inletType, ["inlet_type", "inlet_structure"]),
basinMaterialField,
inletRevet.formField,
),
...basinRows,
...inletRevet.rows,
inletSlot,
);
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = optionalSelect("— 안 정함 —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, { height });
outletGroup.body.append(
grid(labeled("구조", outletType, "outlet_type"), outletRevet.formField),
...outletRevet.rows,
outletSlot,
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
let linkSource: SpecLinks | null = null;
let chainage: number | null = null;
const pipeForm = createSpecForm(
{
get: () => linkSource?.get() ?? {},
pick: (key) => linkSource?.pick(key),
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
},
{
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
},
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
},
},
);
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
const inletSlot = pipeForm.slot("inlet");
const outletSlot = pipeForm.slot("outlet");
/** 유입 보호공 구조 → B06 유입측 형식 값(조정창과 같은 값). */
const inletStructureOf = (): InletStructureKind => {
const inlet = pipeForm.state().inlet?.[0] ?? {};
if (inlet.structure === "기슭막이") return "revet";
return inlet.structure === "집수정" && ["I", "L", "U"].includes(inlet.form ?? "")
? (inlet.form as InletStructureKind)
: "auto";
};
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
@@ -425,13 +400,11 @@ export function createFacilityOptionsForm(
root.append(
saveNote,
pipeRow,
wingRow,
pipeForm.root,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
revetTopRow,
revetInlet.root,
@@ -448,12 +421,8 @@ export function createFacilityOptionsForm(
if (current === null) return;
const isPipe = current === "pipe";
const isBox = current === "box_culvert";
// 관종·관경은 배수관과 세월교가 공유한다.
pipeRow.hidden = !isPipe && current !== "ford_bridge";
// 관보호공 날개벽 원단위는 **배수관 Ø800·Ø1000** 것뿐이다 — 다른 시설에서는 숨긴다.
wingRow.hidden = !isPipe;
inletGroup.root.hidden = !isPipe;
outletGroup.root.hidden = !isPipe;
pipeRow.hidden = current !== "ford_bridge";
pipeForm.root.hidden = !isPipe;
boxWrap.hidden = !isBox;
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
const hasWing = isBox || current === "ford_bridge";
@@ -482,17 +451,6 @@ export function createFacilityOptionsForm(
wingInFields.syncVisibility();
wingOutFields.syncVisibility();
}
if (isPipe) {
// 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8).
// 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29).
const kind = inletKindOf(inletType.value);
const basin = kind.type === "집수정";
const revet = kind.type === "기슭막이";
basinRows.forEach((row) => (row.hidden = !basin));
basinMaterialField.hidden = !basin;
inletRevet.formField.hidden = !revet;
inletRevet.rows.forEach((row) => (row.hidden = !revet));
}
}
function emit(): void {
@@ -500,11 +458,6 @@ export function createFacilityOptionsForm(
callbacks.onChange?.();
}
[inletType, outletType, wingWall, basinForm, basinMaterial, basinLength].forEach((input) =>
input.addEventListener("change", emit),
);
inletRevet.onChange(emit);
outletRevet.onChange(emit);
revetInlet.fields.onChange(emit);
revetOutlet.fields.onChange(emit);
revetSide.addEventListener("change", emit);
@@ -521,9 +474,6 @@ export function createFacilityOptionsForm(
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
let inletStructureHandler: ((value: InletStructureKind) => void) | null = null;
inletType.addEventListener("change", () =>
inletStructureHandler?.(inletKindOf(inletType.value).structure),
);
return {
root,
@@ -538,23 +488,31 @@ export function createFacilityOptionsForm(
revetSideLabels = labels;
syncVisibility();
},
inletStructure: () => inletKindOf(inletType.value).structure,
inletStructure: inletStructureOf,
setInletStructure(value) {
// 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다.
const type = inletKindOf(inletType.value).type;
const hit =
INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ??
INLET_KINDS.find((kind) => kind.structure === value);
if (hit) inletType.value = hit.label;
// 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움)
if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이");
else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정")
pipeForm.setValue("inlet", "form", value === "auto" ? "" : value);
syncVisibility();
},
bindLinks(links) {
linkSource = links;
pipeForm.paintLinks();
},
paintLinks: () => pipeForm.paintLinks(),
setChainage(chainageM) {
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
},
onInletStructureChange(handler) {
inletStructureHandler = handler;
},
setHiddenOptions(facility, registryKeys) {
hiddenOptions.set(facility, registryKeys);
},
setFacility(facility, options = {}, designFlow = null) {
setFacility(facility, options = {}, designFlow = null, definition = null) {
current = facility;
hiddenOptions.remember(options);
ford.setDesignFlow(designFlow ?? null);
@@ -567,25 +525,10 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type");
const savedStructure = text("inlet_structure");
inletType.value = savedType
? (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label
: "";
outletType.value = text("outlet_type");
wingWall.value = text("wing_wall_type");
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m");
basinSplit.write(options);
inletRevet.write(options);
outletRevet.write(options);
if (facility === "pipe") {
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
}
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 「안 정함」.
const savedWidth = Number.parseFloat(text("body_width_m"));
@@ -625,24 +568,7 @@ export function createFacilityOptionsForm(
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
putPipe();
const inletKind = inletKindOf(inletType.value);
if (wingWall.value) options.wing_wall_type = wingWall.value;
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
if (inletType.value) {
options.inlet_type = inletKind.type;
options.inlet_structure = inletKind.structure;
}
if (inletKind.type === "집수정") {
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
putNumber(options, "inlet_basin_length_m", basinLength.value);
basinSplit.read(options);
} else {
inletRevet.read(options);
}
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
putNumber(options, "body_width_m", boxWidth.value);
@@ -26,15 +26,14 @@ const wingKeys = (side: "in" | "out"): string[] => [
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
// 배관 = 표 01 명세 키(PLAN 52-1 · `design_structure_pipe`) — 옛 관종 · 날개벽 · 집수정 형식은 폼에서
// 빠져 이어 붙임(지난 정의 값) · 선택 컨테이너 키는 `FORM_OPTION_PREFIXES`
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
"inlet_basin_height_m",
"inlet_basin_before_m",
"inlet_basin_after_m",
...revetKeys("inlet"),
@@ -55,6 +54,11 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
/** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */
export const FORM_OPTION_PREFIXES: Readonly<Record<string, readonly string[]>> = {
pipe: ["soil_add_", "chute_add_"],
};
/** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */
const REVETMENT_FIELDS = ["form", "length_m", "height_m", "before_m", "after_m"];
@@ -95,7 +99,10 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const heads = FORM_OPTION_PREFIXES[next.facility] ?? [];
const kept = Object.entries(previous.options ?? {}).filter(
([key]) => !managed.has(key) && !heads.some((head) => key.startsWith(head)),
);
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
const summary =
next.summary_item === undefined && previous.summary_item !== undefined
@@ -186,7 +186,7 @@ export function shapeFields(
export interface StructureDefinition {
판?: number;
구조물군?: Record<string, string>;
종류?: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
종류?: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
배치?: Record<string, Record<string, DefinedSpot>>;
규칙?: DefinedRule[];
@@ -206,6 +206,7 @@ export function withDefinition<
group_name: definition.구조물군?.[type.group],
show_anchor: kind.기준측점,
behaviors: kind.동작 ?? [],
spec_names: kind.이름 ?? {},
spots: definition.배치?.[type.type_id] ?? null,
rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id),
...(type.groups
@@ -156,6 +156,14 @@ export function buildStructuresForm(options: {
const summary = buildSummaryRow();
summary.root.hidden = true;
const groupBoxes = createGroupBoxes();
// 배관 명세 컨테이너 「구조물 조합」 잇기 = 그룹 값(`group_items`)과 같은 자리 · 같은 [고르기] 길(PLAN 52-1)
facilityOptions.bindLinks({
get: groupBoxes.get,
pick: groupBoxes.pick,
clear: groupBoxes.clear,
set: groupBoxes.setOne,
});
groupBoxes.watch(facilityOptions.paintLinks);
const staleBox = document.createElement("fieldset");
staleBox.className = "b05-structure__group b05-structure__stale";
staleBox.hidden = true;
@@ -164,11 +172,11 @@ export function buildStructuresForm(options: {
typeRow,
ownerNote,
positionRow,
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
positionDivider,
optionRow,
facilityOptions.root,
staleBox,
summary.root,
groupBoxes.root,
actions,
);
@@ -22,6 +22,7 @@ import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Su
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 { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import {
GROUP_JOIN,
type GroupItems,
@@ -46,6 +47,11 @@ export interface GroupBoxes {
shapeKeys: (type?: StructureType | null) => string[];
/** 구조물 옵션에서 형상 값을 실음(폼 불러오기). */
setShapes: (options: Record<string, string | number> | undefined) => void;
/** 명세 컨테이너 잇기 줄(PLAN 52-1)이 쓰는 [고르기] · [해제] — 그룹 상자와 같은 길. */
pick: (groupId: string) => void;
clear: (groupId: string) => void;
/** 값이 바뀔 때마다 부름(명세 컨테이너 잇기 줄 다시 그림). */
watch: (listener: () => void) => void;
}
interface GroupHandlers {
@@ -63,6 +69,8 @@ export function createGroupBoxes(): GroupBoxes {
let handlers: GroupHandlers | null = null;
let shapes: Record<string, string> = {};
let current: StructureType | null = null;
const listeners: Array<() => void> = [];
const changed = (): void => listeners.forEach((listener) => listener());
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>();
@@ -107,7 +115,8 @@ export function createGroupBoxes(): GroupBoxes {
rows.clear();
shapeInputs.clear();
current = type;
const groups = type?.groups ?? [];
// 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움
const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []);
const boxes: HTMLElement[] = [];
bodies = [];
// 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식)
@@ -187,7 +196,10 @@ export function createGroupBoxes(): GroupBoxes {
boxes.push(box);
bodies.push(body);
}
const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null;
const stale =
type && !STRUCTURE_SPECS[type.type_id]
? paintStale(new Set(groups.map((g) => g.id)))
: null;
root.replaceChildren(...boxes, ...(stale ? [stale] : []));
root.hidden = !type || (!boxes.length && !stale);
// 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로
@@ -204,6 +216,7 @@ export function createGroupBoxes(): GroupBoxes {
set(next) {
items = { ...next };
for (const [id, row] of rows) row.set(items[id] ?? null);
changed();
},
setOne(groupId, item) {
if (item) items = { ...items, [groupId]: item };
@@ -212,7 +225,11 @@ export function createGroupBoxes(): GroupBoxes {
items = rest;
}
rows.get(groupId)?.set(item);
changed();
},
pick: (groupId) => handlers?.pick(groupId),
clear: (groupId) => handlers?.clear(groupId),
watch: (listener) => listeners.push(listener),
bind(next) {
handlers = next;
},
@@ -24,6 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import {
hasSpecContainers,
paintStale,
renderOptionFields as renderOptionFieldsInto,
} from "./B05_Profile_UI_Structures_Panel_Options";
@@ -56,11 +57,9 @@ import {
hasComputedRange,
readBeforeOption,
readLengthOption,
} from "./B05_Profile_UI_Structures_Panel_Types";
import type {
PipeFacilityItem,
StructuresCallbacks,
StructuresSection,
type PipeFacilityItem,
type StructuresCallbacks,
type StructuresSection,
} from "./B05_Profile_UI_Structures_Panel_Types";
export function createStructuresSection(
@@ -228,7 +227,7 @@ export function createStructuresSection(
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
positionRow.hidden = !type;
positionDivider.hidden = !type;
summary.root.hidden = !type;
summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
@@ -252,7 +251,8 @@ export function createStructuresSection(
designFlow: number | null = null,
): void {
const kind = facilityFormKind(currentType());
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow);
facilityOptions.setChainage(selectedPipeChainage ?? tempPipeChainage); // 관 길이(자동 · 52-1)
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow, currentType());
if (kind)
paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType()));
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
@@ -20,6 +20,12 @@ import {
staleValues,
} from "./B05_Profile_UI_Structures_Define";
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { specKeys } from "../A00_Common/design/design_structure_spec";
/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */
export const hasSpecContainers = (type: StructureType): boolean =>
!!STRUCTURE_SPECS[type.type_id]?.containers.length;
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
export interface OptionRenderContext {
@@ -175,12 +181,18 @@ export function paintStale(
/** 그룹 형상 키(`{그룹 id}__{칸}`) — 정의된 그룹 몫이라 지난 값이 아님. */
shapeKeys: string[] = [],
): void {
// 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎
const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined;
const own = spec ? specKeys(spec) : { keys: [], prefixes: [] };
const known = [
...shapeKeys,
...(type?.options ?? []).map((o) => o.key),
...(facility ? (FORM_OPTION_KEYS[facility] ?? []) : []),
];
const stale = type ? staleValues(values, known) : [];
...own.keys,
].filter((key) => !spec?.retired.includes(key));
const stale = type
? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p)))
: [];
box.replaceChildren();
box.hidden = !stale.length;
if (!stale.length) return;
@@ -525,3 +525,22 @@
.b05-structure__stale input[readonly] {
opacity: 0.7;
}
/* 구조물 명세 컨테이너(PLAN 52-1) — 상자 차례 · 긴 잇기 이름이 상자를 밀어 넓히지 않게(fieldset 최소 폭) */
.b05-structure__spec {
display: flex;
flex-direction: column;
gap: 6px;
}
.b05-structure__spec[hidden] {
display: none;
}
.b05-structure__spec .b05-structure__group {
min-width: 0;
}
.b05-structure__spec-add {
align-self: flex-start;
}
@@ -87,8 +87,15 @@ def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, An
return errors, keys
def _names_ok(value: Any) -> bool:
"""구조물 명세 이름 바꿈 표 — `{컨테이너[.항목[=값]]: 빈 글 아닌 이름}`."""
return isinstance(value, dict) and all(
isinstance(k, str) and isinstance(v, str) and v.strip() for k, v in value.items()
)
def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[str]:
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름]}`."""
"""종류마다 덧칸 — `{기준측점: 참거짓, 동작: [동작 이름], 이름: {명세 이름 키: 이름}}`(이름 = PLAN 52)."""
errors: list[str] = []
acts = doc.get("동작") if isinstance(doc.get("동작"), dict) else {}
for tid, row in doc["종류"].items():
@@ -100,8 +107,10 @@ def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s
continue
if name == "동작" and isinstance(value, list) and all(a in acts for a in value):
continue
if name == "이름" and _names_ok(value):
continue
errors.append(
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들)"
f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)"
)
return errors
+58 -2
View File
@@ -1,5 +1,5 @@
{
"판": 5,
"판": 6,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -3232,7 +3232,63 @@
"계곡통과시설",
"유입유출_묶음",
"기슭막이_한벌"
]
],
"이름": {
"pipe.diameter": "관경",
"pipe.diameter=800": "800",
"pipe.diameter=1000": "1000",
"pipe.diameter=1200": "1200",
"pipe.diameter=1500": "1500",
"pipe.length": "관 길이 (m)",
"inlet.structure": "구조",
"inlet.structure=집수정": "집수정",
"inlet.structure=기슭막이": "흙막이",
"inlet.form": "형태",
"inlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"inlet.form=돌쌓기(메)": "돌 쌓기(메)",
"inlet.form=콘크리트": "콘크리트",
"inlet.form=돌망태": "돌망태",
"inlet.form=통나무·목재틀": "통나무/목재틀",
"inlet.form=바자": "바자",
"inlet.form=I": "I형",
"inlet.form=L": "L형",
"inlet.form=U": "ㄷ형",
"inlet.length": "길이 (m)",
"inlet.height": "높이 (m)",
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"outlet.structure": "구조",
"outlet.structure=기슭막이": "흙막이",
"outlet.form": "형태",
"outlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"outlet.form=돌쌓기(메)": "돌 쌓기(메)",
"outlet.form=콘크리트": "콘크리트",
"outlet.form=돌망태": "돌망태",
"outlet.form=통나무·목재틀": "통나무/목재틀",
"outlet.form=바자": "바자",
"outlet.length": "길이 (m)",
"outlet.height": "높이 (m)",
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"soil_add": "흙막이 추가",
"soil_add.structure": "구조",
"soil_add.structure=기슭막이": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
"soil_add.form=돌쌓기(메)": "돌 쌓기(메)",
"soil_add.form=콘크리트": "콘크리트",
"soil_add.form=돌망태": "돌망태",
"soil_add.form=통나무·목재틀": "통나무/목재틀",
"soil_add.form=바자": "바자",
"soil_add.length": "길이 (m)",
"soil_add.height": "높이 (m)",
"soil_add.before": "기준측점 전 (m)",
"soil_add.after": "기준측점 후 (m)",
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
}
},
"box_culvert": {
"동작": [
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
/* 구조물 명세 공용 틀 · 배관 명세 단위검증 헬퍼(PLAN 52-1) — `design_structure_spec.ts` ·
* `design_structure_pipe.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌.
* 인자 `names` 면 배관 이름 표(정의 `종류.pipe.이름`)만 냄.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (file, deps = {}) => {
const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", file), "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m));
return box.exports;
};
const s = load("design_structure_spec.ts");
const { PIPE_SPEC } = load("design_structure_pipe.ts");
if (process.argv[2] === "names") {
process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC)));
process.exit(0);
}
const old = {
pipe_kind: "흄관",
wing_wall_type: "A-TYPE",
pipe_diameter_mm: 1000,
inlet_type: "집수정",
inlet_structure: "L",
inlet_basin_length_m: 2,
inlet_basin_before_m: 1,
inlet_basin_after_m: 1,
outlet_type: "기슭막이",
outlet_revet_form: "돌쌓기(메)",
outlet_revet_length_m: 10,
outlet_revet_height_m: 2.5,
soil_add_2_structure: "기슭막이",
soil_add_2_form: "바자",
soil_add_5_length_m: 4,
chute_add_1_structure: "도수로",
};
const state = s.readState(PIPE_SPEC, old);
const back = s.writeOptions(PIPE_SPEC, state);
// 흙막이로 바꿈 → 갈래 키가 기슭막이 쪽 · also(inlet_structure revet)
const soil = JSON.parse(JSON.stringify(state));
soil.inlet[0].structure = "기슭막이";
soil.inlet[0].form = "콘크리트";
soil.inlet[0].length = "6";
const soilOut = s.writeOptions(PIPE_SPEC, soil);
const inlet = PIPE_SPEC.containers.find((c) => c.id === "inlet");
const form = inlet.items.find((i) => i.id === "form");
const keys = s.specKeys(PIPE_SPEC);
const out = {
containers: PIPE_SPEC.containers.map((c) => [c.id, c.title, c.required]),
state,
back,
soilOut,
formBasin: s.choicesOf(form, { structure: "집수정" }).map((c) => c.label),
formSoil: s.choicesOf(form, { structure: "기슭막이" }).map((c) => c.label),
formNone: s.branchOf(form, {}),
outletForms: s
.choicesOf(
PIPE_SPEC.containers.find((c) => c.id === "outlet").items.find((i) => i.id === "form"),
{ structure: "기슭막이" },
)
.map((c) => c.label),
keys,
links: PIPE_SPEC.containers.map((c) => s.linkOf(c, 2)),
split: {
lengthFromSides: s.solveSplit({ length: "10", before: "3", after: "4" }, ["after", "before"]),
beforeFromLength: s.solveSplit({ length: "10", before: "3", after: "4" }, ["after", "length"]),
defaultKeepsLength: s.solveSplit({ length: "10", before: "3", after: "" }, ["before"]),
blankKept: s.solveSplit({ length: "", before: "3", after: "4" }, ["before", "length"]),
clamp: s.solveSplit({ length: "2", before: "", after: "5" }, ["after", "length"]),
},
names: {
renamed: s.itemLabel(inlet, form, { "inlet.form": "모양" }),
title: s.containerTitle(inlet, {}, "그룹 이름"),
titleOwn: s.containerTitle(inlet, { inlet: "유입 보호" }, "그룹 이름"),
choice: s.choiceLabel(inlet, form, { value: "U", label: "ㄷ형" }, {}),
},
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""구조물 명세 공용 틀 + 횡단배수 › 배관(표 01) — PLAN 52-1.
순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-1_배관_컨테이너.js`.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
def _node(*args):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), *args],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def out():
return _node()
def test_컨테이너_다섯_필수_셋_선택_둘(out):
assert out["containers"] == [
["pipe", "관", True],
["inlet", "관 보호공 (유입)", True],
["outlet", "관 보호공 (유출)", True],
["soil_add", "흙막이 추가", False],
["chute_add", "도수로/돌붙임", False],
]
def test_형태_선택지는_구조를_따름(out):
assert out["formBasin"] == ["I형", "L형", "ㄷ형"]
assert out["formSoil"] == [
"돌 쌓기(찰)",
"돌 쌓기(메)",
"콘크리트",
"돌망태",
"통나무/목재틀",
"바자",
]
assert out["formNone"] is None # 구조 안 고름 = 형태 칸 가림
assert out["outletForms"] == out["formSoil"]
def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out):
back = out["back"]
assert back["pipe_diameter_mm"] == 1000
assert back["inlet_structure"] == "L" and back["inlet_basin_length_m"] == 2
assert back["outlet_revet_height_m"] == 2.5
# 표 01 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "pipe_kind" not in back and "wing_wall_type" not in back
def test_선택_컨테이너는_인스턴스마다_차례대로_다시_매김(out):
assert [i["structure"] for i in out["state"]["soil_add"]] == ["기슭막이", ""]
assert out["back"]["soil_add_1_form"] == "바자"
assert "soil_add_2_form" not in out["back"]
assert out["back"]["chute_add_1_structure"] == "도수로"
def test_흙막이로_바꾸면_기슭막이_키와_revet(out):
s = out["soilOut"]
assert s["inlet_type"] == "기슭막이" and s["inlet_structure"] == "revet"
assert s["inlet_revet_form"] == "콘크리트" and s["inlet_revet_length_m"] == 6
assert "inlet_basin_length_m" not in s
def test_길이_전_후_맞물림_마지막에_고친_둘을_지킴(out):
sp = out["split"]
assert sp["lengthFromSides"]["length"] == "7"
assert sp["beforeFromLength"]["before"] == "6"
assert sp["defaultKeepsLength"]["after"] == "7"
assert sp["blankKept"]["length"] == "" # 지킬 칸이 비면 안 셈
assert sp["clamp"]["before"] == "0"
def test_잇기_키와_이름_바꿈(out):
assert out["links"] == ["grv6lny", "gnbf9m9", "g9lq9uk", "gsoiladd2", "gchuteadd2"]
assert out["names"] == {
"renamed": "모양",
"title": "그룹 이름",
"titleOwn": "유입 보호",
"choice": "ㄷ형",
}
assert {"soil_add_", "chute_add_"} == set(out["keys"]["prefixes"])
def test_정의_파일에_이름_표가_올라_있음():
names = _node("names")
with open(
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
on_file = doc["종류"]["pipe"]["이름"]
assert set(names) == set(on_file)
assert on_file["inlet.structure=기슭막이"] == "흙막이"
@@ -96,7 +96,8 @@ def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -
assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음
assert "inlet_basin_before_m" not in options # 폼 칸(24-4a) — 집수정을 걷으면 함께 지움
assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김
assert "inlet_basin_form" not in options # 폼이 아는 칸을 폼이 비움 → 지움
# 표 01 명세(PLAN 52-1)가 뺀 옛 칸 = 폼이 모르는 칸 → 남음(지난 정의 값)
assert options["inlet_basin_form"] == "□형(기본형)"
assert merged["out"][1]["options"] == {"side": "좌", "back_len_cm": "35"}
@@ -0,0 +1,202 @@
/* 52-1 — B05 · B06 좌측 「구조물 배치」 배관 = 사용자 구조물 표 01 명세(컨테이너 다섯)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m521 = "단계";` 를 붙여
* orca eval --expression "window.__m521='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠)
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m521='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 · 관 길이
* orca eval --expression "window.__m521='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 ·
* soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂
* 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06)
*/
(async () => {
const MODE = window.__m521 || "edit";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"));
const spec = () => document.querySelector(".b05-structure__spec");
const box = (c, i = 0) => spec().querySelectorAll(`[data-spec-container="${c}"]`)[i];
const ctl = (c, item, i = 0) =>
box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input");
const set = async (c, item, v, i = 0) => {
const el = ctl(c, item, i);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(200);
};
const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text);
const pickPipe = async () => {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
/배관 1$/.test(l.textContent.trim()),
);
// 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더(재선택 = 해제)
(li.querySelector("button") ?? li).click();
await w(800);
if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click();
await until(() => spec() && spec().offsetParent !== null);
await w(500);
};
const shape = () =>
[...spec().querySelectorAll("[data-spec-container]")].map((b) => ({
c: b.dataset.specContainer,
legend: b.querySelector("legend").firstChild.textContent,
items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem),
link: b.querySelector(".b05-structure__summary-name")?.textContent,
}));
const linkName = (c, i = 0) =>
box(c, i).querySelector(".b05-structure__summary-name").classList.contains("is-assigned");
const pickLink = async (c) => {
box(c).querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
// 첫 잎까지 펼침
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
const closed = rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸");
closed?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(500);
};
const out = { mode: MODE };
if (MODE === "edit") {
const before = await fetchApi.fetchDetailPipePoints(projectId);
sessionStorage.setItem("__521_original", JSON.stringify(before.pipe_points));
await pickPipe();
const s0 = shape();
out.kinds =
s0.filter((b) => ["pipe", "inlet", "outlet"].includes(b.c)).length +
spec().querySelectorAll("[data-spec-add]").length;
out.required = ["pipe", "inlet", "outlet"].every((c) => box(c)?.offsetParent !== null);
out.addButtons = [...spec().querySelectorAll("[data-spec-add]")].map((b) => b.textContent);
// 형태 선택지가 구조를 따름
await set("inlet", "structure", "집수정");
out.formBasin = labels("inlet", "form");
await set("inlet", "structure", "기슭막이");
out.formSoil = labels("inlet", "form");
await set("inlet", "form", "돌망태");
// 길이 = 전 + 후 — 길이 10 · 전 3 → 후 7 · 이어서 후 4 → 길이 7(전 · 후를 지킴)
await set("inlet", "length", "10");
await set("inlet", "before", "3");
out.splitAfter = ctl("inlet", "after").value;
await set("inlet", "after", "4");
out.splitLength = ctl("inlet", "length").value;
// 선택 컨테이너 — 흙막이 추가 둘 → 첫째 뺌 · 도수로/돌붙임 하나
spec().querySelector('[data-spec-add="soil_add"]').click();
await w(200);
spec().querySelector('[data-spec-add="soil_add"]').click();
await w(200);
await set("soil_add", "structure", "기슭막이", 0);
await set("soil_add", "form", "바자", 0);
await set("soil_add", "structure", "기슭막이", 1);
await set("soil_add", "form", "콘크리트", 1);
out.soilAddTwo = spec().querySelectorAll('[data-spec-container="soil_add"]').length;
out.soilFormsSoilAdd = labels("soil_add", "form").length;
box("soil_add", 0).querySelector('[data-spec-remove="soil_add"]').click();
await w(300);
out.soilAddAfterRemove = [...spec().querySelectorAll('[data-spec-container="soil_add"]')].map(
(b, i) => [
b.querySelector("legend").firstChild.textContent,
ctl("soil_add", "form", i).value,
],
);
spec().querySelector('[data-spec-add="chute_add"]').click();
await w(200);
await set("chute_add", "structure", "돌붙임");
out.chuteChoices = labels("chute_add", "structure");
// 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂
await pickLink("inlet");
out.linkInlet = linkName("inlet");
await pickLink("outlet");
out.linkOutletPicked = linkName("outlet");
box("outlet").querySelector('[data-b05-summary="clear"]').click();
await w(300);
out.linkOutletCleared = !linkName("outlet");
// [저장]
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
const after = await fetchApi.fetchDetailPipePoints(projectId);
const p = after.pipe_points.find((x) => x.chainage_m > 80 && x.chainage_m < 90); // 배관 1 = 4+5
out.file = p && {
options: p.options,
group_items: Object.keys(p.group_items ?? {}),
};
}
if (MODE === "verify") {
await pickPipe();
out.reopened = {
inlet: ["structure", "form", "length", "before", "after"].map((k) => ctl("inlet", k).value),
soilAdd: [...spec().querySelectorAll('[data-spec-container="soil_add"]')].map(
(_, i) => ctl("soil_add", "form", i).value,
),
chute: ctl("chute_add", "structure").value,
linkInlet: linkName("inlet"),
linkOutlet: linkName("outlet"),
};
const b05 = shape();
out.b05Length = ctl("pipe", "length").value;
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await pickPipe();
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
out.b06Length = ctl("pipe", "length").value;
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__521_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const keys = [
"chainage_m",
"source",
"facility",
"start_m",
"end_m",
"options",
"summary_item",
"group_items",
];
const points = original.map((p) =>
Object.fromEntries(
keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]),
),
);
await fetchApi.saveDetailPipePoints(projectId, points);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("aislo:draft:pipes:") || k === "__521_original")
sessionStorage.removeItem(k);
const now = await fetchApi.fetchDetailPipePoints(projectId);
const pick = (list) =>
JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}]));
out.restored = pick(now.pipe_points) === pick(original);
}
return JSON.stringify(out);
})();