/* ============================================================================= * M02_SO_Groups.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 { 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 GroupDef[]): string { const taken = new Set(list.map((g) => g.id)); for (;;) { const id = `g${Math.random().toString(36).slice(2, 8)}`; if (/^g[0-9a-z]{1,16}$/.test(id) && !taken.has(id)) return id; } } /** 그 옵션이 든 그룹 id — 없으면 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: 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); } export interface GroupBlockOptions { /** 고른 종류의 그룹(고칠 사본 — 이 부품이 직접 고침) */ 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; const rows = list.map((group, index) => { const name = el("input", { className: "ui-input ui-input--sm m02-sog__name", attrs: { type: "text", maxlength: String(NAME_MAX), "data-sog-name": group.id }, }) as HTMLInputElement; name.value = group.이름; name.placeholder = to("GroupName"); name.disabled = !opt.edit; // 글자마다 사본만 고침(다시 그리면 초점이 끊김) · 칸을 떠날 때 다시 그림(선택지 이름) name.addEventListener("input", () => { group.이름 = name.value; name.classList.toggle("is-invalid", !name.value.trim()); }); name.addEventListener("change", () => opt.changed()); const step = (delta: number): void => { const at = index + delta; if (at < 0 || at >= list.length) return; [list[index], list[at]] = [list[at], list[index]]; opt.changed(); }; const up = createButton({ label: to("GroupUp"), variant: "ghost", size: "sm" }); const down = createButton({ label: to("GroupDown"), variant: "ghost", size: "sm" }); const del = createButton({ label: to("GroupDel"), variant: "danger", size: "sm" }); up.disabled = !opt.edit || index === 0; down.disabled = !opt.edit || index === list.length - 1; del.disabled = !opt.edit; up.addEventListener("click", () => step(-1)); down.addEventListener("click", () => step(1)); del.addEventListener("click", async () => { if (!(await showConfirmDialog(to("GroupDelAsk", group.이름), to("GroupDel")))) return; list.splice(list.indexOf(group), 1); opt.changed(); }); const line = el("div", { className: "m02-sog__row", attrs: { "data-sog-group": group.id }, children: [ name, el("small", { className: "m02-opts__key", text: to("GroupCount", String(group.옵션.length)), }), ...(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", ""); add.addEventListener("click", () => { let n = list.length + 1; while (list.some((g) => g.이름 === to("GroupNew", String(n)))) n += 1; list.push({ id: newGroupId(list), 이름: to("GroupNew", String(n)), 옵션: [] }); opt.changed(); }); return el("div", { className: "m02-sog", children: [ el("h4", { className: "m02-opts__group", text: to("GroupHead") }), el("p", { className: "m02-side__empty", text: to("GroupNote") }), ...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("GroupNone") })]), ...(opt.edit ? [el("div", { className: "m02-side__row", children: [add] })] : []), ], }); } /** 옵션 줄의 「들어갈 자리」 고르기 — 본체 · 그룹 이름들 */ export function buildPlaceSelect( list: GroupDef[], key: string, edit: boolean, changed: () => void, ): HTMLSelectElement { const select = el("select", { className: "ui-input ui-input--sm m02-sog__place", attrs: { "data-sog-place": key }, }) as HTMLSelectElement; select.append(new Option(to("Main"), ""), ...list.map((g) => new Option(g.이름 || g.id, g.id))); select.value = groupOf(list, key) ?? ""; select.disabled = !edit || !list.length; select.addEventListener("change", () => { moveOption(list, key, select.value || null); changed(); }); return select; }