/* ============================================================================= * M01_LB_Source.ts * 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절) * 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과) * · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처. * 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { tableUsers, type LbTables } from "./M01_LB_Api"; import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks"; import type { LbHit } from "./M01_LB_Check"; import "./M01_LB_Source.css"; const TEXT = { Preview: [ "표 고침은 미리 보기 — 저장은 결정 뒤", "Table edits are a preview — saving comes later", ], Users: ["사용처", "Used by"], UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"], UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"], UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"], Loading: ["불러오는 중", "Loading"], Missing: ["표를 불러오지 못함", "Couldn't load the table"], NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"], DupRow: [ "중복 — 예시값에 맞는 줄이 둘 이상", "Duplicate — more than one row fits the sample values", ], Notes: ["주", "Notes"], NoteUsed: ["계산에 쓰임", "Used in the calculation"], Cond: ["조건", "Condition"], Fixed: ["고정", "Fixed"], Result: ["결과", "Result"], Free: ["무관", "Any"], RoleHint: ["눌러 역할 바꿈", "Tap to change the role"], EditHint: [ "칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소", "Tap a cell to edit · Enter to finish · Esc to cancel", ], Edited: ["고친 칸", "Edited cell"], } as const; const ts = (key: keyof typeof TEXT, n?: number): string => TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? "")); export type LbRole = "조건" | "고정" | "결과"; type Cell = string | number | boolean | null | [number | null, number | null] | undefined; interface SourceTable { 키?: string; 이름?: string; 원문번호?: string; 기준?: string; 범위규칙?: string; 조건?: Record; 값칸?: Record; 주?: string[]; 용도?: { 로직키?: string[] }; 줄: Record[]; } export interface SourceOn { edit(e: TableEdit): void; role(table: string, col: string, role: LbRole): void; } /* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */ const isRange = (v: unknown): v is [number | null, number | null] => Array.isArray(v) && v.length === 2; /** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */ export function showCell(v: Cell): string { if (v === null || v === undefined) return ""; if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; return String(v); } /** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */ export function parseCell(text: string, before: Cell): TableEdit["value"] | null { const t = text.trim(); const num = (s: string): number | null | undefined => { const c = s.replace(/,/g, "").trim(); if (!c) return null; return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined; }; if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) { const parts = t.split(/[~~]/); if (parts.length !== 2) return null; const [a, b] = parts.map(num); if (a === undefined || b === undefined || (a === null && b === null)) return null; if (a !== null && b !== null && a > b) return null; return [a, b]; } if (!t) return null; const n = num(t); if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) { return typeof n === "number" ? n : null; } return t; } /* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */ /** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */ export function roleOf(lookups: LbLookup[], col: string): LbRole | null { for (const lk of lookups) { if (lk.col === col) return "결과"; const c = lk.conds.find((x) => x.col === col); if (c) return c.from.k === "fixed" ? "고정" : "조건"; } return null; } const NEXT: Record = { 조건: "고정", 고정: "결과", 결과: "조건" }; /** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */ export function usedNotes(row: unknown): Map> { const out = new Map>(); const add = (where: string, mark: string): void => { const set = out.get(where) ?? new Set(); set.add(mark); out.set(where, set); }; const walk = (v: unknown): void => { if (typeof v === "string") { for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]); } else if (Array.isArray(v)) v.forEach(walk); else if (v && typeof v === "object") Object.values(v).forEach(walk); }; walk(row); return out; } /** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */ function hitsOf( key: string, lookups: LbLookup[], hits: Record, ): { rows: Set; none: boolean; dup: boolean } { const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null); return { rows: new Set(got.filter((h): h is number => typeof h === "number")), none: got.includes("없음"), dup: got.includes("중복"), }; } /* ── 그리기 ─────────────────────────────────────────────────────── */ /** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */ const originals = new Map(); function snapshot(key: string, table: SourceTable, cols: string[]): string[][] { const had = originals.get(key); if (had && had.length === table.줄.length) return had; const shot = table.줄.map((r) => cols.map((c) => showCell(r[c]))); originals.set(key, shot); return shot; } function roleChip( key: string, col: string, role: LbRole | null, isCond: boolean, on: SourceOn, ): HTMLElement { const shown = role ?? (isCond ? null : undefined); if (shown === undefined) return el("span"); const label = shown ? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result") : ts("Free"); const chip = el("button", { className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`, text: label, attrs: { type: "button", title: ts("RoleHint") }, }); chip.addEventListener("click", (ev) => { ev.stopPropagation(); on.role(key, col, shown ? NEXT[shown] : "조건"); }); return chip; } function editCell( td: HTMLTableCellElement, before: Cell, done: (v: TableEdit["value"]) => void, ): void { if (td.querySelector("input")) return; const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } }); input.value = showCell(before); const text = td.textContent ?? ""; td.replaceChildren(input); input.focus(); input.select(); let closed = false; const close = (save: boolean): void => { if (closed) return; closed = true; const value = save ? parseCell(input.value, before) : null; const same = value !== null && showCell(value) === showCell(before); td.textContent = value === null || same ? text : showCell(value); td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text); if (value !== null && !same) done(value); }; input.addEventListener("keydown", (ev) => { if (ev.key === "Enter") close(true); if (ev.key === "Escape") close(false); ev.stopPropagation(); }); input.addEventListener("blur", () => close(true)); } function renderTable( key: string, table: SourceTable | undefined, lb: LbLogic, hits: Record, notes: Map>, on: SourceOn, ): HTMLElement { const lookups = lb.lookups.filter((lk) => lk.table === key); if (!table?.줄) { return el("section", { className: "m01-lbsrc__table", children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })], }); } const conds = Object.keys(table.조건 ?? {}); const vals = Object.keys(table.값칸 ?? {}); const cols = [...conds, ...vals]; const orig = snapshot(key, table, cols); const hit = hitsOf(key, lookups, hits); const users = (table.용도?.로직키 ?? []).length; const userBtn = el("button", { className: "m01-lbsrc__users", text: users ? ts("UsersN", users) : ts("UsersNone"), attrs: { type: "button" }, }); const userList = el("p", { className: "m01-lbsrc__muted" }); userBtn.addEventListener("click", () => { userList.textContent = ts("Loading"); tableUsers(key) .then((list) => { userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone"); userList.textContent = list.map((u) => u.이름).join(" · "); }) .catch(() => (userList.textContent = ts("UsersFail"))); }); const head = el("tr", { children: [ el("th", { className: "m01-lbsrc__no", text: "" }), ...cols.map((c) => { const unit = table.값칸?.[c]; return el("th", { className: vals.includes(c) ? "is-value" : "", children: [ el("span", { text: unit ? `${c} (${unit})` : c }), roleChip(key, c, roleOf(lookups, c), conds.includes(c), on), ], }); }), ], }); const body = table.줄.map((row, i) => { const tr = el("tr", { className: hit.rows.has(i) ? "is-hit" : "", children: [ el("td", { className: "m01-lbsrc__no", text: String(i + 1) }), ...cols.map((c, j) => { const td = el("td", { className: [ vals.includes(c) ? "is-value" : "", lookups.some((lk) => lk.col === c) ? "is-col" : "", showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "", ].join(" "), text: showCell(row[c]), }); td.addEventListener("click", () => editCell(td, row[c], (value) => { td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]); on.edit({ table: key, row: i, col: c, value }); }), ); return td; }), ], }); return tr; }); const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]); const noteList = (table.주 ?? []).map((n) => { const used = marks.has(n.trim().charAt(0)); return el("li", { className: used ? "is-used" : "", children: [ el("span", { text: n }), ...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []), ], }); }); return el("section", { className: "m01-lbsrc__table", children: [ el("div", { className: "m01-lbsrc__head", children: [ el("strong", { text: table.이름 ?? "" }), el("span", { className: "m01-lbsrc__muted", text: [table.원문번호, table.기준].filter(Boolean).join(" · "), }), userBtn, ], }), userList, ...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []), ...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []), el("div", { className: "m01-lbsrc__grid", children: [ el("table", { children: [el("thead", { children: [head] }), el("tbody", { children: body })], }), ], }), ...(noteList.length ? [ el("details", { className: "m01-lbsrc__notes", attrs: marks.size ? { open: "" } : {}, children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })], }), ] : []), ], }); } /** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */ export function renderSource( host: HTMLElement, lb: LbLogic, tables: LbTables, hits: Record, on: SourceOn, ): void { const keys = [...new Set(lb.lookups.map((lk) => lk.table))]; const notes = usedNotes(lb.doc?.row); host.replaceChildren( el("div", { className: "m01-lbsrc", children: [ el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }), el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }), ...keys.map((k) => renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on), ), ], }), ); }