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
@@ -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] })] : []),
],
});
}