- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
360 lines
12 KiB
TypeScript
360 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);
|
|
}
|
|
}
|
|
|
|
/** 문서를 참조로 쥠 — 만들 때 전부 풂. */
|
|
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}번째 글자)`);
|
|
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<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;
|
|
},
|
|
};
|
|
}
|