/* ============================================================================= * M02_SO_Def_Model.ts * 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음. * 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`). * 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`). * 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈. * ========================================================================== */ export type OptionForm = "select" | "number" | "text" | "bool"; export interface Spot { 줄: number; 칸: number; 너비: number; } export interface OptionDef { key: string; label: string; input: OptionForm; choices?: string[]; unit?: string | null; default?: unknown; enabled?: boolean; phase?: "b05" | "detail"; [more: string]: unknown; } export interface TypeDef { type_id: string; group: string; name: string; options: OptionDef[]; enabled?: boolean; [more: string]: unknown; } export type ShapeKey = "길이" | "높이" | "전" | "후"; export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"]; export interface GroupDef { id: string; 이름: string; 옵션: string[]; 형상?: Partial>; } /** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */ export interface RuleDef { 종류: string; 만약: { 옵션: string; 값들: string[] }; 그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown }; } export interface StructDef { 판: number; 구조물군: Record; 동작: Record; types: TypeDef[]; 종류: Record; 표시: Record>; 그룹: Record; 배치: Record>; 규칙: RuleDef[]; [more: string]: unknown; } export const NAME_MAX = 40; /** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */ export const GRID_COLS = 2; /** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */ export const clone = (value: T): T => structuredClone(value); /** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */ export function newKey(prefix: string, taken: Iterable): string { const used = new Set(taken); for (;;) { const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`; if (key.length > prefix.length + 3 && !used.has(key)) return key; } } /** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */ export function step(list: T[], index: number, delta: number): boolean { const at = index + delta; if (index < 0 || at < 0 || at >= list.length) return false; [list[index], list[at]] = [list[at], list[index]]; return true; } /** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */ export const live = (list: readonly T[]): T[] => list.filter((x) => x.enabled !== false); /** 기준 측점 칸 보이기 — 없으면 보임 */ export const baseShown = (def: StructDef, typeId: string): boolean => def.종류[typeId]?.기준측점 !== false; /** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */ export function badNames(names: readonly string[]): string[] { const seen = new Set(); const bad: string[] = []; for (const raw of names) { const name = raw.trim(); if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)"); seen.add(name); } return bad; } /** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */ export function stepFamily(def: StructDef, key: string, delta: number): boolean { const entries = Object.entries(def.구조물군); if ( !step( entries, entries.findIndex(([k]) => k === key), delta, ) ) return false; def.구조물군 = Object.fromEntries(entries); return true; } /** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */ export function stepType(def: StructDef, typeId: string, delta: number): boolean { const t = def.types.find((x) => x.type_id === typeId); if (!t) return false; const same = def.types.filter((x) => x.group === t.group); const other = same[same.indexOf(t) + delta]; if (!other) return false; const i = def.types.indexOf(t); const j = def.types.indexOf(other); [def.types[i], def.types[j]] = [def.types[j], def.types[i]]; return true; } /** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */ export function renameChoice( def: StructDef, typeId: string, o: OptionDef, from: string, to: string, ): void { if (from === to || !o.choices) return; o.choices = o.choices.map((c) => (c === from ? to : c)); if (o.default === from) o.default = to; for (const r of def.규칙) { if (r.종류 !== typeId) continue; if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v)); if (r.그러면.옵션 === o.key) { if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v)); if (r.그러면.기본값 === from) r.그러면.기본값 = to; } } } /** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */ export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void { o.choices = (o.choices ?? []).filter((c) => c !== value); if (o.default === value) o.default = null; for (const r of def.규칙) { if (r.종류 !== typeId) continue; if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value); if (r.그러면.옵션 === o.key) { if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value); if (r.그러면.기본값 === value) delete r.그러면.기본값; } } } /** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */ export function placeAll( options: readonly OptionDef[], spots: Record = {}, cols = GRID_COLS, ): Map { const out = new Map(); const taken = new Set(); const mark = (c: Spot): void => { for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`); }; const shown = live(options); for (const o of shown) { const s = spots[o.key]; if (!s) continue; const width = Math.max(1, Math.min(cols, s.너비 || 1)); const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width }; out.set(o.key, cell); mark(cell); } let row = 1; let col = 1; for (const o of shown) { if (out.has(o.key)) continue; while (taken.has(`${row}:${col}`)) { col += 1; if (col > cols) ((row += 1), (col = 1)); } const cell = { 줄: row, 칸: col, 너비: 1 }; out.set(o.key, cell); mark(cell); } return out; } /** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */ export function overlaps(cells: Map): string[] { const owner = new Map(); const bad = new Set(); for (const [key, c] of cells) for (let i = 0; i < c.너비; i += 1) { const at = `${c.줄}:${c.칸 + i}`; const other = owner.get(at); if (other) bad.add(other).add(key); else owner.set(at, key); } return [...bad]; } /** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */ export function ruleBad(def: StructDef, r: RuleDef): boolean { const t = def.types.find((x) => x.type_id === r.종류); const src = t?.options.find((o) => o.key === r.만약.옵션); const dst = t?.options.find((o) => o.key === r.그러면.옵션); if (!src || !dst || src === dst || !r.만약.값들.length) return true; if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true; const { 선택지, 보이기, 기본값 } = r.그러면; if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true; let allowed = dst.choices ?? []; if (선택지 !== undefined) { if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true; allowed = 선택지; } return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값)); } /** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */ export function problems(def: StructDef): string[] { const out: string[] = []; const fam = badNames(Object.values(def.구조물군)); if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`); for (const g of Object.keys(def.구조물군)) { const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name)); if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`); } for (const t of def.types) { const opt = badNames(t.options.map((o) => o.label)); if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`); for (const o of t.options) { if (o.input !== "select") continue; const ch = badNames(o.choices ?? []); if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`); if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); } const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); } def.규칙.forEach((r, i) => { if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`); }); return out; }