/* ============================================================================= * M02_DF_FormulaView.ts * 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 · * IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀 * 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address"; import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc"; import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text"; import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types"; import type { CfBox } from "./M02_DF_Model"; export type CellScalar = number | string | boolean | null; export interface FormulaBranch { when: string; then: string; /** 지금 타는 갈래 */ now: boolean; } export interface FormulaLine { /** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */ name: string; /** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */ named: string; /** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */ valued: string; /** IF 면 만약 / 아니면 줄 */ branches?: FormulaBranch[]; } const TEXT = { Title: ["식 보기", "Formulas"], Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"], Else: ["아니면", "Otherwise"], If: ["만약", "If"], Now: ["지금", "now"], NoValue: ["값 없음", "no value"], } as const; const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */ const REF = /(? string): string { return text .split(/("(?:[^"]|"")*")/) .map((p, i) => (i % 2 ? p : fn(p))) .join(""); } const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, ""); /** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */ export function cellNames(box: CfBox): Record { const f = box.func; const out: Record = {}; if (!f) return out; for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port; for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name; if (f.output.cell) out[upper(f.output.cell)] = f.output.name; return out; } const refsIn = (formula: string): string[] => { const found: string[] = []; outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m))); return found; }; const OPS: [RegExp, string][] = [ [/\*/g, " × "], [/\//g, " ÷ "], [/<>/g, " ≠ "], [/>=/g, " ≥ "], [/<=/g, " ≤ "], [/(?≠≥≤])=/g, " = "], [/\+/g, " + "], [/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "], ]; /** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */ function pretty(text: string): string { return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p)) .replace(/\s{2,}/g, " ") .trim(); } const fmt = (n: number): string => Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); /** 오류 글(#N/A …)은 값이 없다는 뜻 — 따옴표째 보이지 않음 */ const isError = (v: CellScalar | undefined): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v); function showScalar(v: CellScalar | undefined): string { if (isError(v)) return tv("NoValue"); if (v === undefined || v === null) return "0"; if (typeof v === "number") return fmt(v); if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; return `"${v}"`; } /** 이름으로 쓴 식 */ export function namedText(formula: string, names: Record): string { return pretty( outside(formula, (p) => p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`), ), ); } /** 값 넣은 식(값이 문자면 따옴표) */ export function valuedText(formula: string, values: Record): string { return pretty( outside(formula, (p) => p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])), ), ); } /* ── 갈래 ───────────────────────────────────────────────────────────────── */ const num = (v: CellScalar | undefined): number | null => { if (typeof v === "number") return v; if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); return null; }; /** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */ function holds(cond: string, values: Record): boolean | null { const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond); if (!m) return null; const read = (t: string): CellScalar | undefined => { const x = t.trim(); const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x); if (cell) return values[`${cell[1]}${cell[2]}`]; const q = /^"((?:[^"]|"")*)"$/.exec(x); if (q) return q[1].replace(/""/g, '"'); return Number.isFinite(Number(x)) ? Number(x) : undefined; }; const a = read(m[1]); const b = read(m[3]); if (a === undefined || b === undefined) return null; const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)]; switch (m[2]) { case "=": return l === r; case "<>": return l !== r; case ">=": return l >= r; case "<=": return l <= r; case ">": return l > r; default: return l < r; } } /** IF 사슬 → 갈래들 — IF 가 아니면 undefined */ function branchesOf( formula: string, names: Record, values: Record, ): FormulaBranch[] | undefined { const out: FormulaBranch[] = []; let rest = formula; let taken = false; for (;;) { const body = ifBody(rest); if (body === null) break; const a = splitArgs(body); if (a.length !== 3) return undefined; const ok = holds(a[0], values) === true && !taken; if (ok) taken = true; out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok }); rest = a[2]; } if (!out.length) return undefined; out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken }); return out; } /* ── 줄 ─────────────────────────────────────────────────────────────────── */ /** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */ export function formulaLines(box: CfBox, values: Record): FormulaLine[] { const f = box.func; if (!f?.output.cell) return []; const names = cellNames(box); const cells = f.sheet.칸; const order: string[] = []; const seen = new Set(); const visit = (a1: string): void => { const key = upper(a1); if (seen.has(key)) return; seen.add(key); const formula = cells[key]?.식; if (formula === undefined) return; for (const r of refsIn(formula)) visit(r); order.push(key); }; visit(f.output.cell); return order.map((key) => { const formula = cells[key].식 as string; const result = showScalar(values[key]); const branches = branchesOf(formula, names, values); return { name: names[key] ?? key, named: namedText(formula, names), valued: `${valuedText(formula, values)} = ${result}`, ...(branches ? { branches } : {}), }; }); } /** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */ export function cellValuesOf( box: CfBox, arrived: Record | undefined, ): Record { const f = box.func; if (!f) return {}; const sheet = structuredClone(f.sheet); const scalar = (v: unknown): number | string | boolean | undefined => typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined; const put = (cell: string, v: number | string | boolean | undefined): void => { if (!parseA1(cell) || v === undefined) return; sheet.칸[upper(cell)] = { 값: v }; }; for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port])); for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example); const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] }); const out: Record = {}; for (const [key, cell] of Object.entries(sheet.칸)) { const c: CalcValue | undefined = calc[sheet.id]?.[key]; out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null; } return out; } function fromCalc(v: CalcValue): CellScalar { if ("수" in v) return Number(v.수); if ("글" in v) return v.글; if ("참" in v) return v.참; return v.오류; } /* ── 그리기 ─────────────────────────────────────────────────────────────── */ /** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */ export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void { host.classList.add("m02-df-formula"); host.replaceChildren( el("strong", { className: "m02-df-formula__title", text: tv("Title") }), ...(lines.length ? lines.map((l) => el("div", { className: "m02-df-formula__line", children: [ el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }), el("div", { className: "m02-df-formula__valued", text: l.valued }), ...(l.branches ? [ el("ul", { className: "m02-df-formula__branches", children: l.branches.map((b, i) => el("li", { className: b.now ? "is-now" : "", text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`, }), ), }), ] : []), ], }), ) : [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]), ); }