/* ============================================================================= * 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; /** 값 든 칸의 가장 먼 행 · 열 — 행 · 열 전체 범위를 여기까지만 읽음 */ 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); } } /** 문서를 참조로 쥠 — 만들 때 전부 풂. */ export function createCalcEngine(book: Workbook): CalcEngine { const parsed = new Map(); let sheets = new Map(); let names = new Map(); /** 식 칸 → 가리키는 칸 · 범위 */ let precs = new Map(); /** 칸 하나 → 그 칸을 가리키는 식 칸 */ let cellDeps = new Map>(); /** 시트 → 범위를 가리키는 식 칸 */ let rangeDeps = new Map>(); let vals = new Map(); let state = new Map(); const stack: string[] = []; const cycle = new Map(); 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(); 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}번째 글자)`); let v: EvalResult; try { v = evaluate(p.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)); } const one = intersect(v, ctx); return one === null ? ZERO : one; } function dependents(key: string, out: Set) { 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(); 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(); 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; }, }; }