/* ============================================================================= * 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; } /** 상위 값 하나에 걸린 갈래 — 저장 키 · 선택지. */ 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 }; /** 길이 = 전 + 후 맞물림 몫. */ split?: SplitRole; /** 고르기 값을 수로 저장(관경 등 옛 저장이 수). */ asNumber?: boolean; step?: number; /** 빈 칸 안내 — 함수면 지금 값으로 셈. */ hint?: string | ((values: Record) => 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; export type SpecState = Record; /** 항목의 지금 갈래 — `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, ): 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 { const out: Record = {}; 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(); 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>, recent: SplitRole[], ): Partial> { 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; 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; }