/* ============================================================================= * 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; } 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(); 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 = ( tag: K, attrs: Record, ) => { 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(); 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(); }, }; }