- 추적 화살표(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
262 lines
8.8 KiB
TypeScript
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;
|
|
}
|