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

286 lines
10 KiB
TypeScript

/* =============================================================================
* spreadsheet_trace.ts (주인 A)
* 선행 · 종속 칸 추적 화살표(엑셀 [수식 분석]) — 엔진 의존 그래프(`engine.precedents`)로 한 단계씩 넓힘.
* 엑셀 규칙: 누를 때마다 한 단계 더(이미 편 칸 너머만) · 범위는 파란 상자 + 화살 · 오류 칸에서 나온 화살은 빨강 ·
* 다른 시트 칸은 점선 화살 + 시트 표 아이콘 · 문서가 바뀌면 화살을 모두 지움.
*
* 잇는 법(D · sub7):
* · 부품 붙이기: `const trace = attachTrace(ctx)` — SVG 겹판을 격자 overlay(editorSlot 층)에 얹음.
* grid.render() 뒤마다 `trace.refresh()` · 스크롤은 스스로 따라감 · 시트를 바꾸면 그 시트 화살만 그림.
* · 도구 모음 [수식 분석]: 「선행 참조 추적」 → `trace.precedents()` · 「종속 참조 추적」 → `trace.dependents()` ·
* 「화살표 지우기」 → `trace.clear()`(종류만 지우려면 `clear("선행")`). 더 펼 칸이 없으면 false(토스트 몫).
* ========================================================================== */
import { cellId, inRange, parseA1 } from "./spreadsheet_address";
import { isError } from "./spreadsheet_eval";
import type { CalcEngine, CellRange, SheetCellAddress, Workbook } from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
export type TraceKind = "선행" | "종속";
/** 화살 하나 — 늘 가리켜진 쪽(from) → 식 칸(to) */
export interface TraceArrow {
종류: TraceKind;
from: { 시트: string; 범위: CellRange };
to: SheetCellAddress;
/** 시작 칸 · 범위에 오류 값이 있음 → 빨강 */
오류: boolean;
}
export interface TraceState {
arrows: TraceArrow[];
/** 이미 편 칸(`종류:시트!id`) */
opened: Set<string>;
}
const key = (kind: TraceKind, sheet: string, r: number, c: number) =>
`${kind}:${sheet}!${cellId(r, c)}`;
const one = (r: number, c: number): CellRange => ({ r0: r, c0: c, r1: r, c1: c });
/** 시트의 식 칸 목록(범위 안만) */
function formulaCells(book: Workbook, sheet: string, rg?: CellRange): SheetCellAddress[] {
const s = book.시트.find((x) => x.id === sheet);
const out: SheetCellAddress[] = [];
for (const [a1, cell] of Object.entries(s?.칸 ?? {})) {
const at = cell.식 !== undefined ? parseA1(a1) : null;
if (at && (!rg || inRange(rg, at.r, at.c))) out.push({ 시트: sheet, ...at });
}
return out;
}
function hasError(book: Workbook, engine: CalcEngine, sheet: string, rg: CellRange): boolean {
if (rg.r0 === rg.r1 && rg.c0 === rg.c1) return isError(engine.value(sheet, rg.r0, rg.c0));
const s = book.시트.find((x) => x.id === sheet);
for (const a1 of Object.keys(s?.칸 ?? {})) {
const at = parseA1(a1);
if (at && inRange(rg, at.r, at.c) && isError(engine.value(sheet, at.r, at.c))) return true;
}
return false;
}
/** 칸을 가리키는 식 칸들(모든 시트) */
export function dependentsOf(
book: Workbook,
engine: CalcEngine,
sheet: string,
r: number,
c: number,
): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const s of book.시트)
for (const f of formulaCells(book, s.id))
if (
engine.precedents(f.시트, f.r, f.c).some((d) => d.시트 === sheet && inRange(d.범위, r, c))
)
out.push(f);
return out;
}
/** 한 단계 넓힘 — 처음이면 그 칸 · 아니면 그 칸에서 이어진 화살 끝 중 아직 안 편 칸. 더한 화살이 있으면 true */
export function traceStep(
book: Workbook,
engine: CalcEngine,
state: TraceState,
kind: TraceKind,
at: SheetCellAddress,
): boolean {
// 이어진 칸을 따라가며 안 편 칸(앞날개)을 모음
const frontier: SheetCellAddress[] = [];
const seen = new Set<string>();
const walk = [at];
while (walk.length) {
const p = walk.pop()!;
const k = key(kind, p.시트, p.r, p.c);
if (seen.has(k)) continue;
seen.add(k);
if (!state.opened.has(k)) {
frontier.push(p);
continue;
}
for (const a of state.arrows) {
if (a.종류 !== kind) continue;
if (kind === "선행" && a.to.시트 === p.시트 && a.to.r === p.r && a.to.c === p.c)
walk.push(...formulaCells(book, a.from.시트, a.from.범위));
if (kind === "종속" && a.from.시트 === p.시트 && inRange(a.from.범위, p.r, p.c))
walk.push(a.to);
}
}
let added = false;
for (const p of frontier) {
state.opened.add(key(kind, p.시트, p.r, p.c));
const links =
kind === "선행"
? engine.precedents(p.시트, p.r, p.c).map((d) => ({ from: d, to: p }))
: dependentsOf(book, engine, p.시트, p.r, p.c).map((to) => ({
from: { 시트: p.시트, 범위: one(p.r, p.c) },
to,
}));
for (const { from, to } of links) {
state.arrows.push({
종류: kind,
from,
to,
오류: hasError(book, engine, from.시트, from.범위),
});
added = true;
}
}
return added;
}
// ── 화면 겹판 ────────────────────────────────────────────────────────────────
const SVG = "http://www.w3.org/2000/svg";
const svg = <K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [a, v] of Object.entries(attrs)) n.setAttribute(a, String(v));
return n;
};
export interface TraceHandle extends PartHandle {
/** 활성 칸 선행 추적 한 단계 — 더한 화살이 없으면 false */
precedents(): boolean;
dependents(): boolean;
clear(kind?: TraceKind): void;
/** 지금 화살(시험 · 상태 표시용) */
arrows(): readonly TraceArrow[];
}
export function attachTrace(ctx: SpreadsheetContext): TraceHandle {
const layer = svg("svg", { class: "ss-trace" });
const defs = svg("defs", {});
for (const kind of ["plain", "error", "sheet"]) {
const m = svg("marker", {
id: `ss-trace-head-${kind}`,
viewBox: "0 0 10 10",
refX: 9,
refY: 5,
markerWidth: 7,
markerHeight: 7,
orient: "auto-start-reverse",
});
m.append(svg("path", { d: "M0,0 L10,5 L0,10 z", class: `ss-trace__head is-${kind}` }));
defs.append(m);
}
let state: TraceState = { arrows: [], opened: new Set() };
let book = ctx.book;
/** 칸 상자 → 겹판 자리(filter 와 같은 맞춤) */
function boxIn(r: number, c: number) {
const { layer: host, box } = ctx.grid.editorSlot(r, c);
const a = host.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) };
}
function draw(): void {
if (ctx.book !== book) {
state = { arrows: [], opened: new Set() };
book = ctx.book;
}
const { layer: host } = ctx.grid.editorSlot(0, 0);
if (layer.parentElement !== host) host.append(layer);
const here = ctx.sheet().id;
const parts: SVGElement[] = [defs];
const boxes = new Set<string>();
for (const a of state.arrows) {
const fromHere = a.from.시트 === here;
const toHere = a.to.시트 === here;
if (!fromHere && !toHere) continue;
const tone = a.오류 ? "error" : fromHere && toHere ? "plain" : "sheet";
const t = toHere ? boxIn(a.to.r, a.to.c) : null;
const rg = a.from.범위;
const f = fromHere ? boxIn(rg.r0, rg.c0) : null;
const end = t
? { x: t.x + t.w / 2, y: t.y + t.h / 2 }
: { x: f!.x + f!.w + 60, y: f!.y - 24 };
const start = f
? { x: f.x + Math.min(f.w, 24) / 2, y: f.y + f.h / 2 }
: { x: t!.x - 60, y: t!.y - 24 };
if (
f &&
(rg.r0 !== rg.r1 || rg.c0 !== rg.c1) &&
!boxes.has(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`)
) {
boxes.add(`${rg.r0},${rg.c0},${rg.r1},${rg.c1}`);
const b = boxIn(rg.r1, rg.c1);
parts.push(
svg("rect", {
class: `ss-trace__box is-${a.오류 ? "error" : "plain"}`,
x: f.x + 1,
y: f.y + 1,
width: Math.max(0, b.x + b.w - f.x - 2),
height: Math.max(0, b.y + b.h - f.y - 2),
}),
);
}
if (f)
parts.push(
svg("circle", { class: `ss-trace__dot is-${tone}`, cx: start.x, cy: start.y, r: 3 }),
);
else
parts.push(
svg("rect", {
class: "ss-trace__sheet",
x: start.x - 9,
y: start.y - 7,
width: 18,
height: 14,
}),
);
parts.push(
svg("line", {
class: `ss-trace__line is-${tone}`,
x1: start.x,
y1: start.y,
x2: end.x,
y2: end.y,
"marker-end": `url(#ss-trace-head-${tone})`,
"data-from": `${a.from.시트}!${rg.r0},${rg.c0}`,
"data-to": `${a.to.시트}!${a.to.r},${a.to.c}`,
}),
);
if (!t)
parts.push(
svg("rect", { class: "ss-trace__sheet", x: end.x, y: end.y - 7, width: 18, height: 14 }),
);
}
layer.replaceChildren(...parts);
}
const step = (kind: TraceKind) => {
if (ctx.book !== book) draw();
const { r, c } = ctx.selection.활성;
const added = traceStep(ctx.book, ctx.engine, state, kind, { 시트: ctx.sheet().id, r, c });
draw();
return added;
};
const onScroll = () => draw();
ctx.grid.root.addEventListener("scroll", onScroll, true);
draw();
return {
root: layer as unknown as HTMLElement,
refresh: draw,
precedents: () => step("선행"),
dependents: () => step("종속"),
clear(kind) {
if (!kind) state = { arrows: [], opened: new Set() };
else {
state.arrows = state.arrows.filter((a) => a.종류 !== kind);
for (const k of [...state.opened]) if (k.startsWith(`${kind}:`)) state.opened.delete(k);
}
draw();
},
arrows: () => state.arrows,
destroy() {
ctx.grid.root.removeEventListener("scroll", onScroll, true);
layer.remove();
},
};
}