- 정의 덧칸 얹기 — 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
217 lines
9.2 KiB
TypeScript
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) })) }
|
|
: {}),
|
|
};
|
|
});
|
|
}
|