Files
Aislo/A00_Common/spreadsheet/spreadsheet_eval_steps.ts
T
eomsangdonandClaude Opus 5.5 6acc8d0c52 feat(spreadsheet): 수식 분석 — 선행 · 종속 추적 화살표 · 수식 계산 단계 보기 · 오류 검사 ⚠ 풍선
- 추적 화살표(spreadsheet_trace.ts): 누를 때마다 한 단계 · 범위는 상자 · 오류 칸 화살 빨강 · 다른 시트 점선 · 지우기
- 수식 계산 창(spreadsheet_eval_steps.ts): 왼쪽부터 한 마디씩 값으로 · 밑줄 · IF 류 첫 인자만 먼저 · 값은 엔진 풀이
- 오류 검사(spreadsheet_errcheck.ts): 순환 · 식 오류 · 숫자처럼 보이는 글 까닭 · ⚠ 메뉴(숫자로 변환 · 계산 단계 · 선행 추적 · 오류 무시)
- 격자 초록 세모는 C(sub6) · 잇기는 sub7 — 머리 주석에 잇는 법
- 엔진: evalSafe 내보냄 · formulaToText 에 갈음 · 밑줄 자리 · 계약 파일 목록 줄만 더함
- 시험: test_spreadsheet_analysis.py(3 묶음) · harness_analysis(ORCA) · 시험 틀은 inspect 로 문맥 꺼냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 21:14:40 +09:00

262 lines
8.8 KiB
TypeScript

/* =============================================================================
* 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 { 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<string, { r: number; c: number }>();
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<FormulaNode, Scalar>();
const steps: FormulaNode[] = [];
for (const n of nodes) {
if (n === p.node) vals.set(n, engine.value(sheet, r, c));
else {
const v = evalSafe(n, ctx);
if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로
vals.set(n, v);
}
steps.push(n);
}
const done = new Map<FormulaNode, string>();
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;
}