feat(M02): 48-2 구조물 옵션 정의 편집 화면 — 군 · 종류 · 옵션 · 선택지 · 배치 격자 · 그룹 형상 · 규칙 · 판 이력

- 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만)
- 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-05 16:15:10 +09:00
co-authored by Claude Opus 5.5
parent 9ca9dff3cb
commit 8ca2f1cd6e
14 changed files with 1857 additions and 344 deletions
@@ -130,11 +130,11 @@ export function buildSide(opt: SideOptions): SideHandle {
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 구조물 옵션 보이기 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 옵션 보이기(그룹)는 시스템 관리자만(PLAN 46)
// 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2)
const extras =
layer === "system"
? {
옵션: canEdit(layer, opt.isAdmin) ? section(options.title, options.body) : null,
옵션: section(options.title, options.body),
도면: section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
}
: null;
+18 -45
View File
@@ -1,56 +1,29 @@
/* =============================================================================
* M02_SO_Api.ts
* 구조물 옵션 보이기 · 그룹 서버 호출 — 서버 = `M02_SO_Router.py`
* (마스터 템플릿 `구조물옵션표시.json` · `구조물옵션그룹.json`)
* 구조물 정의 서버 호출 — 서버 = `M02_SO_Router.py`(마스터 템플릿 `구조물정의.json` 한 벌 · PLAN 48)
* ========================================================================== */
import { call } from "../M02_MasterTemplete_Api_Fetch";
import type { StructDef } from "./M02_SO_Def_Model";
export interface OptionKind {
type_id: string;
group: string;
name: string;
/** 거짓이면 B05 선택지에 안 나오는 종류 */
enabled: boolean;
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
/* --- 구조물 정의(PLAN 48 · 마스터 템플릿 `구조물정의.json` · 판 번호 · 이력) --- */
/** 지난 판 한 줄 */
export interface DefVersion {
판: number;
저장일: string;
}
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
export interface OptionsDoc {
판: string;
표시: Record<string, Record<string, false>>;
종류: OptionKind[];
}
/** 정의 한 벌 — 읽기 = 로그인 누구나 */
export const fetchStructureDef = (): Promise<StructDef> => call("/structure-definition");
export const fetchStructureOptions = (): Promise<OptionsDoc> =>
call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자
/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */
export const saveStructureDef = (판: number, 정의: StructDef): Promise<StructDef> =>
call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) });
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
/** 지난 판 목록(`구조물정의_이력/판_N.json`) */
export const fetchStructureDefHistory = (): Promise<DefVersion[]> =>
call("/structure-definition/history");
/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */
/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */
export interface OptionGroupDef {
id: string;
이름: string;
옵션: string[];
}
/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */
export interface GroupsDoc {
판: string;
그룹: Record<string, OptionGroupDef[]>;
종류: OptionKind[];
}
export const fetchStructureOptionGroups = (): Promise<GroupsDoc> =>
call("/structure-option-groups");
/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
export const saveStructureOptionGroups = (
판: string,
그룹: GroupsDoc["그룹"],
): Promise<GroupsDoc> =>
call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) });
export const fetchStructureDefAt = (판: number): Promise<StructDef> =>
call(`/structure-definition/history/${판}`);
@@ -0,0 +1,339 @@
/* =============================================================================
* M02_SO_Def.ts
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
import {
fetchStructureDef,
fetchStructureDefAt,
fetchStructureDefHistory,
saveStructureDef,
type DefVersion,
} from "./M02_SO_Api";
import { buildLayoutBlock } from "./M02_SO_Def_Layout";
import {
baseShown,
clone,
problems,
stepFamily,
stepType,
type StructDef,
} from "./M02_SO_Def_Model";
import { buildOptionsBlock } from "./M02_SO_Def_Options";
import { buildRulesBlock } from "./M02_SO_Def_Rules";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { buildGroupBlock } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
export interface DefScreenOptions {
/** 시스템 관리자 — 아니면 읽기만 */
isAdmin: boolean;
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
function tidy(def: StructDef): StructDef {
const out = clone(def);
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
for (const [k, v] of Object.entries(part))
if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k];
return out;
}
/** 작은 단추 */
function smallButton(
label: string,
run: () => void,
off = false,
variant: "ghost" | "danger" = "ghost",
): HTMLButtonElement {
const b = createButton({ label, variant, size: "sm" });
b.disabled = off;
b.addEventListener("click", run);
return b;
}
/** 화면을 받아 띄움 — 못 받으면 알림만 · 띄웠으면 참 */
export async function openDefScreen(opt: DefScreenOptions): Promise<boolean> {
let def: StructDef;
try {
def = await fetchStructureDef();
} catch (error) {
showToast(to("Load_Failed", why(error)), "error");
return false;
}
drawDef(def, opt);
return true;
}
function drawDef(first: StructDef, opt: DefScreenOptions): void {
/** 지금 판(서버) · 고칠 사본 · 보는 지난 판(있으면 읽기만) */
let base = first;
let work = clone(first);
let past: StructDef | null = null;
let dirty = false;
let current: string | null = null;
const open = new Set<string>(); // 선택지를 펼친 옵션
let versions: DefVersion[] | null = null;
const shown = (): StructDef => past ?? work;
const canEdit = (): boolean => opt.isAdmin && !past;
const kindList = el("div", { className: "m02-opts__kinds", attrs: { "data-sod-kinds": "" } });
const detail = el("div", { className: "m02-opts__list", attrs: { "data-sod-detail": "" } });
const banner = el("div", { className: "m02-sod__banner", attrs: { "data-sod-banner": "" } });
const issues = el("div", { className: "m02-sod__issues", attrs: { "data-sod-issues": "" } });
const historyBox = el("div", {
className: "m02-sod__history",
attrs: { "data-sod-history": "" },
});
const changed = (): void => {
dirty = true;
sync();
paint();
};
const paintKinds = (): void => {
const d = shown();
const edit = canEdit();
const parts: HTMLElement[] = [];
const families = Object.keys(d.구조물군);
families.forEach((g, gi) => {
parts.push(
el("div", {
className: "m02-sod__family",
attrs: { "data-sod-family": g },
children: [
textBox(d.구조물군[g], (v) => (d.구조물군[g] = v), changed, {
edit,
attr: "data-sod-family-name",
}),
...(edit
? [
smallButton(to("GroupUp"), () => stepFamily(d, g, -1) && changed(), gi === 0),
smallButton(
to("GroupDown"),
() => stepFamily(d, g, 1) && changed(),
gi === families.length - 1,
),
]
: []),
],
}),
);
const kinds = d.types.filter((t) => t.group === g);
kinds.forEach((t, ti) => {
const off = t.enabled === false;
const groups = d.그룹[t.type_id]?.length ?? 0;
const b = el("button", {
className: `m02-opts__kind${t.type_id === current ? " is-active" : ""}${off ? " is-off" : ""}`,
attrs: { type: "button", "data-sod-kind": t.type_id },
children: [
el("span", { text: t.name }),
...(groups ? [createTag(to("GroupTag", String(groups)), "accent")] : []),
...(off ? [createTag(to("Off"), "neutral")] : []),
],
});
b.addEventListener("click", () => {
current = t.type_id;
paint();
});
parts.push(
el("div", {
className: "m02-sod__kindrow",
children: [
b,
...(edit
? [
smallButton(
to("GroupUp"),
() => stepType(d, t.type_id, -1) && changed(),
ti === 0,
),
smallButton(
to("GroupDown"),
() => stepType(d, t.type_id, 1) && changed(),
ti === kinds.length - 1,
),
]
: []),
],
}),
);
});
});
kindList.replaceChildren(...parts);
};
const paintDetail = (): void => {
const d = shown();
const type = d.types.find((t) => t.type_id === current);
if (!type) {
detail.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const edit = canEdit();
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
detail.replaceChildren(
el("div", {
className: "m02-sod__kindhead",
children: [
textBox(type.name, (v) => (type.name = v), changed, {
edit,
attr: "data-sod-kind-name",
cls: "m02-sod__name",
}),
el("small", { className: "m02-opts__key", text: type.type_id }),
checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), {
edit,
attr: "data-sod-kind-off",
}),
checkBox(
to("BaseStationShow"),
baseShown(d, type.type_id),
(v) => {
if (v) delete extra().기준측점;
else extra().기준측점 = false;
changed();
},
{ edit, attr: "data-sod-base" },
),
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
const tag = createTag(a, "neutral");
tag.title = d.동작[a] ?? "";
return tag;
}),
],
}),
buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }),
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
buildRulesBlock({ def: d, type, edit, changed }),
);
};
const paintFoot = (): void => {
banner.replaceChildren(
...(past
? [
el("span", { text: to("PastView", String(past.판)) }),
smallButton(to("PastBack"), () => ((past = null), paint(), paintHistory())),
]
: [
el("span", { text: to("Version", String(base.판)) }),
...(opt.isAdmin ? [] : [createTag(to("ReadOnly"), "neutral")]),
]),
);
banner.classList.toggle("is-past", !!past);
const list = past ? [] : problems(work);
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
};
const paint = (): void => {
paintKinds();
paintDetail();
paintFoot();
};
const reload = async (): Promise<void> => {
try {
base = await fetchStructureDef();
work = clone(base);
dirty = false;
sync();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
};
const save = createButton({ label: to("Save"), variant: "filled" });
save.setAttribute("data-sod-save", "");
save.addEventListener("click", async () => {
if (problems(work).length) return;
save.disabled = true; // 연달아 눌러도 한 번만
try {
base = await saveStructureDef(base.판, tidy(work));
work = clone(base);
dirty = false;
versions = null;
sync();
showToast(to("SavedVersion", String(base.판)), "success");
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
await reload();
} else showToast(to("Failed", why(error)), "error");
} finally {
paint();
paintHistory();
}
});
/** 지난 판 목록 — 펼칠 때 받음 · 누르면 그 판을 읽기만으로 */
const paintHistory = (): void => {
if (!versions) return historyBox.replaceChildren();
historyBox.replaceChildren(
...(versions.length
? versions.map((v) => {
const b = createButton({
label: to("PastItem", `${v.판} · ${v.저장일}`),
variant: past?.판 === v.판 ? "filled" : "ghost",
size: "sm",
});
b.setAttribute("data-sod-past", String(v.판));
b.addEventListener("click", async () => {
try {
past = await fetchStructureDefAt(v.판);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
paint();
paintHistory();
});
return b;
})
: [el("p", { className: "m02-side__empty", text: to("PastNone") })]),
);
};
const history = createButton({ label: to("PastList"), variant: "ghost" });
history.setAttribute("data-sod-history-toggle", "");
history.addEventListener("click", async () => {
if (versions) {
versions = null;
return paintHistory();
}
try {
versions = (await fetchStructureDefHistory()).sort((a, b) => b.판 - a.판);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
paintHistory();
});
const node = el("div", {
className: "m02-opts m02-sod",
children: [
el("p", { className: "m02-side__empty", text: to("DefNote") }),
banner,
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
issues,
el("div", {
className: "m02-side__row",
children: [...(opt.isAdmin ? [save] : []), history],
}),
historyBox,
],
});
const setDirty = opt.show(to("Screen"), node);
const sync = (): void => setDirty(dirty);
paint();
}
@@ -0,0 +1,120 @@
/* =============================================================================
* M02_SO_Def_Layout.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **배치 격자**(B05 · B06 좌측 구조물 칸 = 두 칸)
* 옵션마다 줄 · 칸 · 너비 + 미리보기(기준 측점은 맨 위 한 줄 · 겹치면 빨강).
* 자리를 안 정한 옵션은 차례대로 빈 칸을 채움 · 숫자를 고치면 그 옵션 자리만 정의 `배치` 에 남음.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import {
baseShown,
GRID_COLS,
live,
overlaps,
placeAll,
type Spot,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { head, numberBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
export interface LayoutBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
changed: () => void;
}
const ROW_MAX = 60;
/** 미리보기 — 두 칸 격자 · 옵션마다 상자 */
function preview(def: StructDef, type: TypeDef, cells: Map<string, Spot>): HTMLElement {
const bad = new Set(overlaps(cells));
const base = baseShown(def, type.type_id);
const offset = base ? 1 : 0;
const boxes: HTMLElement[] = [];
if (base)
boxes.push(
el("div", {
className: "m02-sod__cell is-base",
text: to("BaseStation"),
attrs: { style: `grid-row: 1; grid-column: 1 / span ${GRID_COLS}` },
}),
);
for (const o of live(type.options)) {
const c = cells.get(o.key)!;
boxes.push(
el("div", {
className: `m02-sod__cell${bad.has(o.key) ? " is-bad" : ""}`,
text: o.label,
attrs: {
"data-sod-cell": o.key,
title: o.label,
style: `grid-row: ${c.줄 + offset}; grid-column: ${c.칸} / span ${c.너비}`,
},
}),
);
}
return el("div", {
className: "m02-sod__grid",
attrs: {
"data-sod-preview": "",
style: `grid-template-columns: repeat(${GRID_COLS}, minmax(0, 1fr))`,
},
children: boxes,
});
}
export function buildLayoutBlock(opt: LayoutBlockOptions): HTMLElement {
const { def, type, edit, changed } = opt;
const cells = placeAll(type.options, def.배치[type.type_id]);
const rows = live(type.options).map((o) => {
const c = cells.get(o.key)!;
const fix = (patch: Partial<Spot>): void => {
(def.배치[type.type_id] ??= {})[o.key] = { ...c, ...patch };
changed();
};
return el("div", {
className: "m02-sod__place",
attrs: { "data-sod-place-row": o.key },
children: [
el("span", { className: "m02-sod__placename", text: o.label }),
el("small", { text: to("GridRow") }),
numberBox(c.줄, (n) => fix({ 줄: n }), {
edit,
min: 1,
max: ROW_MAX,
attr: "data-sod-row",
}),
el("small", { text: to("GridCol") }),
numberBox(c.칸, (n) => fix({ 칸: n }), {
edit,
min: 1,
max: GRID_COLS,
attr: "data-sod-col",
}),
el("small", { text: to("GridWidth") }),
numberBox(c.너비, (n) => fix({ 너비: n }), {
edit,
min: 1,
max: GRID_COLS,
attr: "data-sod-width",
}),
],
});
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("LayoutHead"), to("LayoutNote")),
el("div", {
className: "m02-sod__layout",
children: [
el("div", { className: "m02-sod__places", children: rows }),
preview(def, type, cells),
],
}),
],
});
}
@@ -0,0 +1,268 @@
/* =============================================================================
* M02_SO_Def_Model.ts
* 구조물 정의 편집 화면(PLAN 48-2)의 자료 꼴 · 순수 손질 함수 — 화면 그리기 없음.
* 정본 = 마스터 템플릿 `구조물정의.json`(48-1 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`).
* 키는 만들 때 자동 · 안 바뀜 · 이름 자유 · 지움 = 「안 씀」(`enabled: false`).
* 선택지는 글 그대로가 값 — 이름을 바꾸면 이 정의 안의 기본값 · 규칙도 같이 바꿈.
* ========================================================================== */
export type OptionForm = "select" | "number" | "text" | "bool";
export interface Spot {
줄: number;
칸: number;
너비: number;
}
export interface OptionDef {
key: string;
label: string;
input: OptionForm;
choices?: string[];
unit?: string | null;
default?: unknown;
enabled?: boolean;
phase?: "b05" | "detail";
[more: string]: unknown;
}
export interface TypeDef {
type_id: string;
group: string;
name: string;
options: OptionDef[];
enabled?: boolean;
[more: string]: unknown;
}
export type ShapeKey = "길이" | "높이" | "전" | "후";
export const SHAPE_KEYS: readonly ShapeKey[] = ["길이", "높이", "전", "후"];
export interface GroupDef {
id: string;
이름: string;
옵션: string[];
형상?: Partial<Record<ShapeKey, { 받기: boolean; 뜻: string }>>;
}
/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */
export interface RuleDef {
종류: string;
만약: { 옵션: string; 값들: string[] };
그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown };
}
export interface StructDef {
판: number;
구조물군: Record<string, string>;
동작: Record<string, string>;
types: TypeDef[];
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
표시: Record<string, Record<string, false>>;
그룹: Record<string, GroupDef[]>;
배치: Record<string, Record<string, Spot>>;
규칙: RuleDef[];
[more: string]: unknown;
}
export const NAME_MAX = 40;
/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */
export const GRID_COLS = 2;
/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */
export const clone = <T>(value: T): T => structuredClone(value);
/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */
export function newKey(prefix: string, taken: Iterable<string>): string {
const used = new Set(taken);
for (;;) {
const key = `${prefix}${Math.random().toString(36).slice(2, 8)}`;
if (key.length > prefix.length + 3 && !used.has(key)) return key;
}
}
/** 목록 안에서 한 칸 옮김 — 끝이면 그대로 · 옮겼으면 참 */
export function step<T>(list: T[], index: number, delta: number): boolean {
const at = index + delta;
if (index < 0 || at < 0 || at >= list.length) return false;
[list[index], list[at]] = [list[at], list[index]];
return true;
}
/** 쓰는 것만 — 「안 씀」(enabled 거짓) 뺌 */
export const live = <T extends { enabled?: boolean }>(list: readonly T[]): T[] =>
list.filter((x) => x.enabled !== false);
/** 기준 측점 칸 보이기 — 없으면 보임 */
export const baseShown = (def: StructDef, typeId: string): boolean =>
def.종류[typeId]?.기준측점 !== false;
/** 이름 검사 — 1~40자 · 같은 목록 안에서 안 겹침 · 틀린 이름들 */
export function badNames(names: readonly string[]): string[] {
const seen = new Set<string>();
const bad: string[] = [];
for (const raw of names) {
const name = raw.trim();
if (!name || name.length > NAME_MAX || seen.has(name)) bad.push(name || "(빈 이름)");
seen.add(name);
}
return bad;
}
/** 구조물군 순서 바꿈 — `{키: 이름}` 묶음의 키 차례 */
export function stepFamily(def: StructDef, key: string, delta: number): boolean {
const entries = Object.entries(def.구조물군);
if (
!step(
entries,
entries.findIndex(([k]) => k === key),
delta,
)
)
return false;
def.구조물군 = Object.fromEntries(entries);
return true;
}
/** 종류 순서 바꿈 — 같은 군 안 앞뒤 종류와 `types` 자리를 바꿈 */
export function stepType(def: StructDef, typeId: string, delta: number): boolean {
const t = def.types.find((x) => x.type_id === typeId);
if (!t) return false;
const same = def.types.filter((x) => x.group === t.group);
const other = same[same.indexOf(t) + delta];
if (!other) return false;
const i = def.types.indexOf(t);
const j = def.types.indexOf(other);
[def.types[i], def.types[j]] = [def.types[j], def.types[i]];
return true;
}
/** 선택지 이름 바꿈 — 그 옵션 기본값 · 이 종류 규칙의 값들 · 선택지 · 기본값도 같이 */
export function renameChoice(
def: StructDef,
typeId: string,
o: OptionDef,
from: string,
to: string,
): void {
if (from === to || !o.choices) return;
o.choices = o.choices.map((c) => (c === from ? to : c));
if (o.default === from) o.default = to;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.map((v) => (v === from ? to : v));
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.map((v) => (v === from ? to : v));
if (r.그러면.기본값 === from) r.그러면.기본값 = to;
}
}
}
/** 선택지 뺌 — 기본값 · 규칙에서도 뺌(규칙 값들이 비면 그 규칙은 검사에서 걸림) */
export function dropChoice(def: StructDef, typeId: string, o: OptionDef, value: string): void {
o.choices = (o.choices ?? []).filter((c) => c !== value);
if (o.default === value) o.default = null;
for (const r of def.규칙) {
if (r.종류 !== typeId) continue;
if (r.만약.옵션 === o.key) r.만약.값들 = r.만약.값들.filter((v) => v !== value);
if (r.그러면.옵션 === o.key) {
if (r.그러면.선택지) r.그러면.선택지 = r.그러면.선택지.filter((v) => v !== value);
if (r.그러면.기본값 === value) delete r.그러면.기본값;
}
}
}
/** 배치 격자 — 정한 자리 먼저 · 안 정한 옵션은 차례대로 빈 칸 채움 · 안 씀은 빠짐 */
export function placeAll(
options: readonly OptionDef[],
spots: Record<string, Spot> = {},
cols = GRID_COLS,
): Map<string, Spot> {
const out = new Map<string, Spot>();
const taken = new Set<string>();
const mark = (c: Spot): void => {
for (let i = 0; i < c.너비; i += 1) taken.add(`${c.줄}:${c.칸 + i}`);
};
const shown = live(options);
for (const o of shown) {
const s = spots[o.key];
if (!s) continue;
const width = Math.max(1, Math.min(cols, s.너비 || 1));
const cell = { 줄: s.줄, 칸: Math.min(s.칸, cols - width + 1), 너비: width };
out.set(o.key, cell);
mark(cell);
}
let row = 1;
let col = 1;
for (const o of shown) {
if (out.has(o.key)) continue;
while (taken.has(`${row}:${col}`)) {
col += 1;
if (col > cols) ((row += 1), (col = 1));
}
const cell = { 줄: row, 칸: col, 너비: 1 };
out.set(o.key, cell);
mark(cell);
}
return out;
}
/** 겹친 칸 — 두 옵션이 같은 줄 · 칸을 쓰면 그 키들 */
export function overlaps(cells: Map<string, Spot>): string[] {
const owner = new Map<string, string>();
const bad = new Set<string>();
for (const [key, c] of cells)
for (let i = 0; i < c.너비; i += 1) {
const at = `${c.줄}:${c.칸 + i}`;
const other = owner.get(at);
if (other) bad.add(other).add(key);
else owner.set(at, key);
}
return [...bad];
}
/** 규칙 한 줄이 틀렸나 — 서버 검사(`M02_SO_Definition._rule_errors`)와 같은 뜻 */
export function ruleBad(def: StructDef, r: RuleDef): boolean {
const t = def.types.find((x) => x.type_id === r.종류);
const src = t?.options.find((o) => o.key === r.만약.옵션);
const dst = t?.options.find((o) => o.key === r.그러면.옵션);
if (!src || !dst || src === dst || !r.만약.값들.length) return true;
if (src.input === "select" && !r.만약.값들.every((v) => src.choices?.includes(v))) return true;
const { 선택지, 보이기, 기본값 } = r.그러면;
if (선택지 === undefined && 보이기 === undefined && 기본값 === undefined) return true;
let allowed = dst.choices ?? [];
if (선택지 !== undefined) {
if (dst.input !== "select" || !선택지.every((v) => allowed.includes(v))) return true;
allowed = 선택지;
}
return 기본값 !== undefined && dst.input === "select" && !allowed.includes(String(기본값));
}
/** 저장 전 검사 — 화면에 띄울 까닭 글들(빈 목록 = 저장 됨) */
export function problems(def: StructDef): string[] {
const out: string[] = [];
const fam = badNames(Object.values(def.구조물군));
if (fam.length) out.push(`구조물군 이름: ${fam.join(", ")}`);
for (const g of Object.keys(def.구조물군)) {
const bad = badNames(def.types.filter((t) => t.group === g).map((t) => t.name));
if (bad.length) out.push(`${def.구조물군[g]} 종류 이름: ${bad.join(", ")}`);
}
for (const t of def.types) {
const opt = badNames(t.options.map((o) => o.label));
if (opt.length) out.push(`${t.name} 옵션 이름: ${opt.join(", ")}`);
for (const o of t.options) {
if (o.input !== "select") continue;
const ch = badNames(o.choices ?? []);
if (ch.length) out.push(`${t.name}.${o.label} 선택지: ${ch.join(", ")}`);
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
}
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
}
def.규칙.forEach((r, i) => {
if (ruleBad(def, r)) out.push(`규칙 ${i + 1} 이 틀림`);
});
return out;
}
@@ -0,0 +1,231 @@
/* =============================================================================
* M02_SO_Def_Options.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **옵션 목록**
* (이름 · 꼴 · 단위 · 기본값 · 보이기 · 들어갈 자리 · 안 씀 · 순서 · [옵션 더함]) +
* 고르기 꼴의 **선택지 목록**(이름 바꿈 · 빼기 · 순서 · [선택지 더함]).
* 옵션 키는 더할 때 자동 · 안 바뀜 · 선택지는 글이 곧 값(이름 바꿈 = 기본값 · 규칙도 같이) ·
* 저장은 화면 [저장](`M02_SO_Def.ts`).
* ========================================================================== */
import { createButton, createTag, el, showConfirmDialog } from "@ui/ui_template_elements";
import {
dropChoice,
newKey,
renameChoice,
type OptionDef,
type OptionForm,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { checkBox, head, moveButtons, pickBox, textBox } from "./M02_SO_Def_Ui";
import { buildPlaceSelect } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
export interface OptionsBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
/** 선택지를 펼친 옵션 키 — 다시 그려도 남음 */
open: Set<string>;
changed: () => void;
}
const FORMS: readonly OptionForm[] = ["select", "number", "text", "bool"];
const FORM_TEXT = {
select: "FormSelect",
number: "FormNumber",
text: "FormText",
bool: "FormBool",
} as const;
/** 기본값 칸 — 꼴에 맞춤(고르기 = 선택지 · 예/아니오 · 숫자 · 글) · 비우면 기본값 없음 */
function defaultBox(o: OptionDef, edit: boolean, changed: () => void): HTMLElement {
const attr = "data-sod-default";
const now = o.default == null ? "" : String(o.default);
if (o.input === "select" || o.input === "bool") {
const pairs: [string, string][] =
o.input === "select"
? (o.choices ?? []).map((c) => [c, c])
: [
["true", to("Yes")],
["false", to("No")],
];
return pickBox(
[["", to("NoDefault")], ...pairs],
now,
(v) => {
o.default = v === "" ? null : o.input === "bool" ? v === "true" : v;
changed();
},
{ edit, attr, cls: "m02-sod__default" },
);
}
return textBox(
now,
(v) => {
const n = Number(v);
o.default = v.trim() === "" ? null : o.input === "number" && Number.isFinite(n) ? n : v;
},
changed,
{ edit, attr, cls: "m02-sod__default", place: to("NoDefault") },
);
}
/** 선택지 목록 — 이름(떠날 때 바꿈) · 빼기 · 순서 · [선택지 더함] */
function choicesBlock(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
const { def, type, edit, changed } = opt;
const list = (o.choices ??= []);
const rows = list.map((c) => {
let next = c;
const del = createButton({ label: to("ChoiceDrop"), variant: "danger", size: "sm" });
del.addEventListener("click", async () => {
if (!(await showConfirmDialog(to("ChoiceDropAsk", c), to("ChoiceDrop")))) return;
dropChoice(def, type.type_id, o, c);
changed();
});
return el("div", {
className: "m02-sod__choice",
attrs: { "data-sod-choice": c },
children: [
textBox(
c,
(v) => (next = v.trim()),
() => {
if (next && next !== c && !list.includes(next))
renameChoice(def, type.type_id, o, c, next);
changed();
},
{ edit, attr: "data-sod-choice-name" },
),
...moveButtons(list, c, changed, edit),
...(edit ? [del] : []),
],
});
});
const add = createButton({ label: to("ChoiceAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-choice-add", "");
add.addEventListener("click", () => {
let n = list.length + 1;
while (list.includes(to("ChoiceNew", String(n)))) n += 1;
list.push(to("ChoiceNew", String(n)));
changed();
});
return el("div", {
className: "m02-sod__choices",
children: [
el("p", { className: "m02-side__empty", text: to("ChoiceNote") }),
...(rows.length ? rows : [el("p", { className: "m02-sod__bad", text: to("ChoiceNone") })]),
...(edit ? [add] : []),
],
});
}
/** 옵션 한 줄(+ 펼친 선택지) */
function optionRow(opt: OptionsBlockOptions, o: OptionDef): HTMLElement {
const { def, type, edit, open, changed } = opt;
const off = o.enabled === false;
const hidden = def.표시[type.type_id]?.[o.key] === false;
const form = pickBox(
FORMS.map((f) => [f, to(FORM_TEXT[f])] as const),
o.input,
(v) => {
o.input = v as OptionForm;
o.default = null; // 꼴이 바뀌면 옛 기본값은 뜻이 없음
if (o.input === "select") open.add(o.key);
changed();
},
{ edit, attr: "data-sod-form", cls: "m02-sod__form" },
);
const choices =
o.input === "select"
? createButton({
label: to("Choices", String(o.choices?.length ?? 0)),
variant: open.has(o.key) ? "filled" : "ghost",
size: "sm",
})
: null;
choices?.setAttribute("data-sod-choices", o.key);
choices?.addEventListener("click", () => {
if (open.has(o.key)) open.delete(o.key);
else open.add(o.key);
changed();
});
const show = checkBox(
to("Shown"),
!hidden,
(v) => {
const row = (def.표시[type.type_id] ??= {});
if (v) delete row[o.key];
else row[o.key] = false;
if (!Object.keys(row).length) delete def.표시[type.type_id];
changed();
},
{ edit, attr: "data-sod-shown" },
);
const row = el("div", {
className: `m02-sod__option${off ? " is-off" : ""}`,
attrs: { "data-sod-option": o.key },
children: [
textBox(o.label, (v) => (o.label = v), changed, {
edit,
attr: "data-sod-name",
cls: "m02-sod__name",
}),
el("small", { className: "m02-opts__key", text: o.key }),
...(o.phase === "detail" ? [createTag(to("Detail"), "accent")] : []),
form,
textBox(o.unit ?? "", (v) => (o.unit = v.trim() || null), changed, {
edit,
attr: "data-sod-unit",
cls: "m02-sod__unit",
place: to("Unit"),
max: 12,
}),
defaultBox(o, edit, changed),
show,
buildPlaceSelect((def.그룹[type.type_id] ??= []), o.key, edit, changed),
checkBox(to("Unused"), off, (v) => ((o.enabled = !v), changed()), {
edit,
attr: "data-sod-off",
}),
...moveButtons(type.options, o, changed, edit),
...(choices ? [choices] : []),
],
});
return o.input === "select" && open.has(o.key)
? el("div", { className: "m02-sod__optwrap", children: [row, choicesBlock(opt, o)] })
: row;
}
/** 옵션 목록 칸 — 제목 · 안내 · 옵션 줄 · [옵션 더함] */
export function buildOptionsBlock(opt: OptionsBlockOptions): HTMLElement {
const { type, edit, changed } = opt;
const add = createButton({ label: to("OptionAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-option-add", "");
add.addEventListener("click", () => {
const n = type.options.length + 1;
const o: OptionDef = {
key: newKey(
"opt_",
type.options.map((x) => x.key),
),
label: to("OptionNew", String(n)),
input: "select",
choices: [to("ChoiceNew", "1")],
default: null,
};
type.options.push(o);
opt.open.add(o.key);
changed();
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("OptionHead"), to("OptionNote")),
...(type.options.length
? type.options.map((o) => optionRow(opt, o))
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
@@ -0,0 +1,234 @@
/* =============================================================================
* M02_SO_Def_Rules.ts
* 구조물 정의 편집 화면(PLAN 48-2) — 고른 종류의 **규칙 표**(정의 `규칙` 중 그 종류 것만)
* 「만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값」 한 줄씩 · [규칙 더함] · 지우기.
* 「그대로」 칸은 정의에 안 남김 · 셋 다 그대로면 틀린 줄(빨강) · 적용은 B05 · B06(48-4).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import {
live,
ruleBad,
type OptionDef,
type RuleDef,
type StructDef,
type TypeDef,
} from "./M02_SO_Def_Model";
import { head, pickBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
export interface RulesBlockOptions {
def: StructDef;
type: TypeDef;
edit: boolean;
changed: () => void;
}
const bool = (): [string, string][] => [
["true", to("Yes")],
["false", to("No")],
];
/** 여럿 고르기 — 체크한 것만 차례대로 */
function multiPick(
all: readonly string[],
picked: readonly string[],
set: (v: string[]) => void,
edit: boolean,
attr: string,
): HTMLElement {
const on = new Set(picked);
return el("div", {
className: "m02-sod__rulechoices",
attrs: { [attr]: "" },
children: all.map((c) => {
const box = el("input", { attrs: { type: "checkbox", value: c } }) as HTMLInputElement;
box.checked = on.has(c);
box.disabled = !edit;
box.addEventListener("change", () => {
if (box.checked) on.add(c);
else on.delete(c);
set(all.filter((x) => on.has(x)));
});
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: c })] });
}),
});
}
/** 글 칸 — 쉼표로 여럿 */
function listText(
value: string[],
set: (v: string[]) => void,
edit: boolean,
attr: string,
): HTMLElement {
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__rulepick",
attrs: { type: "text", [attr]: "", placeholder: to("RuleValues") },
}) as HTMLInputElement;
box.value = value.join(", ");
box.disabled = !edit;
box.addEventListener("change", () =>
set(
box.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
),
);
return box;
}
/** 만약 값들 — 고르기 = 선택지 체크 · 예/아니오 = 하나 · 그 밖 = 쉼표 글 */
function whenValues(
src: OptionDef | undefined,
rule: RuleDef,
edit: boolean,
changed: () => void,
): HTMLElement {
const set = (v: string[]): void => ((rule.만약.값들 = v), changed());
if (src?.input === "select")
return multiPick(src.choices ?? [], rule.만약.값들, set, edit, "data-sod-rule-value");
if (src?.input === "bool")
return pickBox(bool(), rule.만약.값들[0] ?? "true", (v) => set([v]), {
edit,
attr: "data-sod-rule-value",
cls: "m02-sod__rulepick",
});
return listText(rule.만약.값들, set, edit, "data-sod-rule-value");
}
/** 그러면 기본값 — 「그대로」 + (좁힌) 선택지 · 예/아니오 · 글 */
function thenDefault(
dst: OptionDef | undefined,
rule: RuleDef,
edit: boolean,
changed: () => void,
): HTMLElement {
const then = rule.그러면;
const now = then.기본값 === undefined ? "" : String(then.기본값);
const set = (v: string): void => {
if (v === "") delete then.기본값;
else
then.기본값 =
dst?.input === "bool"
? v === "true"
: dst?.input === "number" && Number.isFinite(Number(v))
? Number(v)
: v;
changed();
};
const pairs: [string, string][] =
dst?.input === "select"
? (then.선택지 ?? dst.choices ?? []).map((c) => [c, c])
: dst?.input === "bool"
? bool()
: [];
if (pairs.length || (dst?.input !== "number" && dst?.input !== "text"))
return pickBox([["", to("KeepDefault")], ...pairs], now, set, {
edit,
attr: "data-sod-rule-default",
cls: "m02-sod__rulepick",
});
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__rulepick",
attrs: { type: "text", "data-sod-rule-default": "", placeholder: to("KeepDefault") },
}) as HTMLInputElement;
box.value = now;
box.disabled = !edit;
box.addEventListener("change", () => set(box.value.trim()));
return box;
}
function ruleRow(opt: RulesBlockOptions, rule: RuleDef, index: number): HTMLElement {
const { def, type, edit, changed } = opt;
const byKey = new Map(type.options.map((o) => [o.key, o]));
const names = live(type.options).map((o) => [o.key, o.label] as [string, string]);
const src = byKey.get(rule.만약.옵션);
const dst = byKey.get(rule.그러면.옵션);
const then = rule.그러면;
const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" });
del.addEventListener("click", () => (def.규칙.splice(def.규칙.indexOf(rule), 1), changed()));
const show = then.보이기 === undefined ? "" : then.보이기 ? "1" : "0";
return el("div", {
className: `m02-sod__rule${ruleBad(def, rule) ? " is-bad" : ""}`,
attrs: { "data-sod-rule": String(index) },
children: [
el("small", { text: to("RuleIf") }),
pickBox(names, rule.만약.옵션, (v) => ((rule.만약 = { 옵션: v, 값들: [] }), changed()), {
edit,
attr: "data-sod-rule-top",
cls: "m02-sod__rulepick",
}),
el("small", { text: "=" }),
whenValues(src, rule, edit, changed),
el("small", { text: to("RuleThen") }),
pickBox(names, then.옵션, (v) => ((rule.그러면 = { 옵션: v }), changed()), {
edit,
attr: "data-sod-rule-target",
cls: "m02-sod__rulepick",
}),
...(dst?.input === "select"
? [
el("small", { text: to("RuleChoices") }),
multiPick(
dst.choices ?? [],
then.선택지 ?? [],
(v) => {
if (v.length) then.선택지 = v;
else delete then.선택지;
changed();
},
edit,
"data-sod-rule-choice",
),
]
: []),
pickBox(
[
["", to("Keep")],
["1", to("Show")],
["0", to("Hide")],
],
show,
(v) => {
if (v === "") delete then.보이기;
else then.보이기 = v === "1";
changed();
},
{ edit, attr: "data-sod-rule-show", cls: "m02-sod__rulepick" },
),
thenDefault(dst, rule, edit, changed),
...(edit ? [del] : []),
],
});
}
export function buildRulesBlock(opt: RulesBlockOptions): HTMLElement {
const { def, type, edit, changed } = opt;
const mine = def.규칙.filter((r) => r.종류 === type.type_id);
const opts = live(type.options);
const add = createButton({ label: to("RuleAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sod-rule-add", "");
add.disabled = opts.length < 2;
add.addEventListener("click", () => {
const top = opts.find((o) => o.input === "select") ?? opts[0];
const target = opts.find((o) => o.key !== top.key)!;
def.규칙.push({
종류: type.type_id,
만약: { 옵션: top.key, 값들: top.choices?.length ? [top.choices[0]] : [] },
그러면: { 옵션: target.key, 보이기: false },
});
changed();
});
return el("div", {
className: "m02-sod__block",
children: [
...head(to("RuleHead"), to("RuleNote")),
...(mine.length
? mine.map((r) => ruleRow(opt, r, def.규칙.indexOf(r)))
: [el("p", { className: "m02-side__empty", text: to("RuleNone") })]),
...(edit ? [el("div", { className: "m02-side__row", children: [add] })] : []),
],
});
}
@@ -0,0 +1,121 @@
/* =============================================================================
* M02_SO_Def_Ui.ts
* 구조물 정의 편집 화면(PLAN 48-2)의 작은 입력 부품 — 글 · 숫자 · 체크 · 고르기 · 위아래 단추.
* 값은 받은 손잡이로 사본만 고침 · 글은 칸을 떠날 때(`change`) 다시 그림(초점 안 끊김).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { step } from "./M02_SO_Def_Model";
import { to } from "./M02_SO_Text";
/** 글 칸 — 글자마다 `set` · 떠날 때 `done` */
export function textBox(
value: string,
set: (v: string) => void,
done: () => void,
opt: { edit: boolean; attr?: string; place?: string; cls?: string; max?: number },
): HTMLInputElement {
const box = el("input", {
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
attrs: {
type: "text",
maxlength: String(opt.max ?? 40),
...(opt.attr ? { [opt.attr]: "" } : {}),
},
}) as HTMLInputElement;
box.value = value;
box.placeholder = opt.place ?? "";
box.disabled = !opt.edit;
box.addEventListener("input", () => {
set(box.value);
box.classList.toggle("is-invalid", !box.value.trim() && !opt.place);
});
box.addEventListener("change", done);
return box;
}
/** 숫자 칸 — 범위 밖 · 빈 칸은 무름 */
export function numberBox(
value: number,
set: (v: number) => void,
opt: { edit: boolean; min: number; max: number; attr?: string },
): HTMLInputElement {
const box = el("input", {
className: "ui-input ui-input--sm m02-sod__num",
attrs: {
type: "number",
min: String(opt.min),
max: String(opt.max),
...(opt.attr ? { [opt.attr]: "" } : {}),
},
}) as HTMLInputElement;
box.value = String(value);
box.disabled = !opt.edit;
box.addEventListener("change", () => {
const n = Math.round(Number(box.value));
if (box.value === "" || !Number.isFinite(n) || n < opt.min || n > opt.max) {
box.value = String(value);
return;
}
set(n);
});
return box;
}
/** 체크 + 글 */
export function checkBox(
label: string,
on: boolean,
set: (v: boolean) => void,
opt: { edit: boolean; attr?: string },
): HTMLLabelElement {
const box = el("input", {
attrs: { type: "checkbox", ...(opt.attr ? { [opt.attr]: "" } : {}) },
}) as HTMLInputElement;
box.checked = on;
box.disabled = !opt.edit;
box.addEventListener("change", () => set(box.checked));
return el("label", { className: "m02-sod__check", children: [box, el("span", { text: label })] });
}
/** 고르기 — [값, 글] 짝 */
export function pickBox(
pairs: readonly (readonly [string, string])[],
value: string,
set: (v: string) => void,
opt: { edit: boolean; attr?: string; cls?: string },
): HTMLSelectElement {
const box = el("select", {
className: `ui-input ui-input--sm ${opt.cls ?? ""}`,
attrs: opt.attr ? { [opt.attr]: "" } : {},
}) as HTMLSelectElement;
box.append(...pairs.map(([v, t]) => new Option(t, v)));
box.value = value;
box.disabled = !opt.edit;
box.addEventListener("change", () => set(box.value));
return box;
}
/** 위로 · 아래로 단추 둘 — 고치는 사람만 · 옮겼으면 `changed` */
export function moveButtons<T>(
list: T[],
item: T,
changed: () => void,
edit: boolean,
): HTMLElement[] {
if (!edit) return [];
const index = list.indexOf(item);
const make = (label: string, delta: number, off: boolean): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: "sm" });
b.disabled = off;
b.addEventListener("click", () => step(list, list.indexOf(item), delta) && changed());
return b;
};
return [make(to("GroupUp"), -1, index <= 0), make(to("GroupDown"), 1, index >= list.length - 1)];
}
/** 절 제목 + 안내 한 줄 */
export const head = (title: string, note?: string): HTMLElement[] => [
el("h4", { className: "m02-opts__group", text: title }),
...(note ? [el("p", { className: "m02-side__empty", text: note })] : []),
];
@@ -1,19 +1,21 @@
/* =============================================================================
* M02_SO_Groups.ts
* 「구조물 옵션 보이기」 화면의 **옵션 그룹** 부품(PLAN 46-2) — 고른 종류의 그룹 목록
* (이름 · 위로 · 아래로 · 지우기 · [그룹 추가]) · 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
* 값은 화면이 쥔 고칠 사본(종류 → 그룹[])에만 — 저장은 화면 [저장](`M02_SO_Options.ts`).
* 구조물 정의 화면의 **옵션 그룹** 부품(PLAN 46-2 · 48-2) — 고른 종류의 그룹 목록
* (이름 · 위로 · 아래로 · 지우기 · 형상 칸 길이 · 높이 · 전 · 후 받기 켬끔과 뜻 · [그룹 추가]) ·
* 옵션 줄마다 「들어갈 자리」 고르기(본체 · 그룹).
* 값은 화면이 쥔 고칠 사본에만 — 저장은 화면 [저장](`M02_SO_Def.ts`).
* 그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나 · 옵션이 없어도 됨(서버 `M02_SO_Groups.py`).
* ========================================================================== */
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { OptionGroupDef } from "./M02_SO_Api";
import { SHAPE_KEYS, type GroupDef } from "./M02_SO_Def_Model";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
const NAME_MAX = 40;
/** 새 그룹 id — 서버 규칙 `g[0-9a-z]{1,16}` · 같은 종류 안에서 안 겹치게 */
export function newGroupId(list: readonly OptionGroupDef[]): string {
export function newGroupId(list: readonly GroupDef[]): string {
const taken = new Set(list.map((g) => g.id));
for (;;) {
const id = `g${Math.random().toString(36).slice(2, 8)}`;
@@ -22,35 +24,49 @@ export function newGroupId(list: readonly OptionGroupDef[]): string {
}
/** 그 옵션이 든 그룹 id — 없으면 null(본체) */
export function groupOf(list: readonly OptionGroupDef[], key: string): string | null {
export function groupOf(list: readonly GroupDef[], key: string): string | null {
return list.find((g) => g.옵션.includes(key))?.id ?? null;
}
/** 옵션을 그룹(null = 본체)으로 옮김 — 한 옵션은 한 그룹에만 */
export function moveOption(list: OptionGroupDef[], key: string, gid: string | null): void {
export function moveOption(list: GroupDef[], key: string, gid: string | null): void {
for (const g of list) g.옵션 = g.옵션.filter((k) => k !== key);
const target = gid ? list.find((g) => g.id === gid) : null;
if (target) target.옵션.push(key);
}
/** 이름이 다 괜찮은가 — 1~40자 · 같은 종류 안에서 안 겹침(서버도 같은 검사) */
export function namesOk(groups: Map<string, OptionGroupDef[]>): boolean {
for (const list of groups.values()) {
const names = list.map((g) => g.이름.trim());
if (names.some((n) => !n || n.length > NAME_MAX)) return false;
if (new Set(names).size !== names.length) return false;
}
return true;
}
export interface GroupBlockOptions {
/** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */
list: OptionGroupDef[];
list: GroupDef[];
edit: boolean;
/** 고쳤음 — 화면이 고침 표시 · 다시 그리기 */
changed: () => void;
}
/** 그룹 형상 줄 — 칸마다 받기 체크 + 뜻(무엇을 정하는지 한 줄) */
function shapeLine(group: GroupDef, edit: boolean, changed: () => void): HTMLElement {
const shape = (group.형상 ??= {});
return el("div", {
className: "m02-sog__shape",
attrs: { "data-sog-shape": group.id },
children: SHAPE_KEYS.map((k) => {
const f = shape[k] ?? { 받기: false, 뜻: "" };
const on = checkBox(
to(`Shape_${k}` as const),
f.받기,
(v) => ((shape[k] = { ...f, 받기: v }), changed()),
{ edit, attr: `data-sog-shape-${SHAPE_KEYS.indexOf(k)}` },
);
const mean = textBox(f.뜻, (v) => (shape[k] = { ...(shape[k] ?? f), 뜻: v }), changed, {
edit: edit && f.받기,
cls: "m02-sog__mean",
place: to("ShapeMean"),
});
return el("div", { className: "m02-sog__shapecell", children: [on, mean] });
}),
});
}
/** 그룹 목록 칸 — 제목 · 안내 · 그룹 줄 · [그룹 추가] */
export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
const { list } = opt;
@@ -87,7 +103,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
list.splice(list.indexOf(group), 1);
opt.changed();
});
return el("div", {
const line = el("div", {
className: "m02-sog__row",
attrs: { "data-sog-group": group.id },
children: [
@@ -99,6 +115,10 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
...(opt.edit ? [up, down, del] : []),
],
});
return el("div", {
className: "m02-sog__item",
children: [line, shapeLine(group, opt.edit, opt.changed)],
});
});
const add = createButton({ label: to("GroupAdd"), variant: "ghost", size: "sm" });
add.setAttribute("data-sog-add", "");
@@ -121,7 +141,7 @@ export function buildGroupBlock(opt: GroupBlockOptions): HTMLElement {
/** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */
export function buildPlaceSelect(
list: OptionGroupDef[],
list: GroupDef[],
key: string,
edit: boolean,
changed: () => void,
@@ -1,25 +1,12 @@
/* =============================================================================
* M02_SO_Options.ts
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도 · 시스템 관리자만) — 누르면 메인 칸에
* 종류 목록(군별) → 고른 종류의 **그룹**(PLAN 46-2 · `M02_SO_Groups.ts`) + 옵션 줄
* (보이기 체크 · 라벨 · 키 작은 글 · B06 전용 · 필수 표시 · 들어갈 자리) · [저장] · [모두 보임].
* 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
* 서버 = `M02_SO_Options.py`(`구조물옵션표시.json` · 숨긴 것만) · `M02_SO_Groups.py`(`구조물옵션그룹.json`).
* 좌측 「구조물 옵션 정의」 줄(구조물 집계 양식 아래 별도 · 시스템 층) — 누르면 메인 칸에
* 정의 편집 화면(PLAN 48-2 · `M02_SO_Def.ts`) · 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만.
* 옛 「보이기 · 그룹」 화면(46장)은 정의 화면의 옵션 줄 「보이기」 · 그룹 칸으로 들어감.
* ========================================================================== */
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
import { StaleError } from "../M02_MasterTemplete_Api_Fetch";
import {
fetchStructureOptionGroups,
fetchStructureOptions,
saveStructureOptionGroups,
saveStructureOptions,
type GroupsDoc,
type OptionGroupDef,
type OptionKind,
type OptionsDoc,
} from "./M02_SO_Api";
import { buildGroupBlock, buildPlaceSelect, namesOk } from "./M02_SO_Groups";
import { el } from "@ui/ui_template_elements";
import { openDefScreen } from "./M02_SO_Def";
import "./M02_SO_Style.css";
import { to } from "./M02_SO_Text";
@@ -40,8 +27,6 @@ export interface OptionsSection {
clear: () => void;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
const row = el("button", {
className: "m02-side__item ui-sidebar-row",
@@ -49,7 +34,6 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
attrs: { type: "button", "data-options-row": "" },
});
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
row.addEventListener("click", () => void open());
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
let opened = false;
@@ -57,241 +41,12 @@ export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection
opened = on;
row.classList.toggle("is-active", on);
};
async function open(): Promise<void> {
row.addEventListener("click", async () => {
if (opened || !(await opt.confirmLeave())) return;
let doc: OptionsDoc;
let groups: GroupsDoc;
try {
[doc, groups] = await Promise.all([fetchStructureOptions(), fetchStructureOptionGroups()]);
} catch (error) {
return showToast(to("Load_Failed", why(error)), "error");
}
draw(doc, groups);
}
/** 메인 칸 화면 — `doc.표시` · `groups.그룹` 을 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
function draw(doc: OptionsDoc, groupsDoc: GroupsDoc): void {
let version = doc.판;
let groupVersion = groupsDoc.판;
const edit = opt.isAdmin;
/** 숨긴 옵션 — 종류 → 키 집합 */
const hidden = new Map<string, Set<string>>();
const load = (src: OptionsDoc["표시"]): void => {
hidden.clear();
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
};
/** 그룹 — 종류 → 그룹[](고칠 사본) */
const groups = new Map<string, OptionGroupDef[]>();
const loadGroups = (src: GroupsDoc["그룹"]): void => {
groups.clear();
for (const [id, list] of Object.entries(src))
groups.set(
id,
list.map((g) => ({ ...g, 옵션: [...g.옵션] })),
);
};
load(doc.표시);
loadGroups(groupsDoc.그룹);
/** 종류 = 레지스트리 전부(옵션 없는 종류도 그룹을 둘 수 있음) */
let kinds = groupsDoc.종류;
let current: OptionKind | null = null;
let shownDirty = false;
let groupDirty = false;
const kindList = el("div", { className: "m02-opts__kinds" });
const optionList = el("div", { className: "m02-opts__list" });
const off = (id: string): number => hidden.get(id)?.size ?? 0;
const groupList = (id: string): OptionGroupDef[] => {
if (!groups.has(id)) groups.set(id, []);
return groups.get(id)!;
};
const paintKinds = (): void => {
const parts: HTMLElement[] = [];
let group = "";
for (const k of kinds) {
if (k.group !== group) {
group = k.group;
parts.push(
el("h4", {
className: "m02-opts__group",
text: group.length === 1 ? to("Group", group) : group,
}),
);
}
const n = off(k.type_id);
const g = groups.get(k.type_id)?.length ?? 0;
const b = el("button", {
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
attrs: { type: "button", "data-options-kind": k.type_id },
children: [
el("span", { text: k.name }),
...(g ? [createTag(to("GroupTag", String(g)), "accent")] : []),
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
],
});
b.addEventListener("click", () => {
current = k;
paintKinds();
paintOptions();
});
parts.push(b);
}
kindList.replaceChildren(...parts);
};
/** 그룹을 고쳤음 — 고침 표시 · 종류 목록(그룹 수) · 오른쪽 다시 그림 */
const groupsChanged = (): void => {
groupDirty = true;
sync();
paintKinds();
paintOptions();
};
const paintOptions = (): void => {
if (!current) {
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
return;
}
const kind = current;
const list = groupList(kind.type_id);
const rows = kind.options.map((o) => {
const box = el("input", {
attrs: { type: "checkbox", "data-options-key": o.key },
}) as HTMLInputElement;
box.checked = !hidden.get(kind.type_id)?.has(o.key);
box.disabled = !edit;
box.addEventListener("change", () => {
const set = hidden.get(kind.type_id) ?? new Set<string>();
if (box.checked) set.delete(o.key);
else set.add(o.key);
hidden.set(kind.type_id, set);
shownDirty = true;
sync();
paintKinds();
});
const tags: HTMLElement[] = [];
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
if (o.required && o.phase === "b05") {
const tag = createTag(to("Required"), "danger");
tag.title = to("RequiredTip");
tags.push(tag);
}
return el("div", {
className: "m02-opts__option",
children: [
el("label", {
className: "m02-opts__check",
children: [
box,
el("span", { text: o.label }),
el("small", { className: "m02-opts__key", text: o.key }),
...tags,
],
}),
buildPlaceSelect(list, o.key, edit, groupsChanged),
],
});
});
optionList.replaceChildren(
el("h3", { className: "m02-options__head", text: kind.name }),
buildGroupBlock({ list, edit, changed: groupsChanged }),
el("h4", { className: "m02-opts__group", text: to("OptionHead") }),
...(rows.length
? rows
: [el("p", { className: "m02-side__empty", text: to("NoOptionKind") })]),
);
};
const save = createButton({ label: to("Save"), variant: "filled" });
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
all.addEventListener("click", () => {
if (![...hidden.values()].some((s) => s.size)) return;
hidden.clear();
shownDirty = true;
sync();
paintKinds();
paintOptions();
});
/** 다시 받기 — 낡은 판으로 막혔을 때 둘 다 새로 */
const reload = async (): Promise<void> => {
try {
const [fresh, freshGroups] = await Promise.all([
fetchStructureOptions(),
fetchStructureOptionGroups(),
]);
version = fresh.판;
groupVersion = freshGroups.판;
kinds = freshGroups.종류;
load(fresh.표시);
loadGroups(freshGroups.그룹);
shownDirty = groupDirty = false;
sync();
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
paintKinds();
paintOptions();
} catch (again) {
showToast(to("Load_Failed", why(again)), "error");
}
};
save.addEventListener("click", async () => {
if (groupDirty && !namesOk(groups)) return showToast(to("NameBad"), "error");
save.disabled = true; // 연달아 눌러도 한 번만
try {
if (shownDirty) {
const 표시: OptionsDoc["표시"] = {};
for (const [id, keys] of hidden)
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
const done = await saveStructureOptions(version, 표시);
version = done.판;
load(done.표시);
shownDirty = false;
}
if (groupDirty) {
const 그룹: GroupsDoc["그룹"] = {};
for (const [id, list] of groups)
if (list.length) 그룹[id] = list.map((g) => ({ ...g, 이름: g.이름.trim() }));
const done = await saveStructureOptionGroups(groupVersion, 그룹);
groupVersion = done.판;
loadGroups(done.그룹);
groupDirty = false;
}
sync();
showToast(to("Saved"), "success");
paintKinds();
paintOptions();
} catch (error) {
if (error instanceof StaleError) {
showToast(to("Stale"), "error");
await reload();
} else showToast(to("Failed", why(error)), "error");
} finally {
save.disabled = false;
}
});
const node = el("div", {
className: "m02-opts",
children: [
el("p", { className: "m02-side__empty", text: to("Note") }),
el("div", {
className: "m02-opts__panes",
children: [kindList, optionList],
}),
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
],
});
const setDirty = opt.show(to("Screen"), node);
const sync = (): void => setDirty(shownDirty || groupDirty);
paintKinds();
paintOptions();
if (!(await openDefScreen({ isAdmin: opt.isAdmin, show: opt.show }))) return;
opt.onOpen();
mark(true);
}
});
return { title: to("Title"), body, clear: () => mark(false) };
}
@@ -112,3 +112,200 @@
color: var(--color-text-muted);
font-size: 11px;
}
/* 구조물 정의 편집 화면(PLAN 48-2) — 판 띠 · 종류 머리 · 옵션 · 선택지 · 배치 격자 · 규칙 · 지난 판 */
.m02-sod {
max-width: 80rem;
}
.m02-sod__banner {
display: flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-muted);
}
.m02-sod__banner.is-past {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-mist-violet);
color: var(--color-text);
}
.m02-sod__family,
.m02-sod__kindrow,
.m02-sod__kindhead,
.m02-sod__option,
.m02-sod__choice,
.m02-sod__place,
.m02-sod__rule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-sod__family {
margin-top: var(--spacing-8);
}
.m02-sod__family > .ui-input {
flex: 1 1 8rem;
min-width: 0;
font-weight: 600;
}
.m02-sod__kindrow > .m02-opts__kind {
flex: 1 1 auto;
}
.m02-sod__block {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-top: var(--spacing-8);
border-top: 1px solid var(--color-border);
}
.m02-sod__option {
padding: var(--spacing-4) 0;
border-bottom: 1px solid var(--color-border);
}
.is-off > .ui-input,
.m02-opts__kind.is-off > span:first-child {
color: var(--color-text-muted);
text-decoration: line-through;
}
.m02-sod__name {
flex: 1 1 10rem;
min-width: 0;
}
.m02-sod__form,
.m02-sod__default {
width: 8rem;
}
.m02-sod__unit,
.m02-sod__num {
width: 4.5rem;
}
.m02-sod__check {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
white-space: nowrap;
cursor: pointer;
}
.m02-sod__choices {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0 0 var(--spacing-8) var(--spacing-24);
padding-left: var(--spacing-8);
border-left: 2px solid var(--color-border);
}
/* 배치 격자 — 왼쪽 칸 숫자 · 오른쪽 미리보기 */
.m02-sod__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
gap: var(--spacing-16);
align-items: start;
}
.m02-sod__placename {
flex: 1 1 8rem;
min-width: 0;
}
.m02-sod__grid {
display: grid;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
}
.m02-sod__cell {
overflow: hidden;
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
font-size: 11px;
white-space: nowrap;
text-overflow: ellipsis;
}
.m02-sod__cell.is-base {
background: var(--color-mist-violet);
}
.m02-sod__cell.is-bad,
.m02-sod__rule.is-bad {
border-color: var(--color-danger);
}
.m02-sod__rule {
padding: var(--spacing-4);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.m02-sod__rulepick {
width: 9rem;
}
.m02-sod__rulechoices {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
/* 그룹 형상 칸 — 길이 · 높이 · 전 · 후 받기 + 뜻 */
.m02-sog__item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.m02-sog__shape {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: var(--spacing-4) var(--spacing-12);
}
.m02-sog__shapecell {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m02-sog__mean {
flex: 1 1 auto;
min-width: 0;
}
.m02-sod__issues:empty,
.m02-sod__history:empty {
display: none;
}
.m02-sod__history {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
.m02-sod__bad {
margin: 0;
color: var(--color-danger);
}
+80 -24
View File
@@ -6,13 +6,12 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["구조물 옵션 보이기", "Structure options shown"],
Row: ["종류마다 그룹 · 보일 옵션 정하기", "Groups and shown options per type"],
Screen: ["구조물 옵션 보이기 · 그룹", "Structure options · groups"],
Note: [
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
Title: ["구조물 옵션 정의", "Structure option definition"],
Row: [
"구조물군 · 종류 · 옵션 · 배치 · 그룹 · 규칙",
"Families · types · options · layout · groups · rules",
],
Screen: ["구조물 옵션 정의", "Structure option definition"],
GroupHead: ["그룹", "Groups"],
GroupNote: [
"그룹 하나 = 프로젝트 B05 · B06 폼의 보조 컨테이너 하나(제목 = 그룹 이름) · 그 안에서 구조물 조합 항목을 따로 지정 · 옵션이 없어도 연결됨 · 그룹에 안 넣은 옵션은 본체",
@@ -35,35 +34,92 @@ const TEXT = {
],
GroupTag: ["그룹 {value}", "{value} groups"],
Main: ["본체", "Main body"],
OptionHead: ["옵션 — 보이기 · 들어갈 자리", "Options — shown · container"],
OptionHead: ["옵션", "Options"],
OptionNote: [
"이름 · 꼴 · 단위 · 기본값 · 들어갈 자리(본체 · 그룹) · 안 씀 · 순서 — 키(작은 글)는 안 바뀜 · 고르기 꼴은 [선택지]로 목록을 고침",
"Name · form · unit · default · container · unused · order — keys never change",
],
OptionAdd: ["옵션 더함", "Add option"],
OptionNew: ["새 옵션 {value}", "New option {value}"],
FormSelect: ["고르기", "Select"],
FormNumber: ["숫자", "Number"],
FormText: ["글", "Text"],
FormBool: ["예/아니오", "Yes/No"],
Yes: ["예", "Yes"],
No: ["아니오", "No"],
Unit: ["단위", "Unit"],
NoDefault: ["기본값 없음", "No default"],
Unused: ["안 씀", "Unused"],
Choices: ["선택지 {value}", "{value} choices"],
ChoiceAdd: ["선택지 더함", "Add choice"],
ChoiceNew: ["새 선택지 {value}", "New choice {value}"],
ChoiceNone: ["선택지 없음 — 고르기 꼴은 하나 이상", "No choices — select needs at least one"],
ChoiceNote: [
"선택지 글이 곧 저장 값 — 이름을 바꾸면 기본값 · 규칙도 같이 바뀜 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
"Choice text is the stored value — renaming updates defaults and rules here",
],
ChoiceDrop: ["빼기", "Remove"],
ChoiceDropAsk: [
"선택지 「{value}」 를 뺌 — 기본값 · 규칙에서도 빠짐 · 이미 저장한 프로젝트 값은 「지난 정의 값」으로 남음",
"Remove choice “{value}” — also from defaults and rules",
],
Shown: ["보이기", "Shown"],
RuleValues: ["값(쉼표로 여럿)", "Values (comma separated)"],
RuleChoices: ["선택지", "Choices"],
LayoutHead: ["배치 격자", "Layout grid"],
LayoutNote: [
"B05 · B06 좌측 구조물 칸(두 칸)의 배치 — 옵션마다 줄 · 칸 · 너비 · 기준 측점은 맨 위 한 줄 · 겹치면 빨강 · 안 정한 옵션은 차례대로",
"Left panel layout (two columns) — row · column · width per option · overlaps in red",
],
GridRow: ["줄", "Row"],
GridCol: ["칸", "Col"],
GridWidth: ["너비", "Width"],
BaseStation: ["기준 측점", "Base station"],
BaseStationShow: ["기준 측점 표시", "Show base station"],
Shape_길이: ["길이", "Length"],
Shape_높이: ["높이", "Height"],
Shape_전: ["기준 측점 전", "Before"],
Shape_후: ["기준 측점 후", "After"],
ShapeMean: ["무엇을 정하는지", "What it controls"],
RuleHead: ["규칙", "Rules"],
RuleNote: [
"만약 상위 옵션 = 값 → 하위 옵션의 선택지 · 보이기 · 기본값 — 「그대로」 칸은 손대지 않음 · 틀린 줄은 빨강",
"If parent = value → child choices · visibility · default",
],
RuleNone: ["규칙 없음", "No rules"],
RuleAdd: ["규칙 더함", "Add rule"],
RuleIf: ["만약", "If"],
RuleThen: ["→", "→"],
Keep: ["보이기 그대로", "Keep visibility"],
Show: ["보임", "Show"],
Hide: ["숨김", "Hide"],
KeepDefault: ["기본값 그대로", "Keep default"],
DefNote: [
"구조물군 · 종류 · 옵션 · 선택지 · 배치 · 그룹 · 규칙 정의 — [저장]하면 판이 하나 오르고 B05 · B06 에 바로 적용 · 지난 판은 이력으로 남음",
"Structure definition — Save bumps the version; past versions stay in history",
],
Version: ["판 {value}", "Version {value}"],
ReadOnly: ["읽기만", "Read only"],
SavedVersion: ["저장함 — 판 {value}", "Saved — version {value}"],
PastList: ["지난 판", "Past versions"],
PastItem: ["판 {value}", "v{value}"],
PastNone: ["지난 판 없음", "No past versions"],
PastView: ["판 {value} 보는 중 — 읽기만", "Viewing version {value} — read only"],
PastBack: ["지금 판으로", "Back to current"],
NoOptionKind: [
"이 종류는 옵션이 없음 — 그룹만 만들 수 있음(그룹마다 조합 연결)",
"No options — groups only (each links a combo)",
],
NameBad: [
"그룹 이름은 1~40자 · 같은 종류 안에서 겹치면 안 됨",
"Group names: 1–40 chars, unique",
],
Group: ["{value}군", "Group {value}"],
Hidden: ["숨김 {value}", "{value} hidden"],
Off: ["안 쓰는 종류", "Not offered"],
NoOption: ["옵션 없음", "No options"],
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Required: ["필수", "Required"],
RequiredTip: [
"필수 옵션 — 숨기면 폼에 안 나오고 필수로도 안 침(저장이 안 막힘)",
"Required — when hidden it is not shown and not enforced",
],
Save: ["저장", "Save"],
ShowAll: ["모두 보임", "Show all"],
Saved: ["저장함", "Saved"],
Stale: [
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",
],
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
Load_Failed: ["구조물 정의를 못 받음 — {value}", "Could not load the definition — {value}"],
Failed: ["구조물 정의를 못 저장함 — {value}", "Could not save the definition — {value}"],
} as const satisfies Record<string, readonly [string, string]>;
export type OptionsTextKey = keyof typeof TEXT;