/* ============================================================================= * B05_Profile_UI_Structures_Define.ts * 구조물 정의 → 「구조물 배치」 칸 모양 (PLAN 48-3 · 48-4) — 화면 요소 없는 순수 규칙. * * 정의(`resources/master_template/구조물정의.json` · GET /structure-types 가 실어 줌)를 읽어 * 칸 차례 · 격자 · 안 씀 · 규칙(상위 값 → 하위 선택지 · 보이기 · 기본값)을 낸다. 틀 = * `docs/raw/M02_구조물옵션정의/정의_틀.md`. 선택지를 줄이면(숨김) 옛 값은 그 값만 남겨 보임. * 그리기는 `_Structures_Panel_Options`(레지스트리 칸) · `_Structures_Define_Form`(시설 폼 칸). * 값에 따른 계산 · 연동 제어는 여기 없음(48-5). * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ /** 정의가 옵션 하나에 실어 주는 몫 — 칸 그리기가 쓰는 것만. */ export interface DefinedOption { key: string; label: string; input: string; choices: string[]; default: string | number | null; visible?: boolean; /** 거짓 = 「안 씀」(정의 틀 3장) — 칸을 안 그림 · 옛 값은 그대로 남음. */ enabled?: boolean; } /** 상위 → 하위 규칙 한 줄(정의 `규칙`) — 상위 칸 값이 `만약.값들` 중 하나면 하위 칸에 `그러면` 을 씀. */ export interface DefinedRule { 종류?: string; 만약: { 옵션: string; 값들: Array }; 그러면: { 옵션: string; /** 하위 칸 선택지를 이 목록으로 좁힘. */ 선택지?: string[]; /** 하위 칸을 보이거나 가림. */ 보이기?: boolean; /** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */ 기본값?: string | number | null; }; } /** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */ export interface DefinedSpot { 줄?: number; 칸?: number; 너비?: number; } export interface DefinedType { options: DefinedOption[]; /** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */ show_anchor?: boolean; /** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */ spots?: Record | null; rules?: DefinedRule[] | null; } /** 칸 하나의 지금 모양 — 규칙을 건 뒤. */ export interface OptionState { visible: boolean; choices: string[]; } const whole = (n: unknown): number | null => typeof n === "number" && Number.isInteger(n) && n >= 1 ? n : null; /** 그릴 옵션 차례 — 배치가 있으면 (줄, 칸) 차례 · 없는 칸은 뒤에 정의 차례로. 안 씀 · 숨김은 뺌. */ export function orderedOptions(type: { options: T[]; spots?: Record | null; }): T[] { const spots = type.spots ?? {}; const rank = (key: string): [number, number] => { const spot = spots[key]; return [whole(spot?.줄) ?? Infinity, whole(spot?.칸) ?? Infinity]; }; return type.options .map((option, index) => ({ option, index, at: rank(option.key) })) .filter(({ option }) => option.enabled !== false && option.visible !== false) .sort((a, b) => a.at[0] - b.at[0] || a.at[1] - b.at[1] || a.index - b.index) .map(({ option }) => option); } /** 한 줄 칸 수 — 배치가 쓰는 가장 오른쪽 칸(1 ~ 6) · 배치 없으면 2. */ export function columnsOf(type: { spots?: Record | null }): number { let widest = 0; for (const spot of Object.values(type.spots ?? {})) { const col = whole(spot.칸) ?? 1; widest = Math.max(widest, col + (whole(spot.너비) ?? 1) - 1); } return widest ? Math.min(Math.max(widest, 2), 6) : 2; } /** 칸 하나의 CSS 격자 자리 — 배치 없으면 null(흐름대로). */ export function gridPlace( spot: DefinedSpot | undefined, columns: number, ): { row: string; column: string } | null { if (!spot) return null; const row = whole(spot.줄); const col = whole(spot.칸); const span = Math.min(whole(spot.너비) ?? 1, columns); return { row: row ? String(row) : "", column: col ? `${Math.min(col, columns)} / span ${span}` : `span ${span}`, }; } /** 기준 측점 칸을 보일지 — 정의가 끈 때만 가림. */ export function showsAnchor(type: { show_anchor?: boolean }): boolean { return type.show_anchor !== false; } const same = (a: unknown, b: unknown): boolean => String(a ?? "") === String(b ?? ""); /** 규칙을 지금 값에 건 칸 모양 — 걸린 줄의 선택지 · 보이기가 정의 기본을 덮음(뒤 줄이 이김). */ export function ruleStates( type: DefinedType, values: Record, ): Map { const states = new Map(); for (const option of type.options) states.set(option.key, { visible: true, choices: [...option.choices] }); for (const rule of type.rules ?? []) { const state = states.get(rule.그러면.옵션); // 가려진 상위 칸에 매인 줄은 안 걸림 — 가림이 사슬로 이어짐(정의 차례대로 적용) if (!state || states.get(rule.만약.옵션)?.visible === false) continue; if (!hits(rule, values)) continue; const pick = rule.그러면.선택지; if (pick) state.choices = pick.filter((c) => state.choices.includes(c)); if (typeof rule.그러면.보이기 === "boolean") state.visible = rule.그러면.보이기; } return states; } const hits = (rule: DefinedRule, values: Record): boolean => rule.만약.값들.some((value) => same(value, values[rule.만약.옵션])); /** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */ export function ruleDefaults( type: DefinedType, parent: string, values: Record, ): Record { const out: Record = {}; for (const rule of type.rules ?? []) { const value = rule.그러면.기본값; if (rule.만약.옵션 !== parent || value === undefined || value === null) continue; if (hits(rule, values)) out[rule.그러면.옵션] = value; } return out; } /** 지금 정의에 없는 키의 값 — 「지난 정의 값」 읽기만 칸으로 보임. `known` = 폼이 아는 키 전부. */ export function staleValues( values: Record | undefined, known: Iterable, ): Array<[string, string | number]> { const keys = new Set(known); return Object.entries(values ?? {}).filter(([key]) => !keys.has(key)); } /** 그룹 형상 칸(정의 틀 4장) — 길이 · 높이 · 기준 측점 전 · 후 마다 받기 켬끔 + 뜻. */ export type ShapeName = "길이" | "높이" | "전" | "후"; export const SHAPE_FIELDS: ReadonlyArray<{ name: ShapeName; suffix: string; label: string }> = [ { name: "길이", suffix: "length_m", label: "길이 (m)" }, { name: "높이", suffix: "height_m", label: "높이 (m)" }, { name: "전", suffix: "before_m", label: "기준측점 전 (m)" }, { name: "후", suffix: "after_m", label: "기준측점 후 (m)" }, ]; export type DefinedShape = Partial>; /** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 id}__{칸}` 으로 둠. */ export const shapeKey = (groupId: string, suffix: string): string => `${groupId}__${suffix}`; /** 그룹이 받는 형상 칸 — 받기 켠 것만 정해진 차례로. */ export function shapeFields( shape: DefinedShape | undefined, ): Array<{ suffix: string; label: string; meaning: string }> { return SHAPE_FIELDS.filter((f) => shape?.[f.name]?.받기 === true).map((f) => ({ suffix: f.suffix, label: f.label, meaning: shape?.[f.name]?.뜻 ?? "", })); } /** 정의 한 벌(GET /m02/structure-definition) 중 화면이 쓰는 칸. */ export interface StructureDefinition { 판?: number; 구조물군?: Record; 종류?: Record; 그룹?: Record>; 배치?: Record>; 규칙?: DefinedRule[]; } /** 종류 목록에 정의 덧칸을 얹음 — 구조물군 이름 · 기준 측점 · 동작 · 배치 · 규칙 · 그룹 형상. * 정의를 못 받았으면(null) 그대로 — 지금 화면과 같음. */ export function withDefinition< T extends { type_id: string; group: string; groups?: Array<{ id: string }> }, >(types: T[], definition: StructureDefinition | null): T[] { if (!definition) return types; return types.map((type) => { const kind = definition.종류?.[type.type_id] ?? {}; const shapes = new Map((definition.그룹?.[type.type_id] ?? []).map((g) => [g.id, g.형상])); return { ...type, group_name: definition.구조물군?.[type.group], show_anchor: kind.기준측점, behaviors: kind.동작 ?? [], spots: definition.배치?.[type.type_id] ?? null, rules: (definition.규칙 ?? []).filter((rule) => rule.종류 === type.type_id), ...(type.groups ? { groups: type.groups.map((g) => ({ ...g, shape: shapes.get(g.id) })) } : {}), }; }); }