From 8ca2f1cd6ee2b5e180e6b28d1a68e14d337a50b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 16:15:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=2048-2=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EA=B5=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=98=B5=EC=85=98=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=C2=B7=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=C2=B7=20=EA=B7=B8=EB=A3=B9=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20=C2=B7=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=ED=8C=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 「옵션 보이기 · 그룹」 화면을 정의 편집 화면으로 넓힘(시스템 관리자 고쳐 씀 · 다른 역할 읽기만) - 자료 손질 = M02_SO_Def_Model.ts(정의 틀 48-1 꼴) · 시험 test_m02_so_def_model.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_UI_Side.ts | 4 +- .../StructOptions/M02_SO_Api.ts | 63 +--- .../StructOptions/M02_SO_Def.ts | 339 ++++++++++++++++++ .../StructOptions/M02_SO_Def_Layout.ts | 120 +++++++ .../StructOptions/M02_SO_Def_Model.ts | 268 ++++++++++++++ .../StructOptions/M02_SO_Def_Options.ts | 231 ++++++++++++ .../StructOptions/M02_SO_Def_Rules.ts | 234 ++++++++++++ .../StructOptions/M02_SO_Def_Ui.ts | 121 +++++++ .../StructOptions/M02_SO_Groups.ts | 60 ++-- .../StructOptions/M02_SO_Options.ts | 261 +------------- .../StructOptions/M02_SO_Style.css | 197 ++++++++++ .../StructOptions/M02_SO_Text.ts | 104 ++++-- resources/tester/helper_m02_so_def_model.cjs | 112 ++++++ resources/tester/test_m02_so_def_model.py | 87 +++++ 14 files changed, 1857 insertions(+), 344 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts create mode 100644 resources/tester/helper_m02_so_def_model.cjs create mode 100644 resources/tester/test_m02_so_def_model.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 5fc86b60a..12561432e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -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; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts index a8bafd1fa..a91a2578f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Api.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Api.ts @@ -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>; - 종류: OptionKind[]; -} +/** 정의 한 벌 — 읽기 = 로그인 누구나 */ +export const fetchStructureDef = (): Promise => call("/structure-definition"); -export const fetchStructureOptions = (): Promise => - call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 시스템 관리자 +/** 통째로 고쳐 씀(시스템 관리자만) — 읽어 간 판이 지금 판과 다르면 StaleError · 돌려받는 것 = 판 +1 정의 */ +export const saveStructureDef = (판: number, 정의: StructDef): Promise => + call("/structure-definition", { method: "PUT", body: JSON.stringify({ 판, 정의 }) }); -/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => - call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) }); +/** 지난 판 목록(`구조물정의_이력/판_N.json`) */ +export const fetchStructureDefHistory = (): Promise => + call("/structure-definition/history"); -/* --- 옵션 그룹(PLAN 46-2 · 마스터 템플릿 `구조물옵션그룹.json` · 시스템 관리자만) --- */ - -/** 그룹 하나 = 프로젝트 폼의 보조 컨테이너 하나(제목 = 이름) · 옵션 0 도 됨 */ -export interface OptionGroupDef { - id: string; - 이름: string; - 옵션: string[]; -} - -/** 판 · 그룹(`{종류: 그룹[]}`) · 종류(레지스트리 전부 — 옵션 없는 종류도) */ -export interface GroupsDoc { - 판: string; - 그룹: Record; - 종류: OptionKind[]; -} - -export const fetchStructureOptionGroups = (): Promise => - call("/structure-option-groups"); - -/** 고쳐 씀 — 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ -export const saveStructureOptionGroups = ( - 판: string, - 그룹: GroupsDoc["그룹"], -): Promise => - call("/structure-option-groups", { method: "PUT", body: JSON.stringify({ 판, 그룹 }) }); +export const fetchStructureDefAt = (판: number): Promise => + call(`/structure-definition/history/${판}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts new file mode 100644 index 000000000..26899fdc1 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -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[]) + 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 { + 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(); // 선택지를 펼친 옵션 + 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 => (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 => { + 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(); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts new file mode 100644 index 000000000..d6e4338e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Layout.ts @@ -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): 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): 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), + ], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts new file mode 100644 index 000000000..d45d9511b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -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>; +} + +/** 만약 상위 옵션 = 값들 중 하나 → 하위 옵션의 선택지 · 보이기 · 기본값(하나 이상) */ +export interface RuleDef { + 종류: string; + 만약: { 옵션: string; 값들: string[] }; + 그러면: { 옵션: string; 선택지?: string[]; 보이기?: boolean; 기본값?: unknown }; +} + +export interface StructDef { + 판: number; + 구조물군: Record; + 동작: Record; + types: TypeDef[]; + 종류: Record; + 표시: Record>; + 그룹: Record; + 배치: Record>; + 규칙: RuleDef[]; + [more: string]: unknown; +} + +export const NAME_MAX = 40; +/** B05 · B06 좌측 구조물 칸 = 두 칸 격자 */ +export const GRID_COLS = 2; + +/** 깊은 사본 — 고칠 사본을 서버 문서와 떼어 놓음 */ +export const clone = (value: T): T => structuredClone(value); + +/** 새 키 — 머리글 + 영문 소문자 · 숫자 6자 · 이미 있는 키와 안 겹침 */ +export function newKey(prefix: string, taken: Iterable): 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(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 = (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(); + 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 = {}, + cols = GRID_COLS, +): Map { + const out = new Map(); + const taken = new Set(); + 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[] { + const owner = new Map(); + const bad = new Set(); + 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; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts new file mode 100644 index 000000000..10254bba8 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Options.ts @@ -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; + 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] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts new file mode 100644 index 000000000..bca6186e0 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Rules.ts @@ -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] })] : []), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts new file mode 100644 index 000000000..837c344bd --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Ui.ts @@ -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( + 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 })] : []), +]; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts index 242dad8b4..410875cf4 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Groups.ts @@ -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): 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, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts index d340d13f6..7ede2af54 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Options.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Options.ts @@ -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 { + 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>(); - 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(); - 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(); - 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 => { - 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) }; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 2670d6cfe..80d3e4cec 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -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); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 9b4409b41..90037edd8 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -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; export type OptionsTextKey = keyof typeof TEXT; diff --git a/resources/tester/helper_m02_so_def_model.cjs b/resources/tester/helper_m02_so_def_model.cjs new file mode 100644 index 000000000..92969f79c --- /dev/null +++ b/resources/tester/helper_m02_so_def_model.cjs @@ -0,0 +1,112 @@ +/* M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const source = fs.readFileSync( + path.join(__dirname, "..", "..", "M02_MasterTemplete", "StructOptions", "M02_SO_Def_Model.ts"), + "utf8", +); +const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +const sel = (key, choices) => ({ key, label: key, input: "select", choices: [...choices] }); +const type = () => ({ + type_id: "k1", + group: "A", + name: "배수관", + options: [sel("a", ["x", "y"]), sel("b", ["p", "q"]), { key: "c", label: "c", input: "number" }], +}); +const defOf = (over = {}) => ({ + 판: 3, + 구조물군: { A: "A군", B: "B군" }, + 동작: {}, + types: [ + type(), + { ...type(), type_id: "k2", name: "둘째" }, + { ...type(), type_id: "k3", group: "B", name: "셋째" }, + ], + 종류: {}, + 표시: {}, + 그룹: {}, + 배치: {}, + 규칙: [], + ...over, +}); +const cells = (t, spots) => Object.fromEntries(m.placeAll(t.options, spots)); + +const out = {}; +// 자리 안 정한 옵션 = 차례대로 채움 · 두 칸이면 셋째는 둘째 줄 +out.auto = cells(type()); +// 정한 자리 먼저 · 남은 것은 빈 칸으로 · 너비는 칸 수를 안 넘음 +out.fixed = cells(type(), { c: { 줄: 1, 칸: 1, 너비: 5 } }); +// 안 씀은 격자에서 빠짐 +const off = type(); +off.options[0].enabled = false; +out.off = cells(off); +// 겹침 +out.overlap = m + .overlaps( + m.placeAll(type().options, { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } }), + ) + .sort(); +// 규칙 — 맞는 줄 · 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 고르기 아닌 상위 +const rd = defOf(); +const R = (만약, 그러면) => ({ 종류: "k1", 만약, 그러면 }); +out.rules = [ + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" }), + R({ 옵션: "a", 값들: ["zz"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "a", 보이기: false }), + R({ 옵션: "a", 값들: ["y"] }, { 옵션: "b", 선택지: ["nope"] }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b" }), + R({ 옵션: "c", 값들: ["3"] }, { 옵션: "b", 보이기: false }), + R({ 옵션: "a", 값들: ["x"] }, { 옵션: "b", 선택지: ["p"], 기본값: "q" }), +].map((r) => m.ruleBad(rd, r)); +// 이름 — 빈 이름 · 겹침 +out.names = m.badNames(["가", " ", "가"]); +// 저장 전 검사 — 깨끗한 정의는 빈 목록 · 겹침 · 이름 틀림 · 빈 선택지는 까닭 +out.clean = m.problems(defOf()); +const broken = defOf({ + 배치: { k1: { a: { 줄: 1, 칸: 1, 너비: 2 }, b: { 줄: 1, 칸: 2, 너비: 1 } } }, +}); +broken.types[0].options[1].label = "a"; +broken.types[1].options[0].choices = []; +out.broken = m.problems(broken); +// 선택지 이름 바꿈 · 뺌 — 기본값 · 규칙 같이 +const rn = defOf({ + 규칙: [R({ 옵션: "a", 값들: ["x", "y"] }, { 옵션: "b", 선택지: ["p"], 기본값: "p" })], +}); +const a = rn.types[0].options[0]; +const b = rn.types[0].options[1]; +a.default = "x"; +m.renameChoice(rn, "k1", a, "x", "엑스"); +m.renameChoice(rn, "k1", b, "p", "피"); +out.rename = [ + a.choices, + a.default, + rn.규칙[0].만약.값들, + rn.규칙[0].그러면.선택지, + rn.규칙[0].그러면.기본값, +]; +m.dropChoice(rn, "k1", b, "피"); +out.drop = [b.choices, rn.규칙[0].그러면.선택지, "기본값" in rn.규칙[0].그러면]; +// 순서 — 구조물군 키 차례 · 종류는 같은 군 안에서만 +const od = defOf(); +out.family = [m.stepFamily(od, "A", -1), m.stepFamily(od, "A", 1), Object.keys(od.구조물군)]; +out.type = [m.stepType(od, "k2", 1), m.stepType(od, "k1", 1), od.types.map((t) => t.type_id)]; +out.base = [ + m.baseShown(defOf(), "k1"), + m.baseShown(defOf({ 종류: { k1: { 기준측점: false } } }), "k1"), +]; +// 목록 순서 바꿈 · 끝은 그대로 +const list = [1, 2, 3]; +out.step = [m.step(list, 0, -1), m.step(list, 0, 1), list]; +// 새 키 — 머리글 · 안 겹침 · 영문 소문자 숫자 +const k = m.newKey("opt_", ["opt_aaaaaa"]); +out.newKey = [/^opt_[0-9a-z]{4,}$/.test(k), k !== "opt_aaaaaa"]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_so_def_model.py b/resources/tester/test_m02_so_def_model.py new file mode 100644 index 000000000..6670f0937 --- /dev/null +++ b/resources/tester/test_m02_so_def_model.py @@ -0,0 +1,87 @@ +"""M02 구조물 정의 편집 화면 자료 손질(`M02_SO_Def_Model.ts` · PLAN 48-2) 단위검증. + +프론트에 JS 테스트 러너가 없어 Node 헬퍼(`helper_m02_so_def_model.cjs`)가 TS 를 트랜스파일해 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배치 격자: 안 정한 옵션은 차례대로 · 정한 자리 먼저 · 너비 ≤ 칸 수 · 안 씀 빠짐 · 겹침 찾음. + - 규칙: 없는 값 · 자기 자신 · 없는 하위 선택지 · 그러면 비었음 · 좁힌 선택지 밖 기본값은 틀린 줄 + (서버 `M02_SO_Definition._rule_errors` 와 같은 뜻) · 고르기 아닌 상위는 값 검사 안 함. + - 저장 전 검사: 깨끗한 정의 = 빈 목록 · 이름 겹침 · 배치 겹침 · 빈 선택지 = 까닭 글. + - 선택지 이름 바꿈 · 뺌은 그 옵션 기본값 · 같은 종류 규칙도 같이 고침. + - 순서: 구조물군 = 키 차례 · 종류 = 같은 군 안에서만. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_model.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배치_자동_채움(r) -> None: + assert r["auto"] == { + "a": {"줄": 1, "칸": 1, "너비": 1}, + "b": {"줄": 1, "칸": 2, "너비": 1}, + "c": {"줄": 2, "칸": 1, "너비": 1}, + } + + +def test_배치_정한_자리_먼저_너비는_칸수까지(r) -> None: + assert r["fixed"]["c"] == {"줄": 1, "칸": 1, "너비": 2} + assert r["fixed"]["a"] == {"줄": 2, "칸": 1, "너비": 1} + assert r["fixed"]["b"] == {"줄": 2, "칸": 2, "너비": 1} + + +def test_안씀은_격자에서_빠짐(r) -> None: + assert set(r["off"]) == {"b", "c"} + assert r["off"]["b"] == {"줄": 1, "칸": 1, "너비": 1} + + +def test_겹침(r) -> None: + assert r["overlap"] == ["a", "b"] + + +def test_규칙_틀린_줄(r) -> None: + assert r["rules"] == [False, True, True, True, True, False, True] + + +def test_이름_검사(r) -> None: + assert r["names"] == ["(빈 이름)", "가"] + + +def test_저장_전_검사(r) -> None: + assert r["clean"] == [] + assert any("옵션 이름" in t for t in r["broken"]) + assert any("배치 겹침" in t for t in r["broken"]) + assert any("선택지가 있어야" in t for t in r["broken"]) + + +def test_선택지_이름_바꿈_뺌은_기본값_규칙도_같이(r) -> None: + assert r["rename"] == [["엑스", "y"], "엑스", ["엑스", "y"], ["피"], "피"] + assert r["drop"] == [["q"], [], False] + + +def test_순서_기준측점_새키(r) -> None: + assert r["family"] == [False, True, ["B", "A"]] + assert r["type"] == [False, True, ["k2", "k1", "k3"]] + assert r["base"] == [True, False] + assert r["step"] == [False, True, [2, 1, 3]] + assert r["newKey"] == [True, True]