/* ============================================================================= * spreadsheet_eval_steps.ts (주인 A) * 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌. * 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 · * IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로. * 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만. * * 잇는 법(D · sub7): * · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌. * · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { isError, isRange, numberText } from "./spreadsheet_eval"; import type { PlacedRange } from "./spreadsheet_eval"; import { bindNames, evalSafe } from "./spreadsheet_graph"; import { formulaToText, parseFormula } from "./spreadsheet_parser"; import type { CalcEngine, CellRange, EvalContext, FormulaNode, Scalar, Workbook, } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; import "./spreadsheet_analysis.css"; /** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */ export interface EvalStep { 글: string; 밑줄: [number, number] | null; } /** 첫 인자만 먼저 풂 */ const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]); /** 칸 참조 인자를 값으로 안 바꿈 */ const REF_ARGS = new Set([ "ROW", "COLUMN", "ROWS", "COLUMNS", "OFFSET", "INDEX", "ISREF", "ISBLANK", "CELL", "ISFORMULA", "FORMULATEXT", "SUMIF", "SUMIFS", "COUNTIF", "COUNTIFS", "AVERAGEIF", "AVERAGEIFS", "COUNTBLANK", ]); /** 푸는 차례(왼쪽 먼저 · 아래 먼저) */ function order(node: FormulaNode, out: FormulaNode[], refArg = false): void { switch (node.type) { case "cell": if (!refArg) out.push(node); return; case "name": out.push(node); return; case "unary": if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로 order(node.arg, out); out.push(node); return; case "binary": order(node.left, out); order(node.right, out); out.push(node); return; case "call": { const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args; for (const a of args) order(a, out, REF_ARGS.has(node.name)); out.push(node); } } } export function valueText(v: Scalar): string { if (v === null) return "0"; if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`; if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; if (isError(v)) return v.error; return numberText(v); } /** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */ function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) { const ext = new Map(); const extent = (id: string) => { let e = ext.get(id); if (!e) { e = { r: 0, c: 0 }; for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) { const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null; if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) }; } ext.set(id, e); } return e; }; const ctx: EvalContext = { sheet, r, c, cell: (s, rr, cc) => engine.value(s, rr, cc), range(s, rg: CellRange): PlacedRange { const e = extent(s); const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r)); const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c)); return { kind: "range", rows: r1 - rg.r0 + 1, cols: c1 - rg.c0 + 1, at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j), ...rg, }; }, sheetId: (name) => book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null, }; return ctx; } /** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */ export function evalSteps( book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number, ): EvalStep[] | null { const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식; if (text === undefined) return null; const p = parseFormula(text); if (!p.ok) return [ { 글: text, 밑줄: null }, { 글: valueText(engine.value(sheet, r, c)), 밑줄: null }, ]; const ctx = contextFor(book, engine, sheet, r, c); const nodes: FormulaNode[] = []; order(p.node, nodes); // 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차) if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node); const vals = new Map(); const steps: FormulaNode[] = []; for (const n of nodes) { if (n === p.node) vals.set(n, engine.value(sheet, r, c)); else { const v = evalSafe(bindNames(n, book, sheet), ctx); if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로 vals.set(n, v); } steps.push(n); } const done = new Map(); const out: EvalStep[] = []; for (let i = 0; i <= steps.length; i++) { const raw = formulaToText(p.node, (n) => done.get(n), steps[i]); const a = raw.indexOf("\u0001"); const b = raw.indexOf("\u0002"); out.push( a < 0 ? { 글: raw, 밑줄: null } : { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] }, ); if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null)); } return out; } // ── 창 ────────────────────────────────────────────────────────────────────── const TEXT = { Title: ["수식 계산", "Evaluate Formula"], Ref: ["참조", "Reference"], Hint: [ "밑줄 친 식의 값을 보려면 [계산] 을 누름", "To show the result of the underlined expression, click Evaluate.", ], Eval: ["계산", "Evaluate"], Restart: ["다시 시작", "Restart"], Close: ["닫기", "Close"], NoFormula: ["식이 없는 칸", "The cell has no formula"], }; const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0]; export function openEvalSteps(ctx: SpreadsheetContext): () => void { const sheet = ctx.sheet(); const { r, c } = ctx.selection.활성; const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c); if (!steps) { showToast(t("NoFormula"), "info"); return () => {}; } let i = 0; const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } }); const next = createButton({ label: t("Eval"), onClick: () => go(i + 1 < steps.length ? i + 1 : 0), }); const box = el("div", { className: "ss-steps__box", attrs: { role: "dialog", "aria-label": t("Title") }, }); const shade = el("div", { className: "ss-steps", children: [box] }); function go(to: number) { i = to; const s = steps![i]; if (!s.밑줄) view.replaceChildren(s.글); else { const [a, b] = s.밑줄; view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b)); } next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart"); } const close = () => { shade.remove(); document.removeEventListener("keydown", onKey, true); }; const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") close(); else if (ev.key === "Enter") next.click(); else return; ev.preventDefault(); ev.stopPropagation(); }; box.append( el("div", { className: "ss-steps__title", text: t("Title") }), el("div", { className: "ss-steps__meta", text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`, }), view, el("div", { className: "ss-steps__meta", text: t("Hint") }), el("div", { className: "ss-steps__foot", children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })], }), ); shade.addEventListener("mousedown", (ev) => { if (ev.target === shade) close(); }); document.addEventListener("keydown", onKey, true); go(0); ctx.root.append(shade); next.focus(); return close; }