- 명세 컨테이너 칸 max — 차면 [추가] 숨김 · 빼면 다시 보임 · 옛 저장이 넘쳐도 다 보이고 지우지 않음 - 물넘이 포장 chute · 기타 detail = max 1 · 배관 흙막이 추가 · 도수로/돌붙임은 여러 개 그대로 - 시험: test_52_17_single_container · 헬퍼 max · ui_checks 52-3 · 52-10 을 하나만 흐름으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* design_structure_spec.ts
|
|
* 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌.
|
|
*
|
|
* · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[]
|
|
* · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개 · `max` 면 그 수까지)
|
|
* · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) ·
|
|
* 맞물림(길이 = 전 + 후)
|
|
* · 상위 값에 따른 선택지 · 저장 키 = `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" | "text" | "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;
|
|
/** 선택 컨테이너 개수 상한 — 차면 [추가] 숨김(옛 저장이 넘쳐도 다 보임). 없으면 여러 개. */
|
|
max?: number;
|
|
/** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */
|
|
link: string;
|
|
items: SpecItem[];
|
|
}
|
|
|
|
export interface StructureSpec {
|
|
typeId: string;
|
|
containers: SpecContainer[];
|
|
/** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */
|
|
retired: string[];
|
|
}
|
|
|
|
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
|
|
export const TEXT_MAX = 100;
|
|
|
|
/** 컨테이너 인스턴스 한 벌 값 `{항목 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) => {
|
|
const before = Object.keys(out).length;
|
|
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] = item.kind === "text" ? raw.slice(0, TEXT_MAX) : raw;
|
|
if (c.required)
|
|
Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also);
|
|
}
|
|
const first = c.items.find((item) => item.kind !== "auto");
|
|
if (!c.required && first && Object.keys(out).length === before)
|
|
out[instanceKey(c, index + 1, first)] = "";
|
|
});
|
|
}
|
|
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;
|
|
}
|