- 엔진: 식 나무의 이름을 Sheet.이름정의 범위로 바꿔 풂(자기 시트 먼저 · 없으면 다른 시트 · 없는 이름 #NAME?) · 의존 그래프도 범위로 이어짐 - 식 캐시는 시트마다 · rebuild 때 비움 — 이름 바꾸기 · 지우기(시트통째) 뒤 새로 풂 - 행열 넣기 · 지우기: 병합과 같은 범위 옮김으로 이름 범위도 옮김 · 통째로 지워진 이름은 뺌 - 수식 계산 걸음도 이름을 풂 - 시험: test_spreadsheet_analysis.py names 묶음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
400 lines
13 KiB
TypeScript
400 lines
13 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,
|
|
Sheet,
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */
|
|
export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode {
|
|
const go = (n: FormulaNode): FormulaNode => {
|
|
switch (n.type) {
|
|
case "name": {
|
|
const low = n.name.toLowerCase();
|
|
const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low);
|
|
const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def);
|
|
if (!s) return n;
|
|
const g = def(s)!.범위;
|
|
const sheet = s.id === host ? null : s.이름;
|
|
const from = { r: g.r0, c: g.c0, absR: true, absC: true };
|
|
const to = { r: g.r1, c: g.c1, absR: true, absC: true };
|
|
return g.r0 === g.r1 && g.c0 === g.c1
|
|
? { type: "cell", sheet, ref: from }
|
|
: { type: "range", sheet, from, to };
|
|
}
|
|
case "unary":
|
|
return { ...n, arg: go(n.arg) };
|
|
case "binary":
|
|
return { ...n, left: go(n.left), right: go(n.right) };
|
|
case "call":
|
|
return { ...n, args: n.args.map(go) };
|
|
}
|
|
return n;
|
|
};
|
|
return book.시트.some((s) => s.이름정의?.length) ? go(node) : node;
|
|
}
|
|
|
|
/** 나무 하나 풂 —던진 오류는 오류 값으로(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;
|
|
|
|
/** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */
|
|
const parse = (text: string, host: string) => {
|
|
const k = `${host}!${text}`;
|
|
let p = parsed.get(k);
|
|
if (!p) {
|
|
p = parseFormula(text);
|
|
if (p.ok) p.node = bindNames(p.node, book, host);
|
|
parsed.set(k, p);
|
|
}
|
|
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.식, sheet.id);
|
|
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;
|
|
parsed.clear();
|
|
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, ctx.sheet);
|
|
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;
|
|
},
|
|
};
|
|
}
|