- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만) - 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* 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<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
|
|
}
|
|
|
|
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
|
|
export interface RuleDef {
|
|
종류: string;
|
|
만약: { 옵션: string; 값들: string[] };
|
|
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
|
|
}
|
|
|
|
export interface StructDef {
|
|
판: number;
|
|
구조물군: Record<string, string>;
|
|
동작: Record<string, string>;
|
|
types: TypeDef[];
|
|
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
|
|
표시: Record<string, Record<string, false>>;
|
|
그룹: Record<string, GroupDef[]>;
|
|
배치: Record<string, Record<string, Spot>>;
|
|
규칙: RuleDef[];
|
|
[more: string]: unknown;
|
|
}
|
|
|
|
export const NAME_MAX = 40;
|
|
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
|
|
export const GRID_COLS = 2;
|
|
|
|
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
|
|
export const clone = <T>(value: T): T => structuredClone(value);
|
|
|
|
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
|
|
export function newKey(prefix: string, taken: Iterable<string>): 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<T>(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 = <T extends { enabled?: boolean }>(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<string>();
|
|
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<string, Spot> = {},
|
|
cols = GRID_COLS,
|
|
): Map<string, Spot> {
|
|
const out = new Map<string, Spot>();
|
|
const taken = new Set<string>();
|
|
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, Spot>): string[] {
|
|
const owner = new Map<string, string>();
|
|
const bad = new Set<string>();
|
|
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;
|
|
}
|