- 추적 화살표(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
286 lines
10 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|