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