Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Define.ts
T
eomsangdonandClaude Opus 5.5 fc54a385df feat(B05): 48-3 · 48-4 구조물 배치 칸 = 구조물 정의대로 그림
- 정의 덧칸 얹기 — GET /m02/structure-definition 의 구조물군 이름 · 기준측점 · 동작 · 배치 · 규칙 · 그룹 형상을 종류에 얹음(못 받으면 옛 화면 그대로)
- 레지스트리 칸 — 배치(줄 · 칸 · 너비) 격자 · 차례 · 안 씀(enabled:false) · 규칙(선택지 · 보이기 · 기본값) 즉시 적용 · 규칙이 가린 칸은 저장 안 건드림
- 손코딩 시설 폼(동작 계곡통과시설) — 안 씀 · 선택지 · 규칙을 칸 표식으로 붙임 · 배치 · 이름은 손코딩 그대로
- 지난 정의 값 — 지금 정의에 없는 키 값을 읽기만 상자로
- 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 켬끔 · 값은 옵션 {그룹 id}__{칸}
- 정의에서 뺀 선택지를 고른 옛 값은 그 값만 남겨 보임
- 시험 test_b05_structures_define 8 · 헬퍼 문맥 groupBoxes 로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-05 16:15:37 +09:00

217 lines
9.2 KiB
TypeScript

/* =============================================================================
* 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 | number> };
그러면: {
옵션: string;
/** 하위 칸 선택지를 이 목록으로 좁힘. */
선택지?: string[];
/** 하위 칸을 보이거나 가림. */
보이기?: boolean;
/** 상위 값이 바뀌어 이 줄이 걸리면 하위 칸 값을 이 값으로. */
기본값?: string | number | null;
};
}
/** 배치 격자 한 칸 — 정의 `배치` 의 `{줄, 칸, 너비}`(1부터). */
export interface DefinedSpot {
줄?: number;
칸?: number;
너비?: number;
}
export interface DefinedType {
options: DefinedOption[];
/** 종류 덧칸 — 기준 측점 칸 보임(기본 보임). */
show_anchor?: boolean;
/** 옵션 키 → 배치 칸. 비면 정의 옵션 차례대로 2열. */
spots?: Record<string, DefinedSpot> | 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<T extends DefinedOption>(type: {
options: T[];
spots?: Record<string, DefinedSpot> | 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<string, DefinedSpot> | 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<string, string | number | undefined>,
): Map<string, OptionState> {
const states = new Map<string, OptionState>();
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<string, unknown>): boolean =>
rule.만약.값들.some((value) => same(value, values[rule.만약.옵션]));
/** 상위 값이 바뀌었을 때 하위 칸에 넣을 기본값 `{하위 키: 값}` — 걸린 줄 중 기본값이 있는 것만. */
export function ruleDefaults(
type: DefinedType,
parent: string,
values: Record<string, string | number | undefined>,
): Record<string, string | number> {
const out: Record<string, string | number> = {};
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<string, string | number> | undefined,
known: Iterable<string>,
): 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<Record<ShapeName, { 받기?: boolean; 뜻?: string }>>;
/** 그룹 형상 값 저장 키 — 구조물 옵션에 `{그룹 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<string, string>;
종류?: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
그룹?: Record<string, Array<{ id: string; 이름?: string; 형상?: DefinedShape }>>;
배치?: Record<string, Record<string, DefinedSpot>>;
규칙?: 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) })) }
: {}),
};
});
}