/* ============================================================================= * M01_MasterData_UI_Logic_Note.ts * 설명 카드 — 식 하나를 쉬운 말로 풀고, 그 식이 기대는 원문 표를 미리 보임(걸린 줄 강조). * * 순수 읽기 — `/elements` 로 표 파일을 찾고 `/table` 로 통째 읽어 그 자리에서 줄을 맞힘. * 엔진을 다시 돌리지 않음(수량·금액은 `/calc` 답이 줌) — 여기서는 「어느 줄이 걸렸나」만 보임. * 흐름 그림 상자(`…_Logic_Flow.ts`)와 테스트 방식 B 가 같이 씀. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; /* ── 원문 표(소요량·계수) 읽기 ─────────────────────────────────────────── */ export interface TableRow { file: string; 키: string; 이름?: string; 원문번호?: string; 출처?: string; 용도?: { 공종?: string; 대상?: string[]; 로직키?: string[] }; 조건?: Record; 값칸?: Record; 줄?: Record[]; } const tableCache = new Map>(); async function getJson(path: string): Promise { try { const res = await fetch(`${API_BASE_URL}/m01${path}`); if (!res.ok) return null; return (await res.json()) as T; } catch { return null; } } export function loadTable(key: string): Promise { const cached = tableCache.get(key); if (cached) return cached; const job = (async (): Promise => { for (const group of ["소요량", "계수"]) { const found = await getJson<{ items: ElementBrief[] }>( `/elements?group=${encodeURIComponent(group)}&q=${encodeURIComponent(key)}&limit=5`, ); const file = found?.items?.find((i) => i.ref === key)?.file; if (!file) continue; const got = await getJson<{ table: TableRow }>( `/table?file=${encodeURIComponent(file)}&key=${encodeURIComponent(key)}`, ); if (got?.table) return { ...got.table, file }; } return null; })(); tableCache.set(key, job); return job; } /* ── 식 속 찾기(표, 조건...).칸 뽑기 ────────────────────────────────────── */ export interface FindCall { table: string; conds: [string, string][]; col: string; } const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g; function splitConds(raw: string): [string, string][] { return raw .split(",") .map((p) => p.trim()) .filter(Boolean) .map((p): [string, string] => { const i = p.indexOf("="); return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }); } function findCalls(expr: string): FindCall[] { const out: FindCall[] = []; for (const m of expr.matchAll(FIND_RE)) { out.push({ table: m[1], conds: splitConds(m[2]), col: m[3] }); } return out; } /** 식 안에 이름이 낱말로 나오는지 — 한글엔 \b 가 안 먹어 앞뒤 글자를 직접 봄 */ function mentions(expr: string, name: string): boolean { if (!name) return false; const idx = expr.indexOf(name); if (idx < 0) return false; const isWord = (c: string | undefined) => !!c && /[A-Za-z0-9_가-힣]/.test(c); return !isWord(expr[idx - 1]) && !isWord(expr[idx + name.length]); } export interface RelatedFind { /** 「이 줄」 또는 물려 쓰는 중간 값 이름 */ source: string; find: FindCall; } /** 이 줄 식이 기대는 찾기(...) — 자기 식 + (한 단계) 물려 쓰는 중간 식 */ export function relatedFinds(expr: string, middles: NamedFormula[]): RelatedFind[] { const out: RelatedFind[] = findCalls(expr).map((find) => ({ source: tx("Note_ThisLine"), find, })); for (const m of middles) { if (!mentions(expr, m.이름)) continue; for (const find of findCalls(m.식)) out.push({ source: m.이름, find }); } return out; } /* ── 조건 값 풀기 · 표 줄 맞히기 ───────────────────────────────────────── */ export interface EvalCtx { values: Record; middle: Record; } function resolveIdent(name: string, ctx: EvalCtx): string | number | undefined { const quoted = name.match(/^["'](.*)["']$/); if (quoted) return quoted[1]; if (/^-?\d+(\.\d+)?$/.test(name)) return Number(name); if (ctx.values[name] !== undefined && ctx.values[name] !== "") return ctx.values[name]; if (ctx.middle[name] !== undefined) return ctx.middle[name] as number; return undefined; } function sameValue(a: unknown, b: unknown): boolean { if (a === undefined || a === null || b === undefined) return false; const na = Number(a); const nb = Number(b); if (!Number.isNaN(na) && !Number.isNaN(nb)) return na === nb; return String(a) === String(b); } /** 지금 조건 값으로 찾기() 가 고를 줄 — 못 맞히면 null(값을 아직 안 골랐거나 후보 여럿) */ export function matchRow( table: TableRow, conds: [string, string][], ctx: EvalCtx, ): Record | null { for (const row of table.줄 ?? []) { if (conds.every(([col, rhs]) => sameValue(row[col], resolveIdent(rhs, ctx)))) return row; } return null; } /* ── 쉬운 말 풀이 — 찾기()/만약() 을 문장으로, 안 되면 식 그대로 ─────────── */ function splitTop(s: string): string[] { const out: string[] = []; let depth = 0; let cur = ""; for (const ch of s) { if (ch === "(") depth++; if (ch === ")") depth--; if (ch === "," && depth === 0) { out.push(cur); cur = ""; } else cur += ch; } out.push(cur); return out; } function expandIf(expr: string): string { const at = expr.indexOf("만약("); if (at < 0) return expr; let depth = 0; let j = at + 2; // "만약(" 의 '(' 자리 for (; j < expr.length; j++) { if (expr[j] === "(") depth++; else if (expr[j] === ")") { depth--; if (depth === 0) { j++; break; } } } const [cond, then, els] = splitTop(expr.slice(at + 3, j - 1)).map((p) => p.trim()); const said = `(${expandIf(cond)} 이면 ${expandIf(then)}, 아니면 ${expandIf(els ?? "")})`; return expandIf(expr.slice(0, at) + said + expr.slice(j)); } export function explain(expr: string): string { const withFind = expr.replace(FIND_RE, (_all, table: string, conds: string, col: string) => { const cs = splitConds(conds) .map(([, rhs]) => rhs) .join(" · "); return `[${table} 표에서 ${cs} 에 맞는 「${col}」]`; }); return expandIf(withFind).trim() || expr; } /* ── 카드 그리기 ──────────────────────────────────────────────────────── */ export interface NoteOptions { /** 풀어 볼 식 — 호표 줄의 수량 · 중간 값·덧줄의 식 */ expr: string; /** 물려 쓰는 중간 값(한 단계) */ middles: NamedFormula[]; ctx: EvalCtx; } /** 표 하나를 그림 — 지금 값으로 걸린 줄은 강조 */ function tableView(find: RelatedFind, table: TableRow, ctx: EvalCtx): HTMLElement[] { const hit = matchRow(table, find.find.conds, ctx); const cols = [...Object.keys(table.조건 ?? {}), ...Object.keys(table.값칸 ?? {})]; const body = (table.줄 ?? []).map((line) => el("tr", { className: hit === line ? "m01c__hit" : "", children: cols.map((c) => el("td", { text: formatNumber(line[c]) })), }), ); const use = table.용도 ? [table.용도.공종, (table.용도.대상 ?? []).join("·")].filter(Boolean).join(" · ") : ""; return [ el("p", { className: "m01c__muted", text: `${find.source} · ${table.원문번호 ?? table.키} ${table.이름 ?? ""} (${table.출처 ?? ""})`, }), ...(use ? [el("p", { className: "m01c__muted", text: `${tx("Use_Of")} — ${use}` })] : []), el("table", { className: "m01c__table", children: [ el("thead", { children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })] }), el("tbody", { children: body }), ], }), ]; } /** * 설명 카드를 `host` 에 채움 — 쉬운 말 풀이 · 원문 표 미리보기 · 고급(식 그대로). * 표는 서버에서 읽어 오므로 먼저 「찾는 중」을 보이고 온 뒤 갈아 끼움. */ export function buildNote(host: HTMLElement, opts: NoteOptions): void { const related = relatedFinds(opts.expr, opts.middles); const advanced = el("details", { className: "m01c__raw", children: [el("summary", { text: tx("Note_Formula") }), el("pre", { text: opts.expr })], }); const plain = explain(opts.expr); const head: HTMLElement[] = [ el("p", { className: "m01c__muted", text: tx("Note_Plain") }), el("p", { className: "m01c__plain", text: plain }), ]; if (!related.length) { host.replaceChildren(...head, advanced); return; } const tables = el("div", { className: "m01c__muted", text: `${tx("Note_Searching")}…` }); host.replaceChildren( ...head, el("p", { className: "m01c__muted", text: tx("Note_Table") }), tables, advanced, ); void Promise.all(related.map(async (r) => ({ r, table: await loadTable(r.find.table) }))).then( (got) => { tables.className = "m01c__tables"; tables.replaceChildren( ...got.flatMap(({ r, table }) => table ? tableView(r, table, opts.ctx) : [ el("p", { className: "m01c__muted", text: tx("Note_TableMissing", { v: r.find.table }), }), ], ), ); }, ); }