Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Define.ts
T
eomsangdonandClaude Opus 5.5 5a85e0b321 feat(B05): 안전부대용지 = 구조물 표 07 — 대피로 · 가드레일 컨테이너 · 점형 일곱 (PLAN 52-7)
- 정의 판 7(판 6 이력) — 대피로(evac_road · 점형 · 상세 = 대피소 · 정차/작업장 · 차돌림곳) · 경계석 · 위험표시 · 취수정 새 키 · 가드레일 = 형태(손 입력) · 길이 · 기준측점 전 · 후 · 대피로 · 가드레일 「상세 내용」 컨테이너(정의 그룹 · 잇기 1)
- 반사경 방향 · 가드레일 옛 종류(kind)는 화면에서 빼고 지난 정의 값으로 남김
- 옛 대피소 · 정차·작업장 · 차돌림곳 = 고르기 목록에서 빼고 대피로 + 상세로 읽음 · 고치기 전까지 옛 종류 그대로 · 고쳐도 옛 칸 값 남김
- 가드레일 길이 = 전 + 후 = 배관 맞물림 solveSplit 그대로
- 컨테이너 종류(정의 그룹 있음)는 종류 단위 잇기 줄 가림 · 가린 시설 폼 칸이 그룹 상자로 딸려 오던 흠 막음
- 집계 알림 「가드레일·경계석·위험표지 종류 미정」 걷음
- 시험 test_52_7_safety · 정책 시험 B05 선택지 27 · ORCA ui_checks/52-7_안전부대용지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
2026-10-05 23:48:27 +09:00

240 lines
10 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, 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.동작 ?? [],
spec_names: 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) })) }
: {}),
};
});
}
/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음.
* 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */
export const ROAD_TYPE = "evac_road";
export const ROAD_DETAILS: Readonly<Record<string, string>> = {
refuge: "대피소",
work_yard: "정차/작업장",
turnaround: "차돌림곳",
};
/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */
export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id];
/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */
export function asRoad<T extends { type_id: string; options?: Record<string, string | number> }>(
record: T,
): T {
const detail = ROAD_DETAILS[record.type_id];
return detail
? { ...record, type_id: ROAD_TYPE, options: { detail, ...record.options } }
: record;
}