Files
Aislo/A00_Common/spreadsheet/spreadsheet_graph.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

363 lines
12 KiB
TypeScript

/* =============================================================================
* spreadsheet_graph.ts (주인 A)
* 계산 엔진 — 식마다 가리키는 칸 · 범위로 의존 그래프 · 바뀐 칸에 딸린 칸만 차례로 다시 풂.
* 순환은 고리의 칸마다 `#CYCLE!`(까닭 = 고리 칸 목록) · 반복 계산 안 함 · 나머지 칸은 계속 풂
* (고리를 읽는 칸은 오류 전파로 `#CYCLE!`). 식 나무는 식 글마다 캐시.
* 풀이는 필요할 때 끌어 풂(앞 칸이 안 풀렸으면 먼저) — 차례는 저절로 위상 순서.
* ========================================================================== */
import { FormulaError, ZERO } from "@ui/sheet/ui_template_sheet_frac";
import { cellId, fromCellId, inRange, parseA1, toA1 } from "./spreadsheet_address";
import { err, evaluate, inputValue, intersect, toCalcValue } from "./spreadsheet_eval";
import type { PlacedRange } from "./spreadsheet_eval";
import { parseFormula } from "./spreadsheet_parser";
import type {
CalcEngine,
CalcValues,
Cell,
CellRange,
EvalContext,
EvalResult,
FormulaNode,
ParseResult,
Scalar,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
interface SheetIndex {
id: string;
cells: Map<number, Cell>;
/** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */
maxR: number;
maxC: number;
}
interface Dep {
sheet: string;
range: CellRange;
}
const DONE = 2;
const ACTIVE = 1;
const keyOf = (sheet: string, id: number) => `${sheet}!${id}`;
function splitKey(key: string): SheetCellAddress {
const i = key.lastIndexOf("!");
return { 시트: key.slice(0, i), ...fromCellId(Number(key.slice(i + 1))) };
}
/** 나무 안 칸 · 범위 참조(시트 이름 → id · 없는 시트는 뺌) */
function collectRefs(
node: FormulaNode,
host: string,
sheetId: (n: string) => string | null,
out: Dep[],
) {
switch (node.type) {
case "cell":
case "range": {
const sheet = node.sheet === null ? host : sheetId(node.sheet);
if (sheet === null) return;
const [a, b] = node.type === "cell" ? [node.ref, node.ref] : [node.from, node.to];
out.push({
sheet,
range: {
r0: Math.min(a.r, b.r),
c0: Math.min(a.c, b.c),
r1: Math.max(a.r, b.r),
c1: Math.max(a.c, b.c),
},
});
return;
}
case "unary":
return collectRefs(node.arg, host, sheetId, out);
case "binary":
collectRefs(node.left, host, sheetId, out);
return collectRefs(node.right, host, sheetId, out);
case "call":
for (const a of node.args) collectRefs(a, host, sheetId, out);
}
}
/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */
export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult {
try {
return evaluate(node, ctx);
} catch (e) {
if (e instanceof FormulaError && e.message.startsWith("0 으로"))
return err("#DIV/0!", e.message);
return err("#VALUE!", e instanceof Error ? e.message : String(e));
}
}
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
export function createCalcEngine(book: Workbook): CalcEngine {
const parsed = new Map<string, ParseResult>();
let sheets = new Map<string, SheetIndex>();
let names = new Map<string, string>();
/** 식 칸 → 가리키는 칸 · 범위 */
let precs = new Map<string, Dep[]>();
/** 칸 하나 → 그 칸을 가리키는 식 칸 */
let cellDeps = new Map<string, Set<string>>();
/** 시트 → 범위를 가리키는 식 칸 */
let rangeDeps = new Map<string, Map<string, CellRange[]>>();
let vals = new Map<string, Scalar>();
let state = new Map<string, number>();
const stack: string[] = [];
const cycle = new Map<string, string>();
const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null;
const parse = (text: string) => {
let p = parsed.get(text);
if (!p) parsed.set(text, (p = parseFormula(text)));
return p;
};
function unlink(key: string) {
for (const d of precs.get(key) ?? []) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1)
cellDeps.get(keyOf(d.sheet, cellId(d.range.r0, d.range.c0)))?.delete(key);
else rangeDeps.get(d.sheet)?.delete(key);
}
precs.delete(key);
}
/** 칸 하나를 색인에 올림(식이면 가리키는 곳 잇기) */
function index(sheet: SheetIndex, id: number, cell: Cell | undefined) {
const key = keyOf(sheet.id, id);
unlink(key);
vals.delete(key);
state.delete(key);
cycle.delete(key);
if (!cell || (cell.식 === undefined && cell.값 === undefined)) {
sheet.cells.delete(id);
return;
}
sheet.cells.set(id, cell);
const { r, c } = fromCellId(id);
sheet.maxR = Math.max(sheet.maxR, r);
sheet.maxC = Math.max(sheet.maxC, c);
if (cell.식 === undefined) {
vals.set(key, inputValue(cell.값!));
return;
}
const p = parse(cell.식);
const deps: Dep[] = [];
if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps);
precs.set(key, deps);
for (const d of deps) {
if (d.range.r0 === d.range.r1 && d.range.c0 === d.range.c1) {
const k = keyOf(d.sheet, cellId(d.range.r0, d.range.c0));
let set = cellDeps.get(k);
if (!set) cellDeps.set(k, (set = new Set()));
set.add(key);
} else {
let m = rangeDeps.get(d.sheet);
if (!m) rangeDeps.set(d.sheet, (m = new Map()));
let list = m.get(key);
if (!list) m.set(key, (list = []));
list.push(d.range);
}
}
}
function rebuild(next: Workbook) {
book = next;
sheets = new Map();
names = new Map();
precs = new Map();
cellDeps = new Map();
rangeDeps = new Map();
vals = new Map();
state = new Map();
cycle.clear();
for (const s of book.시트) {
sheets.set(s.id, { id: s.id, cells: new Map(), maxR: 0, maxC: 0 });
names.set(s.이름.toLowerCase(), s.id);
}
for (const s of book.시트) {
const idx = sheets.get(s.id)!;
for (const [a1, cell] of Object.entries(s.칸)) {
const at = parseA1(a1);
if (at) index(idx, cellId(at.r, at.c), cell);
}
}
for (const key of precs.keys()) ensure(key);
}
function ctxFor(sheet: string, r: number, c: number): EvalContext {
return { sheet, r, c, cell: read, range, sheetId };
}
function read(sheet: string, r: number, c: number): Scalar {
const key = keyOf(sheet, cellId(r, c));
if (precs.has(key)) return compute(key);
return vals.get(key) ?? null;
}
function range(sheet: string, rg: CellRange): PlacedRange {
const s = sheets.get(sheet);
// ponytail: 행 · 열 전체 범위는 값 든 끝까지만 — INDEX(A:A, 끝 너머)는 #REF! 로 갈림
const r1 = Math.min(rg.r1, Math.max(rg.r0, s?.maxR ?? 0));
const c1 = Math.min(rg.c1, Math.max(rg.c0, s?.maxC ?? 0));
return {
kind: "range",
rows: r1 - rg.r0 + 1,
cols: c1 - rg.c0 + 1,
at: (i, j) => read(sheet, rg.r0 + i, rg.c0 + j),
...rg,
};
}
/** 가리키는 식 칸들(범위는 넓이 · 든 칸 수 중 작은 쪽으로 훑음) */
function formulaPrecs(key: string): string[] {
const out: string[] = [];
for (const d of precs.get(key) ?? []) {
const s = sheets.get(d.sheet);
if (!s) continue;
const r1 = Math.min(d.range.r1, s.maxR);
const c1 = Math.min(d.range.c1, s.maxC);
if ((r1 - d.range.r0 + 1) * (c1 - d.range.c0 + 1) <= s.cells.size) {
for (let r = d.range.r0; r <= r1; r++)
for (let c = d.range.c0; c <= c1; c++) {
const k = keyOf(d.sheet, cellId(r, c));
if (precs.has(k)) out.push(k);
}
} else
for (const id of s.cells.keys()) {
const k = keyOf(d.sheet, id);
const { r, c } = fromCellId(id);
if (precs.has(k) && inRange(d.range, r, c)) out.push(k);
}
}
return out;
}
/** 앞 식 칸부터 반복으로 풂 — 깊은 사슬에도 스택이 안 넘침 · 고리는 compute 가 잡음 */
function ensure(key: string): Scalar {
const todo: [string, boolean][] = [[key, false]];
const seen = new Set<string>();
while (todo.length) {
const [k, ready] = todo.pop()!;
if (state.get(k) === DONE) continue;
if (ready) {
compute(k);
continue;
}
if (seen.has(k)) continue;
seen.add(k);
todo.push([k, true]);
for (const d of formulaPrecs(k))
if (!seen.has(d) && state.get(d) !== DONE) todo.push([d, false]);
}
return vals.get(key) ?? null;
}
/** 식 칸 하나 풂 — 풀이 중인 칸을 다시 만나면 그 사이가 고리 */
function compute(key: string): Scalar {
const st = state.get(key);
if (st === DONE) return vals.get(key) ?? null;
if (st === ACTIVE) {
const ring = stack.slice(stack.indexOf(key));
const why = `순환 참조: ${ring.map(label).join(" → ")} → ${label(key)}`;
for (const k of ring) if (!cycle.has(k)) cycle.set(k, why);
return err("#CYCLE!", why);
}
state.set(key, ACTIVE);
stack.push(key);
const { 시트, r, c } = splitKey(key);
const cell = sheets.get(시트)!.cells.get(cellId(r, c))!;
let v: Scalar;
try {
v = run(cell.식!, ctxFor(시트, r, c));
} catch (e) {
state.delete(key);
throw e;
} finally {
stack.pop();
}
const why = cycle.get(key);
if (why) v = err("#CYCLE!", why);
vals.set(key, v);
state.set(key, DONE);
return v;
}
function label(key: string) {
const { 시트, r, c } = splitKey(key);
const s = book.시트.find((x) => x.id === 시트);
return book.시트.length > 1 && s ? `${s.이름}!${toA1(r, c)}` : toA1(r, c);
}
function run(text: string, ctx: EvalContext): Scalar {
const p = parse(text);
if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`);
const one = intersect(evalSafe(p.node, ctx), ctx);
return one === null ? ZERO : one;
}
function dependents(key: string, out: Set<string>) {
for (const k of cellDeps.get(key) ?? []) out.add(k);
const { 시트, r, c } = splitKey(key);
for (const [k, list] of rangeDeps.get(시트) ?? [])
if (list.some((rg) => inRange(rg, r, c))) out.add(k);
}
rebuild(book);
return {
update(changed) {
const dirty = new Set<string>();
for (const a of changed) {
const s = sheets.get(a.시트);
const sheet = book.시트.find((x) => x.id === a.시트);
if (!s || !sheet) continue;
const id = cellId(a.r, a.c);
index(s, id, sheet.칸[toA1(a.r, a.c)]);
dirty.add(keyOf(a.시트, id));
}
// 딸린 칸을 끝까지 모음(고리였던 칸도 다시)
const queue = [...dirty];
while (queue.length) {
const next = new Set<string>();
dependents(queue.pop()!, next);
for (const k of next)
if (!dirty.has(k)) {
dirty.add(k);
queue.push(k);
}
}
for (const k of dirty)
if (precs.has(k)) {
state.delete(k);
cycle.delete(k);
}
for (const k of dirty) if (precs.has(k)) ensure(k);
return [...dirty].map(splitKey);
},
rebuild,
value(sheet, r, c) {
const key = keyOf(sheet, cellId(r, c));
return precs.has(key) ? ensure(key) : (vals.get(key) ?? null);
},
precedents(sheet, r, c) {
return (precs.get(keyOf(sheet, cellId(r, c))) ?? []).map((d) => ({
시트: d.sheet,
범위: d.range,
}));
},
snapshot() {
const out: CalcValues = {};
for (const key of precs.keys()) {
const { 시트, r, c } = splitKey(key);
(out[시트] ??= {})[toA1(r, c)] = toCalcValue(ensure(key));
}
return out;
},
};
}