feat(구조물표): 322-2 M02 표 화면 — 탭 셋(한눈 표 · 종류별 · 부품) · 반영 · 부품 · 알약 색 저장 · 목록 + enabled 함께 · 검사 짝(화면 = 서버 · 1-3 규칙 · 만든 뒤 고정) · 명세 폼 보임 따름 · 판 알림

- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로]
- 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만
- 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만)
- 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참
- 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만
- 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름
- 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번
- 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 15:40:38 +09:00
co-authored by Claude Opus 5.5
parent bbd6254892
commit 3abd2bdfd4
26 changed files with 1635 additions and 34 deletions
@@ -0,0 +1,169 @@
"""구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 반영 · 부품 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
- 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
- 부품 = `{"갈래.부품": false}`(끈 것만)
- 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
- 앞 판이 없으면(옛 판 읽기) 꼴만 검사
- 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
화면 짝 = `M02_SO_Def_Check.ts`(같은 글)
거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
"""
from __future__ import annotations
from typing import Any
from common_util.common_util_structure_catalog import load_parts, resolve_catalog
BRANCHES = ("횡단도", "카드이름", "형상", "3D")
OFF_VALUE = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"}
LISTS = ("both", "b06", "hidden")
_FIELDS = {"목록", "형상칸", *BRANCHES}
def _shape_errors(doc: dict[str, Any], known: set[str]) -> list[str]:
"""반영 · 부품 꼴."""
parts = load_parts()["parts"]
out: list[str] = []
reflect = doc.get("반영", {})
if not isinstance(reflect, dict):
out.append("반영은 {종류: 묶음} 이어야 함")
else:
for tid, row in reflect.items():
if tid not in known or not isinstance(row, dict):
out.append(f"반영 「{tid}」 — 없는 종류이거나 묶음 아님")
continue
for field, value in row.items():
where = f"반영 「{tid}.{field}」"
if field not in _FIELDS:
out.append(f"{where} — 모르는 칸")
elif field == "목록":
if value not in LISTS:
out.append(f"{where} — both · b06 · hidden 중 하나")
elif field == "형상칸":
ok = isinstance(value, dict) and all(
(isinstance(v, str) and bool(v.strip()))
if k == "form"
else (k == "weep" and isinstance(v, bool))
for k, v in value.items()
)
if not ok:
out.append(f"{where} — form 글 · weep 참거짓")
elif not isinstance(value, str) or (
value != OFF_VALUE[field] and value not in parts[field]
):
shown = value if isinstance(value, str) else _js(value)
out.append(f"{where} — 없는 부품 「{shown}」")
off = doc.get("부품", {})
if not isinstance(off, dict):
out.append("부품은 {갈래.부품: 참거짓} 이어야 함")
else:
for key, value in off.items():
branch, _, part = key.partition(".")
if part not in parts.get(branch, {}):
out.append(f"부품 「{key}」 — 없는 부품")
elif not isinstance(value, bool):
out.append(f"부품 「{key}」 — 참거짓")
return out
def _js(value: Any) -> str:
"""TS `String(value)` 와 같은 글(검사 글 짝)."""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (list, tuple)):
return ",".join(_js(v) for v in value)
if isinstance(value, dict):
return "[object Object]"
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def _hidden_in(doc: dict[str, Any] | None, tid: str) -> list[str]:
row = ((doc or {}).get("표시") or {}).get(tid) or {}
return [key for key, shown in row.items() if shown is False]
def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
"""표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만)."""
parts = load_parts()["parts"]
rows = resolve_catalog(doc)
out: list[str] = []
for t in doc.get("types") or []:
row = rows.get(t["type_id"])
if row is None:
continue
if row["shape"] == "none" and row["station"] != "none":
out.append(f"「{row['name']}」 형상 none — 횡단도도 none 이어야 함")
kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음
hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept]
# 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
keys = {
o["key"]
for o in t.get("options") or []
if o.get("default") is None and not o.get("empty_means")
}
used = (
("횡단도", row["station"]),
("카드이름", row["title"]),
("형상", row["shape"]),
("3D", row["model3d"]),
)
for branch, part_id in used:
part = parts[branch].get(part_id)
if part is None:
continue
if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]:
out.append(
f"「{row['name']}」 {branch} 「{part_id}」 — 자리꼴 {row['placement']} · "
f"정본 {row['owner']} 에 안 맞음"
)
for key in part["읽는 칸"]:
if key in keys and key in hidden:
out.append(
f"「{row['name']}」 {branch} 「{part_id}」 이 읽는 칸 「{key}」 — "
"숨길 수 없음"
)
return out
def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]:
"""앞 판과 비교 — 만든 뒤 고정."""
out: list[str] = []
now = {t["type_id"]: t for t in doc.get("types") or []}
for old in before.get("types") or []:
tid = old["type_id"]
t = now.get(tid)
if t is None:
out.append(f"종류 「{tid}」 — 지울 수 없음")
continue
fixed = (
("자리꼴", old.get("placement"), t.get("placement")),
("정본", old.get("managed_by"), t.get("managed_by")),
("군 글자", old.get("group"), t.get("group")),
)
for what, a, b in fixed:
if a != b:
out.append(f"「{tid}」 {what} — 만든 뒤 고정")
options = {o["key"]: o for o in t.get("options") or []}
for o in old.get("options") or []:
n = options.get(o["key"])
if o.get("input") != "select" or n is None:
continue
if sorted(o.get("choices") or []) != sorted(n.get("choices") or []):
out.append(f"「{tid}.{o['key']}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)")
return out
def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]:
"""구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 판(없으면 고정 검사 안 함)."""
known = {t["type_id"] for t in doc.get("types") or [] if isinstance(t, dict)}
shape = _shape_errors(doc, known)
if shape:
return shape
return _row_errors(doc, before) + _fixed_errors(doc, before) if before else []
+59 -13
View File
@@ -1,8 +1,10 @@
/* =============================================================================
* M02_SO_Def.ts
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) ·
* 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) ·
* 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄).
* 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 탭 셋(322-2): 「한눈 표」(종류마다 반영 한 줄 ·
* `M02_SO_Def_Table`) · 「종류별」(왼쪽 구조물군 · 종류 목록 · 오른쪽 고른 종류 이름 · 반영 · 명세 이름 나무 ·
* 줄마다 보임 · 미리보기) · 「부품」(`M02_SO_Def_Parts`) ·
* 아래 검사(화면 = 서버 짝 `M02_SO_Def_Check`) · [저장](판 +1 · 목록 + enabled 함께 · 시스템 관리자만) ·
* 지난 판 목록(누르면 그 판을 읽기만으로 봄).
* 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1).
* ========================================================================== */
@@ -28,6 +30,10 @@ import { buildOptionsBlock } from "./M02_SO_Def_Options";
import { buildRulesBlock } from "./M02_SO_Def_Rules";
import { buildSpecBlock } from "./M02_SO_Def_Spec";
import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe";
import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check";
import { buildParts } from "./M02_SO_Def_Parts";
import { setReflect, syncEnabled } from "./M02_SO_Def_Reflect";
import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table";
import { checkBox, textBox } from "./M02_SO_Def_Ui";
import { buildGroupBlock } from "./M02_SO_Groups";
import { to } from "./M02_SO_Text";
@@ -44,10 +50,12 @@ export interface DefScreenOptions {
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */
/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */
function tidy(def: StructDef): StructDef {
const out = clone(def);
for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record<string, unknown>[])
syncEnabled(out);
const reflect = (out.반영 ?? {}) as Record<string, unknown>;
for (const part of [out.그룹, out.배치, out.종류, out.표시, reflect] 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;
@@ -88,6 +96,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
let current: string | null = null;
const open = new Set<string>(); // 선택지를 펼친 옵션
let versions: DefVersion[] | null = null;
type Tab = "table" | "kinds" | "parts";
let tab: Tab = "table";
const shown = (): StructDef => past ?? work;
const canEdit = (): boolean => opt.isAdmin && !past;
@@ -100,6 +110,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
attrs: { "data-sod-history": "" },
});
/** 검사 글 — 이름 · 배치 · 규칙(`problems`) + 구조물 표(서버 짝 · 앞 판 = 지금 서버 판) */
const allProblems = (): string[] =>
past ? [] : [...problems(work), ...catalogProblems(work as CheckDef, base as CheckDef)];
const tabBar = el("div", { className: "m02-sod__tabs", attrs: { "data-sod-tabs": "" } });
const body = el("div", { className: "m02-sod__body" });
const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] });
const changed = (): void => {
dirty = true;
sync();
@@ -199,10 +216,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
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",
}),
// 안 씀 = 목록 B06 만(enabled 는 목록 짝 · 322-2) · 숨김(hidden)은 한눈 표 목록 칸에서
checkBox(
to("Unused"),
type.enabled === false,
(v) => (setReflect(d, type.type_id, "목록", v ? "b06" : "both"), changed()),
{ edit, attr: "data-sod-kind-off" },
),
...(!OLD_EDITOR
? []
: [
@@ -225,7 +245,10 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
],
}),
...(!OLD_EDITOR
? [buildSpecBlock({ def: d, type, spec, edit, changed })]
? [
buildReflectBlock(d, type, edit, changed),
buildSpecBlock({ def: d, base, type, spec, edit, changed }),
]
: [
buildOptionsBlock({ def: d, type, edit, open, changed }),
buildLayoutBlock({ def: d, type, edit, changed }),
@@ -235,6 +258,27 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
);
};
const paintTabs = (): void => {
const pairs: Array<[Tab, string]> = [
["table", to("TabTable")],
["kinds", to("TabKinds")],
["parts", to("TabParts")],
];
tabBar.replaceChildren(
...pairs.map(([key, label]) => {
const b = createButton({ label, variant: key === tab ? "filled" : "ghost", size: "sm" });
b.setAttribute("data-sod-tab", key);
b.addEventListener("click", () => ((tab = key), paint()));
return b;
}),
);
const edit = canEdit();
if (tab === "table")
body.replaceChildren(buildTable({ def: shown(), edit, changed, issues: allProblems() }));
else if (tab === "parts") body.replaceChildren(buildParts({ def: shown(), edit, changed }));
else body.replaceChildren(panes);
};
const paintFoot = (): void => {
banner.replaceChildren(
...(past
@@ -248,7 +292,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
]),
);
banner.classList.toggle("is-past", !!past);
const list = past ? [] : problems(work);
const list = allProblems();
issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t })));
save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0;
};
@@ -256,6 +300,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
const paint = (): void => {
paintKinds();
paintDetail();
paintTabs();
paintFoot();
};
@@ -273,7 +318,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
const save = createButton({ label: to("Save"), variant: "filled" });
save.setAttribute("data-sod-save", "");
save.addEventListener("click", async () => {
if (problems(work).length) return;
if (allProblems().length) return;
save.disabled = true; // 연달아 눌러도 한 번만
try {
base = await saveStructureDef(base.판, tidy(work));
@@ -339,7 +384,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
children: [
el("p", { className: "m02-side__empty", text: to("DefNote") }),
banner,
el("div", { className: "m02-opts__panes", children: [kindList, detail] }),
tabBar,
body,
issues,
el("div", {
className: "m02-side__row",
@@ -0,0 +1,192 @@
/* =============================================================================
* M02_SO_Def_Check.ts
* 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정.
*
* · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것
* · 부품 = `{"갈래.부품": false}`(끈 것만)
* · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 ·
* 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면
* 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠)
* · 앞 판이 없으면(옛 판 읽기) 꼴만 검사
* · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로)
* 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`.
* ========================================================================== */
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
import {
resolveCatalog,
type CatalogDefinition,
} from "../../common_util/common_util_structure_catalog";
type Branch = "횡단도" | "카드이름" | "형상" | "3D";
interface Part {
이름: string;
"읽는 칸": string[];
자리꼴: string[];
정본: string[];
}
const PARTS_BY = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"];
/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */
export const OFF_VALUE: Record<Branch, string> = {
횡단도: "none",
카드이름: "name",
형상: "none",
"3D": "none",
};
export const LISTS = ["both", "b06", "hidden"] as const;
const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]);
/** 검사가 읽는 정의 칸. */
export interface CheckDef extends CatalogDefinition {
types?: ReadonlyArray<{
type_id: string;
name?: string;
group?: string;
placement?: string;
managed_by?: string | null;
options?: ReadonlyArray<{
key: string;
input?: string;
choices?: readonly string[];
default?: unknown;
empty_means?: string | null;
}>;
}>;
표시?: Record<string, Record<string, boolean>>;
}
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */
const partOf = (branch: string, id: string): Part | undefined => {
const box = (PARTS_BY as Record<string, Record<string, Part>>)[branch];
return box && Object.hasOwn(box, id) ? box[id] : undefined;
};
/** 반영 · 부품 꼴. */
function shapeErrors(doc: CheckDef, known: Set<string>): string[] {
const out: string[] = [];
const reflect: unknown = doc.반영 ?? {};
if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함");
else
for (const [tid, row] of Object.entries(reflect)) {
if (!known.has(tid) || !isObject(row)) {
out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`);
continue;
}
for (const [field, value] of Object.entries(row)) {
const where = `반영 「${tid}.${field}」`;
if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`);
else if (field === "목록") {
if (!(LISTS as readonly unknown[]).includes(value))
out.push(`${where} — both · b06 · hidden 중 하나`);
} else if (field === "형상칸") {
const ok =
isObject(value) &&
Object.entries(value).every(([k, v]) =>
k === "form"
? typeof v === "string" && !!v.trim()
: k === "weep" && typeof v === "boolean",
);
if (!ok) out.push(`${where} — form 글 · weep 참거짓`);
} else {
const branch = field as Branch;
if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value)))
out.push(`${where} — 없는 부품 「${String(value)}」`);
}
}
}
const parts: unknown = doc.부품 ?? {};
if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함");
else
for (const [key, value] of Object.entries(parts)) {
const at = key.indexOf(".");
if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1)))
out.push(`부품 「${key}」 — 없는 부품`);
else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`);
}
return out;
}
const hiddenIn = (doc: CheckDef | null, tid: string): string[] =>
Object.entries(doc?.표시?.[tid] ?? {})
.filter(([, shown]) => shown === false)
.map(([key]) => key);
/** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */
function rowErrors(doc: CheckDef, before: CheckDef | null): string[] {
const out: string[] = [];
const rows = resolveCatalog(doc);
for (const t of doc.types ?? []) {
const row = rows.get(t.type_id);
if (!row) continue;
if (row.shape === "none" && row.station !== "none")
out.push(`「${row.name}」 형상 none — 횡단도도 none 이어야 함`);
const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음
const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key));
// 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김
const keys = new Set(
(t.options ?? [])
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
.map((o) => o.key),
);
const used: Array<[Branch, string]> = [
["횡단도", row.station],
["카드이름", row.title],
["형상", row.shape],
["3D", row.model3d],
];
for (const [branch, id] of used) {
const part = partOf(branch, id);
if (!part) continue;
if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner))
out.push(
`「${row.name}」 ${branch} 「${id}」 — 자리꼴 ${row.placement} · 정본 ${row.owner} 에 안 맞음`,
);
for (const key of part["읽는 칸"])
if (keys.has(key) && hidden.includes(key))
out.push(`「${row.name}」 ${branch} 「${id}」 이 읽는 칸 「${key}」 — 숨길 수 없음`);
}
}
return out;
}
/** 앞 판과 비교 — 만든 뒤 고정. */
function fixedErrors(doc: CheckDef, before: CheckDef): string[] {
const out: string[] = [];
const now = new Map((doc.types ?? []).map((t) => [t.type_id, t]));
for (const old of before.types ?? []) {
const t = now.get(old.type_id);
if (!t) {
out.push(`종류 「${old.type_id}」 — 지울 수 없음`);
continue;
}
const fixed: Array<[string, unknown, unknown]> = [
["자리꼴", old.placement, t.placement],
["정본", old.managed_by ?? null, t.managed_by ?? null],
["군 글자", old.group, t.group],
];
for (const [what, a, b] of fixed)
if (a !== b) out.push(`「${old.type_id}」 ${what} — 만든 뒤 고정`);
const options = new Map((t.options ?? []).map((o) => [o.key, o]));
for (const o of old.options ?? []) {
const n = options.get(o.key);
if (o.input !== "select" || !n) continue;
const a = [...(o.choices ?? [])].sort().join("\u0000");
const b = [...(n.choices ?? [])].sort().join("\u0000");
if (a !== b)
out.push(`「${old.type_id}.${o.key}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)`);
}
}
return out;
}
/** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */
export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] {
const known = new Set((doc.types ?? []).map((t) => t.type_id));
const shape = shapeErrors(doc, known);
if (shape.length) return shape;
return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : [];
}
@@ -0,0 +1,73 @@
/* =============================================================================
* M02_SO_Def_Parts.ts
* 구조물 정의 「부품」 탭(PLAN 322-2) — 코드가 그리는 방법(부품 상자 `common_util_structure_parts.json`).
* 갈래마다 부품 · 이름 · 읽는 칸 · 맞는 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 `부품` = 끈 것만).
* 끈 부품을 쓰는 종류는 그 갈래가 없음(카드 이름은 종류 이름)으로 — 표 줄(`resolveCatalog`)이 따름.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { StructDef } from "./M02_SO_Def_Model";
import { PART_BOX, partsOff, setPartOn, usersOf } from "./M02_SO_Def_Reflect";
import { checkBox, head } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
type Branch = keyof typeof PART_BOX;
export function buildParts(opt: {
def: StructDef;
edit: boolean;
changed: () => void;
}): HTMLElement {
const { def, edit, changed } = opt;
const off = partsOff(def);
const blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [
...head(branch),
el("table", {
className: "m02-sod__table",
attrs: { "data-sod-parts": branch },
children: [
el("thead", {
children: [
el("tr", {
children: ["", "ColName", "PartRead", "PartFits", "PartUsers", "PartOn"].map((key) =>
el("th", { text: key ? to(key as "ColName") : "" }),
),
}),
],
}),
el("tbody", {
children: Object.entries(PART_BOX[branch]).map(([id, part]) => {
const key = `${branch}.${id}`;
// 끈 부품을 쓰던 종류도 보이게 — 끈 동안은 표가 none 으로 풀므로 켠 꼴로 셈
const users = usersOf({ ...def, 부품: {} } as StructDef, branch, id);
return el("tr", {
attrs: { "data-sod-part": key },
className: off[key] === false ? "is-off" : "",
children: [
el("td", { children: [el("small", { className: "m02-opts__key", text: id })] }),
el("td", { text: part.이름 }),
el("td", { text: part["읽는 칸"].join(" · ") || "—" }),
el("td", { text: `${part.자리꼴.join(" · ")} / ${part.정본.join(" · ")}` }),
el("td", { text: users.map((row) => row.name).join(" · ") || "—" }),
el("td", {
children: [
checkBox(
to("PartOn"),
off[key] !== false,
(on) => (setPartOn(def, key, on), changed()),
{ edit, attr: "data-sod-part-on" },
),
],
}),
],
});
}),
}),
],
}),
]);
return el("div", {
className: "m02-sod__tablewrap",
children: [el("p", { className: "m02-side__empty", text: to("PartsNote") }), ...blocks],
});
}
@@ -0,0 +1,176 @@
/* =============================================================================
* M02_SO_Def_Reflect.ts
* 구조물 표 편집(PLAN 322-2)의 순수 손질 — 정의 사본의 `반영` · `부품` · 목록 + enabled · 형상칸.
*
* · 지금 값 = 구조물 표 한 줄(`resolveCatalog` · 정의 반영 ⊕ 처음값 ⊕ 부품 끔)
* · 반영에는 처음 값과 다른 칸만 남김(같으면 지움 = 「기본」) · [처음 값으로] = 그 종류 반영 지움
* · 목록과 enabled 는 함께 씀 — enabled = 목록 both 일 때만 참(옛 짝 · 브레인 322-1 답 ①)
* 화면 그리기 없음 — 표 · 종류별 · 부품 탭이 이 함수들로 사본을 고침.
* ========================================================================== */
import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" };
import {
resolveCatalog,
type CatalogDefinition,
type CatalogRow,
} from "../../common_util/common_util_structure_catalog";
import type { StructDef } from "./M02_SO_Def_Model";
export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D";
type Reflect = Partial<Record<ReflectField, string>> & { 형상칸?: Record<string, unknown> };
interface Part {
이름: string;
"읽는 칸": string[];
자리꼴: string[];
정본: string[];
}
type Branch = Exclude<ReflectField, "목록">;
export const PART_BOX = (PARTS as unknown as { parts: Record<Branch, Record<string, Part>> }).parts;
const START = (PARTS as unknown as { 처음값: Record<string, Reflect> }).처음값;
/** 칸 → 표 줄 열쇠. */
const ROW_KEY: Record<ReflectField, keyof CatalogRow> = {
목록: "list",
횡단도: "station",
카드이름: "title",
형상: "shape",
"3D": "model3d",
};
export const reflectOf = (def: StructDef): Record<string, Reflect> =>
((def as { 반영?: Record<string, Reflect> }).반영 ??= {});
export const partsOff = (def: StructDef): Record<string, boolean> =>
((def as { 부품?: Record<string, boolean> }).부품 ??= {});
/** 사본 전체 표 — 종류마다 한 줄. */
export const rowsOf = (def: StructDef): Map<string, CatalogRow> =>
resolveCatalog(def as unknown as CatalogDefinition);
/** 칸 지금 값. */
export const valueOf = (row: CatalogRow, field: ReflectField): string =>
String(row[ROW_KEY[field]]);
/** 반영이 없을 때(처음 값 · 목록은 enabled 짝) 그 칸 값. */
export function defaultOf(def: StructDef, typeId: string, field: ReflectField): string {
const copy = { ...def, 반영: { ...reflectOf(def) } } as unknown as StructDef;
const own = { ...(reflectOf(copy)[typeId] ?? {}) };
delete own[field];
reflectOf(copy)[typeId] = own;
const row = rowsOf(copy).get(typeId);
return row ? valueOf(row, field) : "";
}
/** 반영에 적힌 칸인가(아니면 「기본」). */
export const isSet = (def: StructDef, typeId: string, field: ReflectField | "형상칸"): boolean =>
field in (reflectOf(def)[typeId] ?? {});
const dropEmpty = (def: StructDef, typeId: string): void => {
const all = reflectOf(def);
if (all[typeId] && !Object.keys(all[typeId]).length) delete all[typeId];
};
/** 칸 고침 — 처음 값과 같으면 반영에서 지움 · 목록이면 enabled 도(both 일 때만 참). */
export function setReflect(
def: StructDef,
typeId: string,
field: ReflectField,
value: string,
): void {
const type = def.types.find((t) => t.type_id === typeId);
if (field === "목록" && type) type.enabled = value === "both";
const own = (reflectOf(def)[typeId] ??= {});
delete own[field];
if (defaultOf(def, typeId, field) !== value) own[field] = value;
dropEmpty(def, typeId);
}
/** 형상칸 한 칸 고침(form 글 · weep 참거짓) — 처음 값과 같으면 지움. */
export function setShapeOpt(def: StructDef, typeId: string, key: string, value: unknown): void {
const own = (reflectOf(def)[typeId] ??= {});
const opts = { ...(own.형상칸 ?? {}) };
const start = START[typeId]?.형상칸?.[key];
if (value === start || value === "" || (value === false && start === undefined)) delete opts[key];
else opts[key] = value;
if (Object.keys(opts).length) own.형상칸 = opts;
else delete own.형상칸;
dropEmpty(def, typeId);
}
/** [처음 값으로] — 그 종류 반영 지움 · enabled = 처음 목록 both 일 때만 참. */
export function resetRow(def: StructDef, typeId: string): void {
delete reflectOf(def)[typeId];
const type = def.types.find((t) => t.type_id === typeId);
if (type) type.enabled = START[typeId] ? START[typeId].목록 === "both" : type.enabled;
}
/** [저장] 전 — 모든 종류 enabled = 지금 목록 both 일 때만 참(목록 + enabled 함께 씀). */
export function syncEnabled(def: StructDef): void {
const rows = rowsOf(def);
for (const type of def.types) {
const row = rows.get(type.type_id);
if (row) type.enabled = row.list === "both";
}
}
/** 부품 켬끔 — 끔만 남김. */
export function setPartOn(def: StructDef, key: string, on: boolean): void {
const off = partsOff(def);
if (on) delete off[key];
else off[key] = false;
}
/** 못 숨김 칸 — 종류가 지금 쓰는 부품들이 읽는 칸 중 기본값도 비워 두는 뜻도 없는 옵션
* (숨기면 그림이 받을 값이 없음 · 검사 짝 `M02_SO_Def_Check`). */
export function readKeys(def: StructDef, typeId: string): Set<string> {
const row = rowsOf(def).get(typeId);
if (!row) return new Set();
const type = def.types.find((t) => t.type_id === typeId);
const bare = new Set(
(type?.options ?? [])
.filter((o) => (o.default === null || o.default === undefined) && !o.empty_means)
.map((o) => o.key),
);
const used: Array<[Branch, string]> = [
["횡단도", row.station],
["카드이름", row.title],
["형상", row.shape],
["3D", row.model3d],
];
return new Set(
used
.flatMap(([branch, id]) => PART_BOX[branch][id]?.["읽는 칸"] ?? [])
.filter((key) => bare.has(key)),
);
}
/** 그 갈래 부품을 지금 쓰는 종류들. */
export function usersOf(def: StructDef, branch: Branch, id: string): CatalogRow[] {
const field = ROW_KEY[branch];
return [...rowsOf(def).values()].filter((row) => row[field] === id);
}
/** 정의 「표시」 숨김 키(그 종류). */
export const hiddenKeys = (def: StructDef, typeId: string): Set<string> =>
new Set(
Object.entries(def.표시[typeId] ?? {})
.filter(([, shown]) => shown === false)
.map(([key]) => key),
);
/** 옵션 키들 보임 켬끔 — 정의 옵션인 키만(숨김 = false 만 남김). */
export function setShown(
def: StructDef,
typeId: string,
keys: readonly string[],
on: boolean,
): void {
const type = def.types.find((t) => t.type_id === typeId);
const known = new Set((type?.options ?? []).map((o) => o.key));
const row = (def.표시[typeId] ??= {});
for (const key of keys.filter((k) => known.has(k))) {
if (on) delete row[key];
else row[key] = false;
}
if (!Object.keys(row).length) delete def.표시[typeId];
}
@@ -6,11 +6,13 @@
* 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품
* (`field` · `optionControl` · 그룹 상자)으로 같은 차례.
* 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침.
* 줄마다 보임(322-2 · 정의 「표시」 · 컨테이너 = 그 안 칸 전부) · 부품이 읽는 칸 = 「못 숨김」(앞 판에서
* 이미 숨긴 칸은 다시 보이기만) · 미리보기도 숨김을 따름.
* ========================================================================== */
import { createTag, el } from "@ui/ui_template_elements";
import { createSpecForm } from "../../A00_Common/design/design_structure_form";
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
import { specItemKeys, type StructureSpec } from "../../A00_Common/design/design_structure_spec";
import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
@@ -20,7 +22,8 @@ import {
specNameRows,
type SpecNameRow,
} from "./M02_SO_Def_Spec_Model";
import { head, textBox } from "./M02_SO_Def_Ui";
import { hiddenKeys, readKeys, setShown } from "./M02_SO_Def_Reflect";
import { checkBox, head, textBox } from "./M02_SO_Def_Ui";
import { to, type OptionsTextKey } from "./M02_SO_Text";
const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
@@ -36,7 +39,8 @@ const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */
function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
const names = def.종류[type.type_id]?.이름 ?? {};
const options = type.options.filter((o) => o.enabled !== false);
const hidden = hiddenKeys(def, type.type_id);
const options = type.options.filter((o) => o.enabled !== false && !hidden.has(o.key));
const cell = (o: (typeof options)[number]): HTMLElement =>
field(
o.unit ? `${o.label} (${o.unit})` : o.label,
@@ -67,8 +71,45 @@ function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
});
}
/** 이름 줄이 쓰는 정의 옵션 키 — 명세 상자 = 그 안 칸 전부 · 칸 = 저장 · 갈래 키 · 그룹 = 그 옵션들. */
function rowKeys(type: TypeDef, spec: StructureSpec | undefined, nameKey: string): string[] {
if (!nameKey || nameKey.includes("=")) return [];
if (!spec) {
if (!nameKey.startsWith("#")) return [nameKey];
return [];
}
const [cid, iid] = nameKey.split(".");
const c = spec.containers.find((x) => x.id === cid);
const items = (c?.items ?? []).filter((i) => !iid || i.id === iid);
const own = new Set(type.options.map((o) => o.key));
return items.flatMap(specItemKeys).filter((key) => own.has(key));
}
/** 줄 보임 칸 — 키 없는 줄은 비움 · 부품이 읽는 보이는 칸은 못 숨김. */
function shownBox(
opt: { def: StructDef; base: StructDef; type: TypeDef; edit: boolean; changed: () => void },
keys: string[],
): HTMLElement[] {
if (!keys.length) return [];
const { def, base, type, edit, changed } = opt;
const hidden = hiddenKeys(def, type.type_id);
const kept = hiddenKeys(base, type.type_id);
const read = readKeys(def, type.type_id);
const shown = keys.some((key) => !hidden.has(key));
const locked = shown && keys.some((key) => read.has(key) && !kept.has(key));
return [
checkBox(to("ShownCol"), shown, (v) => (setShown(def, type.type_id, keys, v), changed()), {
edit: edit && !locked,
attr: "data-sod-spec-shown",
}),
...(locked ? [createTag(to("CannotHide"), "neutral")] : []),
];
}
export function buildSpecBlock(opt: {
def: StructDef;
/** 지금 서버 판 — 이미 숨긴 칸(못 숨김 예외)을 가름 */
base: StructDef;
type: TypeDef;
/** 없으면 정의 그룹 꼴(52-12) */
spec: StructureSpec | undefined;
@@ -90,6 +131,12 @@ export function buildSpecBlock(opt: {
: el("span", { text: to("SpecLoose") }),
el("small", { className: "m02-opts__key", text: row.key }),
...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []),
...shownBox(
opt,
row.group && !spec
? ((def.그룹[type.type_id] ?? []).find((g) => g.id === row.group)?.옵션 ?? [])
: rowKeys(type, spec, row.nameKey),
),
],
}),
);
@@ -98,7 +145,14 @@ export function buildSpecBlock(opt: {
if (spec) {
const form = createSpecForm(null);
const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름]));
form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups);
form.write(
spec,
{},
def.종류[type.type_id]?.이름 ?? {},
groups,
{},
hiddenKeys(def, type.type_id),
);
preview = form.root;
} else preview = groupPreview(def, type);
preview.inert = true;
@@ -0,0 +1,327 @@
/* =============================================================================
* M02_SO_Def_Table.ts
* 구조물 정의 「한눈 표」 탭(PLAN 322-2) — 종류마다 B05 · B06 반영 한 줄.
* 군 · 종류(▲▼) · 이름 · 목록 · 자리꼴 · 정본(읽기만 「고정」) · 횡단도 · 카드 이름 · 형상(+ 형상칸) ·
* 알약 색 · 3D · 검사. 흐린 칸 = 처음 값(「기본」) · 진한 칸 = 바꾼 값 · [처음 값으로] = 그 종류 반영 지움.
* 칸 손질 = `M02_SO_Def_Reflect` · 검사 글 = `M02_SO_Def_Check`(서버 짝) · 「종류별」 탭이 같은 칸(`reflectCells`)을 씀.
* ========================================================================== */
import { createButton, createTag, el } from "@ui/ui_template_elements";
import type { CatalogRow } from "../../common_util/common_util_structure_catalog";
import { stepFamily, stepType, type StructDef, type TypeDef } from "./M02_SO_Def_Model";
import {
isSet,
PART_BOX,
resetRow,
rowsOf,
setReflect,
setShapeOpt,
valueOf,
type ReflectField,
} from "./M02_SO_Def_Reflect";
import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui";
import { to } from "./M02_SO_Text";
type Pairs = ReadonlyArray<readonly [string, string]>;
const LIST_PAIRS = (): Pairs => [
["both", to("ListBoth")],
["b06", to("ListB06")],
["hidden", to("ListHidden")],
];
/** 갈래 고르기 — 없음(카드 이름은 종류 이름) + 부품 상자 차례. */
const partPairs = (branch: Exclude<ReflectField, "목록">): Pairs => [
[
branch === "카드이름" ? "name" : "none",
branch === "카드이름" ? to("TitleName") : to("OffNone"),
],
...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, `${id} · ${part.이름}`] as const),
];
interface CellCtx {
def: StructDef;
type: TypeDef;
row: CatalogRow;
edit: boolean;
changed: () => void;
}
/** 반영 칸 하나 — 처음 값이면 흐리게 「기본」 · 바꿨으면 진하게. */
function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement {
const { def, type, row, edit, changed } = ctx;
const pairs = field === "목록" ? LIST_PAIRS() : partPairs(field);
const box = pickBox(
pairs,
valueOf(row, field),
(v) => {
setReflect(def, type.type_id, field, v);
changed();
},
{ edit, attr: `data-sod-reflect-${field}` },
);
return mark(isSet(def, type.type_id, field), box);
}
/** 흐림(기본) · 진함(바꿈) 꼴. */
function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement {
return el("div", {
className: `m02-sod__cell ${changed ? "is-changed" : "is-default"}`,
children: [...children, ...(changed ? [] : [createTag(to("Default"), "neutral")])],
});
}
/** 형상칸 — 벽(형상 wall)만: 기본 형태 글 · 물빼기. */
function shapeOptsCell(ctx: CellCtx): HTMLElement {
const { def, type, row, edit, changed } = ctx;
if (row.shape !== "wall") return el("span", { className: "m02-side__empty", text: "—" });
const form = String(row.shapeOpts.form ?? "");
return mark(
isSet(def, type.type_id, "형상칸"),
textBox(
form,
() => undefined,
() => undefined,
{
edit,
attr: "data-sod-shape-form",
place: to("WallForm"),
max: 20,
},
),
checkBox(
to("Weep"),
row.shapeOpts.weep === true,
(v) => (setShapeOpt(def, type.type_id, "weep", v), changed()),
{ edit, attr: "data-sod-shape-weep" },
),
);
}
/** 형상칸 글은 떠날 때 씀(초점 안 끊김). */
function bindForm(node: HTMLElement, ctx: CellCtx): void {
const box = node.querySelector<HTMLInputElement>("[data-sod-shape-form]");
box?.addEventListener("change", () => {
setShapeOpt(ctx.def, ctx.type.type_id, "form", box.value.trim());
ctx.changed();
});
}
/** 알약 색(style.color). */
function colorCell(ctx: CellCtx): HTMLElement {
const box = el("input", {
className: "m02-sod__color",
attrs: { type: "color", "data-sod-color": "" },
}) as HTMLInputElement;
const style = (ctx.type.style ?? {}) as { color?: string };
box.value = style.color ?? "#888888";
box.disabled = !ctx.edit;
box.addEventListener("change", () => {
ctx.type.style = { ...style, color: box.value };
ctx.changed();
});
return box;
}
const fixed = (text: string): HTMLElement =>
el("span", {
className: "m02-sod__fixed",
children: [el("span", { text }), createTag(to("Fixed"), "neutral")],
});
/** 「종류별」 탭이 쓰는 반영 칸들 — [글, 칸] 짝. */
export function reflectCells(
def: StructDef,
type: TypeDef,
edit: boolean,
changed: () => void,
): Array<[string, HTMLElement]> {
const row = rowsOf(def).get(type.type_id);
if (!row) return [];
const ctx = { def, type, row, edit, changed };
const opts = shapeOptsCell(ctx);
bindForm(opts, ctx);
return [
[to("ColList"), reflectCell(ctx, "목록")],
[to("ColPlacement"), fixed(row.placement)],
[to("ColOwner"), fixed(row.owner)],
[to("ColStation"), reflectCell(ctx, "횡단도")],
[to("ColTitle"), reflectCell(ctx, "카드이름")],
[to("ColShape"), reflectCell(ctx, "형상")],
[to("ColShapeOpts"), opts],
[to("ColColor"), colorCell(ctx)],
[to("Col3D"), reflectCell(ctx, "3D")],
];
}
function moveButton(label: string, run: () => boolean, off: boolean, changed: () => void) {
const b = createButton({ label, variant: "ghost", size: "sm" });
b.disabled = off;
b.addEventListener("click", () => run() && changed());
return b;
}
/** 그 종류 검사 글 — 이름 · 키가 든 줄. */
const issuesOf = (issues: readonly string[], type: TypeDef): string[] =>
issues.filter((t) => t.includes(`「${type.name}」`) || t.includes(`「${type.type_id}`));
export function buildTable(opt: {
def: StructDef;
edit: boolean;
changed: () => void;
issues: readonly string[];
}): HTMLElement {
const { def, edit, changed, issues } = opt;
const heads = [
"ColFamily",
"ColKind",
"ColName",
"ColList",
"ColPlacement",
"ColOwner",
"ColStation",
"ColTitle",
"ColShape",
"ColShapeOpts",
"ColColor",
"Col3D",
"ColCheck",
] as const;
const td = (child: HTMLElement | string): HTMLElement =>
el("td", typeof child === "string" ? { text: child } : { children: [child] });
const body: HTMLElement[] = [];
const families = Object.keys(def.구조물군);
families.forEach((g, gi) => {
const name = textBox(def.구조물군[g], (v) => (def.구조물군[g] = v), changed, {
edit,
attr: "data-sod-family-name",
});
const familyRow = el("tr", {
className: "m02-sod__familyrow",
attrs: { "data-sod-table-family": g },
children: [
el("td", {
attrs: { colspan: String(heads.length) },
children: [
el("strong", { text: g }),
name,
...(edit
? [
moveButton(to("GroupUp"), () => stepFamily(def, g, -1), gi === 0, changed),
moveButton(
to("GroupDown"),
() => stepFamily(def, g, 1),
gi === families.length - 1,
changed,
),
]
: []),
],
}),
],
});
body.push(familyRow);
const kinds = def.types.filter((t) => t.group === g);
kinds.forEach((type, ti) => {
const cells = new Map(reflectCells(def, type, edit, changed));
const bad = issuesOf(issues, type);
const reset = createButton({ label: to("Reset"), variant: "ghost", size: "sm" });
reset.disabled = !edit || !(def as { 반영?: Record<string, unknown> }).반영?.[type.type_id];
reset.addEventListener("click", () => (resetRow(def, type.type_id), changed()));
body.push(
el("tr", {
attrs: { "data-sod-table-kind": type.type_id },
children: [
td(""),
el("td", {
children: [
el("small", { className: "m02-opts__key", text: type.type_id }),
...(edit
? [
moveButton(
to("GroupUp"),
() => stepType(def, type.type_id, -1),
ti === 0,
changed,
),
moveButton(
to("GroupDown"),
() => stepType(def, type.type_id, 1),
ti === kinds.length - 1,
changed,
),
]
: []),
],
}),
td(
textBox(type.name, (v) => (type.name = v), changed, {
edit,
attr: "data-sod-kind-name",
}),
),
...[
"ColList",
"ColPlacement",
"ColOwner",
"ColStation",
"ColTitle",
"ColShape",
"ColShapeOpts",
"ColColor",
"Col3D",
].map((key) => td(cells.get(to(key as (typeof heads)[number])) ?? "")),
el("td", {
attrs: { "data-sod-table-check": "" },
children: [
bad.length
? el("span", { className: "m02-sod__bad", text: bad.join(" · ") })
: createTag(to("Ok"), "accent"),
reset,
],
}),
],
}),
);
});
});
return el("div", {
className: "m02-sod__tablewrap",
children: [
el("p", { className: "m02-side__empty", text: to("TableNote") }),
el("table", {
className: "m02-sod__table",
attrs: { "data-sod-table": "" },
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: to(h) })) })],
}),
el("tbody", { children: body }),
],
}),
],
});
}
/** 「종류별」 탭 머리 아래 반영 칸 묶음(한눈 표 한 줄과 같은 칸). */
export function buildReflectBlock(
def: StructDef,
type: TypeDef,
edit: boolean,
changed: () => void,
): HTMLElement {
return el("div", {
className: "m02-sod__block",
attrs: { "data-sod-reflect-block": "" },
children: [
el("h4", { className: "m02-opts__group", text: to("ReflectHead") }),
el("div", {
className: "m02-sod__reflect",
children: reflectCells(def, type, edit, changed).map(([label, cell]) =>
el("label", {
className: "m02-sod__reflectrow",
children: [el("span", { text: label }), cell],
}),
),
}),
],
});
}
@@ -18,10 +18,12 @@ from pydantic import ValidationError
from B05_Profile import B05_Profile_Structures_Schema as schema
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors
FILE = "구조물정의.json"
HISTORY = "구조물정의_이력"
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"}
# 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`)
TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"}
SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list}
SPOT = ("줄", "칸", "너비")
@@ -171,8 +173,8 @@ def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s
return errors
def check(doc: Any) -> list[str]:
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과)."""
def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]:
"""정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2)."""
from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups
from M02_MasterTemplete.StructOptions import M02_SO_Options as options
@@ -194,7 +196,8 @@ def check(doc: Any) -> list[str]:
fn(doc[section], plain)
except store.StoreError as e:
errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)]
return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys)
return errors + ([] if errors else catalog_errors(doc, before))
def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]:
@@ -206,7 +209,7 @@ def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) ->
if str(version) != str(have):
raise store.StoreError(409, {"stale": [FILE], "판": have})
doc = {**change(current), "판": have}
errors = check(doc)
errors = check(doc, current)
if errors:
raise store.StoreError(422, {"errors": errors})
atomic_write_json(history_path(have), current)
@@ -368,3 +368,67 @@
font-size: var(--text-caption);
line-height: 1;
}
/* ── 322-2 탭 셋 · 한눈 표 · 부품 ── */
.m02-sod__tabs {
display: flex;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.m02-sod__tablewrap {
overflow-x: auto;
}
.m02-sod__table {
border-collapse: collapse;
font-size: var(--text-body-sm);
min-width: 100%;
}
.m02-sod__table th,
.m02-sod__table td {
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-4);
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.m02-sod__familyrow td {
background: var(--color-surface-raised);
}
.m02-sod__table tr.is-off {
opacity: 0.5;
}
.m02-sod__cell {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.m02-sod__cell.is-default {
opacity: 0.6;
}
.m02-sod__cell.is-changed {
font-weight: 600;
}
.m02-sod__fixed {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
opacity: 0.7;
}
.m02-sod__color {
width: 32px;
height: 22px;
padding: 0;
border: none;
background: none;
}
.m02-sod__reflect {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-4);
}
.m02-sod__reflectrow {
display: grid;
grid-template-columns: 7em 1fr;
align-items: center;
gap: var(--spacing-4);
}
@@ -125,6 +125,48 @@ const TEXT = {
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
Detail: ["B06 전용", "B06 only"],
Save: ["저장", "Save"],
TabTable: ["한눈 표", "Overview"],
TabKinds: ["종류별", "By type"],
TabParts: ["부품", "Parts"],
TableNote: [
"종류마다 B05 · B06 반영 한 줄 — 목록 · 횡단도 · 카드 이름 · 형상 · 3D 는 코드 부품 중에서 고름 · 흐린 칸 = 처음 값(기본) · 진한 칸 = 바꾼 값 · 자리꼴 · 정본은 만든 뒤 고정",
"One row per type — pick parts for list · cross section · card name · shape · 3D; faded = default, bold = changed",
],
ColFamily: ["군", "Family"],
ColKind: ["종류", "Type"],
ColName: ["이름", "Name"],
ColList: ["목록", "List"],
ColPlacement: ["자리꼴", "Placement"],
ColOwner: ["정본", "Owner"],
ColStation: ["횡단도", "Cross section"],
ColTitle: ["카드 이름", "Card name"],
ColShape: ["형상", "Shape"],
ColShapeOpts: ["형상칸", "Shape fields"],
ColColor: ["알약 색", "Pill color"],
Col3D: ["3D", "3D"],
ColCheck: ["검사", "Check"],
ListBoth: ["B05 · B06", "B05 · B06"],
ListB06: ["B06 만", "B06 only"],
ListHidden: ["안 보임", "Hidden"],
OffNone: ["없음", "None"],
TitleName: ["종류 이름", "Type name"],
Default: ["기본", "Default"],
Fixed: ["고정", "Fixed"],
Reset: ["처음 값으로", "Reset"],
Ok: ["통과", "OK"],
WallForm: ["기본 형태", "Default form"],
Weep: ["물빼기", "Weep holes"],
ReflectHead: ["B05 · B06 반영", "B05 · B06 use"],
ShownCol: ["보임", "Shown"],
CannotHide: ["못 숨김", "Required"],
PartsNote: [
"코드가 그리는 방법(부품) — 끄면 그 부품을 쓰는 종류는 그 갈래가 없음으로 · 읽는 칸 = 그 부품이 쓰는 옵션(숨기지 못함)",
"Code drawing parts — turning a part off makes its users fall back to none; read fields cannot be hidden",
],
PartRead: ["읽는 칸", "Reads"],
PartFits: ["맞는 자리꼴 · 정본", "Fits"],
PartUsers: ["쓰는 종류", "Used by"],
PartOn: ["켬", "On"],
Stale: [
"그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
"Changed meanwhile — reloaded, please edit again",