- M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖
- 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트
- 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음
- 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면
- 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음
- drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침)
- 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음
- 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리
- 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
240 lines
10 KiB
TypeScript
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>; 본보기?: 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) — 옛 대피 종류는 대피로 + 상세로 읽음(고르기 목록 숨김 = 구조물 표 처음값 hidden · 322-1).
|
|
* 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */
|
|
export const ROAD_TYPE = "evac_road";
|
|
export const ROAD_DETAILS: Readonly<Record<string, string>> = {
|
|
refuge: "대피소",
|
|
work_yard: "정차/작업장",
|
|
turnaround: "차돌림곳",
|
|
};
|
|
|
|
/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */
|
|
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;
|
|
}
|