From 6acc8d0c529f35be84ad5f43f71f85a58c6485f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:14:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=88=98=EC=8B=9D=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=E2=80=94=20=EC=84=A0=ED=96=89=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EC=86=8D=20=EC=B6=94=EC=A0=81=20=ED=99=94=EC=82=B4?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=88=98=EC=8B=9D=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EA=B2=80=EC=82=AC=20=E2=9A=A0=20=ED=92=8D=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추적 화살표(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 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- .../spreadsheet/spreadsheet_analysis.css | 187 ++++++++++++ .../spreadsheet/spreadsheet_errcheck.ts | 193 ++++++++++++ .../spreadsheet/spreadsheet_eval_steps.ts | 261 ++++++++++++++++ A00_Common/spreadsheet/spreadsheet_graph.ts | 21 +- A00_Common/spreadsheet/spreadsheet_parser.ts | 17 +- A00_Common/spreadsheet/spreadsheet_trace.ts | 285 ++++++++++++++++++ A00_Common/spreadsheet/spreadsheet_types.ts | 4 + .../tester/spreadsheet/harness_analysis.html | 27 ++ .../tester/spreadsheet/harness_analysis.ts | 72 +++++ .../spreadsheet/harness_analysis_bundle.cjs | 29 ++ .../tester/spreadsheet/harness_sortfilter.ts | 13 +- .../helper_spreadsheet_analysis.cjs | 167 ++++++++++ .../spreadsheet/helper_spreadsheet_engine.cjs | 1 + .../spreadsheet/test_spreadsheet_analysis.py | 36 +++ 14 files changed, 1291 insertions(+), 22 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_analysis.css create mode 100644 A00_Common/spreadsheet/spreadsheet_errcheck.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_eval_steps.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_trace.ts create mode 100644 resources/tester/spreadsheet/harness_analysis.html create mode 100644 resources/tester/spreadsheet/harness_analysis.ts create mode 100644 resources/tester/spreadsheet/harness_analysis_bundle.cjs create mode 100644 resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs create mode 100644 resources/tester/spreadsheet/test_spreadsheet_analysis.py diff --git a/A00_Common/spreadsheet/spreadsheet_analysis.css b/A00_Common/spreadsheet/spreadsheet_analysis.css new file mode 100644 index 00000000..47541bbd --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_analysis.css @@ -0,0 +1,187 @@ +/* spreadsheet_analysis.css (주인 A) — 추적 화살표 겹판 · 수식 계산 창 · 오류 ⚠ 단추 */ + +/* 추적 화살표 — 크기 0 겹판(격자 조작을 막지 않음) */ +.ss-trace { + position: absolute; + left: 0; + top: 0; + width: 1px; + height: 1px; + overflow: visible; + pointer-events: none; +} + +.ss-trace__line { + stroke-width: 1.5; +} + +.ss-trace__line.is-plain, +.ss-trace__dot.is-plain, +.ss-trace__head.is-plain { + stroke: var(--color-info); + fill: var(--color-info); +} + +.ss-trace__line.is-error, +.ss-trace__dot.is-error, +.ss-trace__head.is-error { + stroke: var(--color-danger); + fill: var(--color-danger); +} + +.ss-trace__line.is-sheet, +.ss-trace__dot.is-sheet, +.ss-trace__head.is-sheet { + stroke: var(--color-text-body); + fill: var(--color-text-body); +} + +.ss-trace__line.is-sheet { + stroke-dasharray: 4 3; +} + +.ss-trace__box { + fill: none; + stroke-width: 2; + stroke: var(--color-info); +} + +.ss-trace__box.is-error { + stroke: var(--color-danger); +} + +.ss-trace__sheet { + fill: var(--color-surface); + stroke: var(--color-text-body); +} + +/* 수식 계산 창 */ +.ss-steps { + position: absolute; + inset: 0; + z-index: var(--z-dropdown); + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 18%); +} + +.ss-steps__box { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 420px; + max-width: 90%; + padding: var(--spacing-12); + background: var(--color-surface-raised); + color: var(--color-text-body); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + box-shadow: var(--shadow-lg); + font-size: 13px; +} + +.ss-steps__title { + font-weight: 600; +} + +.ss-steps__meta { + color: var(--color-text-secondary); +} + +.ss-steps__text { + min-height: 60px; + padding: var(--spacing-8); + font-family: var(--font-mono, monospace); + white-space: pre-wrap; + word-break: break-all; + background: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.ss-steps__foot { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +/* 오류 ⚠ 단추 · 풍선 · 메뉴 */ +.ss-errtip { + position: absolute; + z-index: var(--z-dropdown); + width: 0; + height: 0; + overflow: visible; +} + +.ss-errtip__btn { + position: absolute; + width: 18px; + height: 18px; + padding: 0; + font-size: 11px; + line-height: 16px; + cursor: pointer; + color: var(--color-warning); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.ss-errtip__balloon { + display: none; + position: absolute; + left: 0; + top: 22px; + z-index: var(--z-dropdown); + width: max-content; + max-width: 320px; + padding: var(--spacing-4) var(--spacing-8); + font-size: 12px; + color: var(--color-text-body); + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); +} + +.ss-errtip__btn:hover + .ss-errtip__balloon { + display: block; +} + +.ss-errtip__menu { + position: absolute; + left: 0; + top: 22px; + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + min-width: 200px; + max-width: 320px; + padding: var(--spacing-4); + font-size: 13px; + color: var(--color-text-body); + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); +} + +.ss-errtip__head { + padding: var(--spacing-4); + font-weight: 600; +} + +.ss-errtip__item { + padding: var(--spacing-4); + text-align: left; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +.ss-errtip__item:hover { + background: var(--color-surface-sunken); +} diff --git a/A00_Common/spreadsheet/spreadsheet_errcheck.ts b/A00_Common/spreadsheet/spreadsheet_errcheck.ts new file mode 100644 index 00000000..b4642234 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_errcheck.ts @@ -0,0 +1,193 @@ +/* ============================================================================= + * spreadsheet_errcheck.ts (주인 A) + * 오류 검사(엑셀 초록 세모) — 칸마다 까닭을 냄: 순환 참조 · 식 결과가 오류(#DIV/0! 등) · 숫자처럼 보이는 글. + * 활성 칸에 까닭이 있으면 칸 왼쪽에 ⚠ 단추 · 올리면 까닭 풍선 · 누르면 메뉴(숫자로 변환 · 계산 단계 보기 · + * 선행 참조 추적 · 오류 무시). 「오류 무시」는 이 화면 동안만(문서에 안 적음). + * + * 잇는 법(C · sub6 · D · sub7): + * · 부품 붙이기: `const tip = attachErrorTip(ctx, trace?)` — ⚠ 단추를 격자 overlay(editorSlot 층)에 얹음. + * grid.render() · 고름 바뀜 뒤마다 `tip.refresh()`. `trace`(spreadsheet_trace 의 손잡이)를 주면 메뉴에 「선행 참조 추적」. + * · 격자(C): 보이는 칸마다 `tip.warning(r, c)` 가 null 이 아니면 칸 왼쪽 위에 초록 세모(6px · `--color-success`). + * 시트 전체를 훑지 말고 그리는 칸만 물음(값 하나 · 칸 하나 읽는 값싼 부름). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { toA1 } from "./spreadsheet_address"; +import { isError } from "./spreadsheet_eval"; +import { openEvalSteps } from "./spreadsheet_eval_steps"; +import type { TraceHandle } from "./spreadsheet_trace"; +import type { CalcEngine, Cell, Workbook } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_analysis.css"; + +export type WarningKind = "순환" | "오류" | "글숫자"; + +export interface CellWarning { + 종류: WarningKind; + 까닭: string; +} + +/** 엑셀 오류 값 뜻(엔진 까닭이 없을 때) */ +const MEANING: Record = { + "#DIV/0!": ["0 으로 나눔", "Divide by zero"], + "#VALUE!": ["값의 종류가 맞지 않음", "Wrong type of value"], + "#REF!": ["지워진 칸을 가리킴", "Invalid cell reference"], + "#NAME?": ["모르는 이름 · 함수", "Unrecognized name"], + "#N/A": ["찾는 값이 없음", "Value not available"], + "#NUM!": ["수가 맞지 않음", "Invalid number"], + "#NULL!": ["겹치지 않는 범위", "Ranges do not intersect"], +}; + +const TEXT = { + Cycle: ["순환 참조", "Circular reference"], + TextNumber: ["숫자가 글로 저장됨", "Number stored as text"], + Convert: ["숫자로 변환", "Convert to number"], + Steps: ["계산 단계 보기", "Show calculation steps"], + Trace: ["선행 참조 추적", "Trace precedents"], + Ignore: ["오류 무시", "Ignore error"], +}; +const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0]; + +/** 숫자로 읽히는 글(앞뒤 빈칸 · 천 단위 쉼표 · 지수 허용) */ +const NUMBER_LIKE = /^[+-]?(\d{1,3}(,\d{3})+|\d+)?(\.\d+)?(e[+-]?\d+)?$/i; + +export function numberLike(text: string): number | null { + const s = text.trim(); + if (!/\d/.test(s) || !NUMBER_LIKE.test(s)) return null; + const n = Number(s.replace(/,/g, "")); + return Number.isFinite(n) ? n : null; +} + +/** 칸 하나 까닭 — 없으면 null */ +export function cellWarning( + book: Workbook, + engine: CalcEngine, + sheet: string, + r: number, + c: number, +): CellWarning | null { + const cell: Cell | undefined = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]; + if (!cell) return null; + if (cell.식 !== undefined) { + const v = engine.value(sheet, r, c); + if (!isError(v)) return null; + if (v.error === "#CYCLE!") return { 종류: "순환", 까닭: v.why ?? t("Cycle") }; + const m = MEANING[v.error]; + const head = m ? `${v.error} ${m[currentLanguageIndex] ?? m[0]}` : v.error; + return { 종류: "오류", 까닭: v.why ? `${v.error} ${v.why}` : head }; + } + if (typeof cell.값 === "string" && numberLike(cell.값) !== null) + return { 종류: "글숫자", 까닭: t("TextNumber") }; + return null; +} + +export interface ErrorTipHandle extends PartHandle { + /** 격자 세모용 — 「오류 무시」한 칸은 null */ + warning(r: number, c: number): CellWarning | null; +} + +export function attachErrorTip(ctx: SpreadsheetContext, trace?: TraceHandle): ErrorTipHandle { + const layer = el("div", { className: "ss-errtip" }); + const ignored = new Set(); + let menu: HTMLElement | null = null; + + const warning = (r: number, c: number) => + ignored.has(`${ctx.sheet().id}!${toA1(r, c)}`) + ? null + : cellWarning(ctx.book, ctx.engine, ctx.sheet().id, r, c); + + function closeMenu() { + menu?.remove(); + menu = null; + document.removeEventListener("mousedown", onOutside, true); + } + const onOutside = (ev: MouseEvent) => { + if (menu && !layer.contains(ev.target as Node)) closeMenu(); + }; + + /** 고른 칸 중 글숫자를 모두 수로(서식은 그대로) */ + function convert() { + const s = ctx.sheet(); + const cells: Record = {}; + for (const g of ctx.selection.범위) + for (let r = g.r0; r <= g.r1; r++) + for (let c = g.c0; c <= g.c1; c++) { + const a1 = toA1(r, c); + const cell = s.칸[a1]; + const n = + typeof cell?.값 === "string" && cell.식 === undefined ? numberLike(cell.값) : null; + if (n !== null) cells[a1] = { ...cell, 값: n }; + } + if (Object.keys(cells).length) ctx.dispatch({ 종류: "칸", 시트: s.id, 칸: cells }); + } + + function place() { + closeMenu(); + const { r, c } = ctx.selection.활성; + const { layer: host, box } = ctx.grid.editorSlot(r, c); + if (layer.parentElement !== host) host.append(layer); + const w = warning(r, c); + const v = ctx.grid.visibleRange(); + if (!w || !box.w || r < v.r0 || r > v.r1 || c < v.c0 || c > v.c1) { + layer.replaceChildren(); + return; + } + const a = host.getBoundingClientRect(); + const g = ctx.grid.root.getBoundingClientRect(); + layer.style.left = `${box.x - (a.left - g.left) - 20}px`; + layer.style.top = `${box.y - (a.top - g.top)}px`; + const btn = el("button", { + className: "ss-errtip__btn", + text: "⚠", + attrs: { type: "button", "aria-label": w.까닭 }, + }); + const balloon = el("div", { className: "ss-errtip__balloon", text: w.까닭 }); + btn.addEventListener("mousedown", (ev) => ev.stopPropagation()); + btn.addEventListener("click", () => (menu ? closeMenu() : openMenu(w, r, c))); + layer.replaceChildren(btn, balloon); + } + + function openMenu(w: CellWarning, r: number, c: number) { + const item = (label: string, run: () => void) => { + const b = el("button", { + className: "ss-errtip__item", + text: label, + attrs: { type: "button" }, + }); + b.addEventListener("click", () => { + closeMenu(); + run(); + }); + return b; + }; + const items: HTMLElement[] = [el("div", { className: "ss-errtip__head", text: w.까닭 })]; + if (w.종류 === "글숫자" && !ctx.readOnly) items.push(item(t("Convert"), convert)); + if (w.종류 !== "글숫자") items.push(item(t("Steps"), () => openEvalSteps(ctx))); + if (w.종류 !== "글숫자" && trace) items.push(item(t("Trace"), () => trace.precedents())); + items.push( + item(t("Ignore"), () => { + ignored.add(`${ctx.sheet().id}!${toA1(r, c)}`); + ctx.grid.render(); + place(); + }), + ); + menu = el("div", { className: "ss-errtip__menu", attrs: { role: "menu" }, children: items }); + layer.append(menu); + document.addEventListener("mousedown", onOutside, true); + } + + const onScroll = () => place(); + ctx.grid.root.addEventListener("scroll", onScroll, true); + place(); + return { + root: layer, + refresh: place, + warning, + destroy() { + closeMenu(); + ctx.grid.root.removeEventListener("scroll", onScroll, true); + layer.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts new file mode 100644 index 00000000..15750ba8 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * spreadsheet_eval_steps.ts (주인 A) + * 수식 계산 단계 보기(엑셀 [수식 계산] 창) — 식을 왼쪽부터 한 마디씩 값으로 바꿔 보여 줌. + * 엑셀 규칙: 칸 참조 → 값(빈 칸은 0 · 글은 따옴표) · 연산 · 함수는 인자가 다 값이 된 뒤 · 범위는 함수가 통째로 풂 · + * IF · IFERROR · CHOOSE 류는 첫 인자만 먼저 풀고 고른 쪽은 함수 한 걸음으로 · ROW(A1) 같은 참조 인자는 그대로. + * 마디 값은 늘 엔진 풀이(`evalSafe`)로 냄 — 보이는 글은 보여 주기만. + * + * 잇는 법(D · sub7): + * · 도구 모음 [수식 분석] 「수식 계산」 → `openEvalSteps(ctx)` — 활성 칸 식의 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌. + * · 식 없는 칸이면 토스트로 알리고 창을 안 띄움. 들어가기(참조 칸 식 속으로)는 아직 없음. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; +import { isError, isRange, numberText } from "./spreadsheet_eval"; +import type { PlacedRange } from "./spreadsheet_eval"; +import { evalSafe } from "./spreadsheet_graph"; +import { formulaToText, parseFormula } from "./spreadsheet_parser"; +import type { + CalcEngine, + CellRange, + EvalContext, + FormulaNode, + Scalar, + Workbook, +} from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_analysis.css"; + +/** 한 걸음 — 글과 다음에 풀 곳(밑줄 · 글 안 [시작, 끝)) */ +export interface EvalStep { + 글: string; + 밑줄: [number, number] | null; +} + +/** 첫 인자만 먼저 풂 */ +const LAZY = new Set(["IF", "IFS", "IFERROR", "IFNA", "CHOOSE", "SWITCH"]); +/** 칸 참조 인자를 값으로 안 바꿈 */ +const REF_ARGS = new Set([ + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "OFFSET", + "INDEX", + "ISREF", + "ISBLANK", + "CELL", + "ISFORMULA", + "FORMULATEXT", + "SUMIF", + "SUMIFS", + "COUNTIF", + "COUNTIFS", + "AVERAGEIF", + "AVERAGEIFS", + "COUNTBLANK", +]); + +/** 푸는 차례(왼쪽 먼저 · 아래 먼저) */ +function order(node: FormulaNode, out: FormulaNode[], refArg = false): void { + switch (node.type) { + case "cell": + if (!refArg) out.push(node); + return; + case "name": + out.push(node); + return; + case "unary": + if (node.op !== "%" && node.arg.type === "number") return; // -5 는 글 그대로 + order(node.arg, out); + out.push(node); + return; + case "binary": + order(node.left, out); + order(node.right, out); + out.push(node); + return; + case "call": { + const args = LAZY.has(node.name) ? node.args.slice(0, 1) : node.args; + for (const a of args) order(a, out, REF_ARGS.has(node.name)); + out.push(node); + } + } +} + +export function valueText(v: Scalar): string { + if (v === null) return "0"; + if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + if (isError(v)) return v.error; + return numberText(v); +} + +/** 엔진 없이 쓸 풀이 문맥 — 칸 값은 엔진에서 · 범위는 값 든 끝까지(엔진과 같게) */ +function contextFor(book: Workbook, engine: CalcEngine, sheet: string, r: number, c: number) { + const ext = new Map(); + const extent = (id: string) => { + let e = ext.get(id); + if (!e) { + e = { r: 0, c: 0 }; + for (const [a1, cell] of Object.entries(book.시트.find((s) => s.id === id)?.칸 ?? {})) { + const at = cell.식 !== undefined || cell.값 !== undefined ? parseA1(a1) : null; + if (at) e = { r: Math.max(e.r, at.r), c: Math.max(e.c, at.c) }; + } + ext.set(id, e); + } + return e; + }; + const ctx: EvalContext = { + sheet, + r, + c, + cell: (s, rr, cc) => engine.value(s, rr, cc), + range(s, rg: CellRange): PlacedRange { + const e = extent(s); + const r1 = Math.min(rg.r1, Math.max(rg.r0, e.r)); + const c1 = Math.min(rg.c1, Math.max(rg.c0, e.c)); + return { + kind: "range", + rows: r1 - rg.r0 + 1, + cols: c1 - rg.c0 + 1, + at: (i, j) => engine.value(s, rg.r0 + i, rg.c0 + j), + ...rg, + }; + }, + sheetId: (name) => + book.시트.find((s) => s.이름.toLowerCase() === name.toLowerCase())?.id ?? null, + }; + return ctx; +} + +/** 칸 식의 모든 걸음(첫 글 → … → 값) · 식이 없으면 null */ +export function evalSteps( + book: Workbook, + engine: CalcEngine, + sheet: string, + r: number, + c: number, +): EvalStep[] | null { + const text = book.시트.find((s) => s.id === sheet)?.칸[toA1(r, c)]?.식; + if (text === undefined) return null; + const p = parseFormula(text); + if (!p.ok) + return [ + { 글: text, 밑줄: null }, + { 글: valueText(engine.value(sheet, r, c)), 밑줄: null }, + ]; + const ctx = contextFor(book, engine, sheet, r, c); + const nodes: FormulaNode[] = []; + order(p.node, nodes); + // 식 전체가 범위(`=A1:A3`)면 마지막 걸음으로 엔진 값(암묵 교차) + if (nodes[nodes.length - 1] !== p.node && p.node.type === "range") nodes.push(p.node); + const vals = new Map(); + const steps: FormulaNode[] = []; + for (const n of nodes) { + if (n === p.node) vals.set(n, engine.value(sheet, r, c)); + else { + const v = evalSafe(n, ctx); + if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로 + vals.set(n, v); + } + steps.push(n); + } + const done = new Map(); + const out: EvalStep[] = []; + for (let i = 0; i <= steps.length; i++) { + const raw = formulaToText(p.node, (n) => done.get(n), steps[i]); + const a = raw.indexOf("\u0001"); + const b = raw.indexOf("\u0002"); + out.push( + a < 0 + ? { 글: raw, 밑줄: null } + : { 글: raw.replace(/[\u0001\u0002]/g, ""), 밑줄: [a, b - 1] }, + ); + if (i < steps.length) done.set(steps[i], valueText(vals.get(steps[i]) ?? null)); + } + return out; +} + +// ── 창 ────────────────────────────────────────────────────────────────────── + +const TEXT = { + Title: ["수식 계산", "Evaluate Formula"], + Ref: ["참조", "Reference"], + Hint: [ + "밑줄 친 식의 값을 보려면 [계산] 을 누름", + "To show the result of the underlined expression, click Evaluate.", + ], + Eval: ["계산", "Evaluate"], + Restart: ["다시 시작", "Restart"], + Close: ["닫기", "Close"], + NoFormula: ["식이 없는 칸", "The cell has no formula"], +}; +const t = (k: keyof typeof TEXT) => TEXT[k][currentLanguageIndex] ?? TEXT[k][0]; + +export function openEvalSteps(ctx: SpreadsheetContext): () => void { + const sheet = ctx.sheet(); + const { r, c } = ctx.selection.활성; + const steps = evalSteps(ctx.book, ctx.engine, sheet.id, r, c); + if (!steps) { + showToast(t("NoFormula"), "info"); + return () => {}; + } + let i = 0; + const view = el("div", { className: "ss-steps__text", attrs: { "aria-live": "polite" } }); + const next = createButton({ + label: t("Eval"), + onClick: () => go(i + 1 < steps.length ? i + 1 : 0), + }); + const box = el("div", { + className: "ss-steps__box", + attrs: { role: "dialog", "aria-label": t("Title") }, + }); + const shade = el("div", { className: "ss-steps", children: [box] }); + + function go(to: number) { + i = to; + const s = steps![i]; + if (!s.밑줄) view.replaceChildren(s.글); + else { + const [a, b] = s.밑줄; + view.replaceChildren(s.글.slice(0, a), el("u", { text: s.글.slice(a, b) }), s.글.slice(b)); + } + next.textContent = i + 1 < steps!.length ? t("Eval") : t("Restart"); + } + const close = () => { + shade.remove(); + document.removeEventListener("keydown", onKey, true); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") close(); + else if (ev.key === "Enter") next.click(); + else return; + ev.preventDefault(); + ev.stopPropagation(); + }; + + box.append( + el("div", { className: "ss-steps__title", text: t("Title") }), + el("div", { + className: "ss-steps__meta", + text: `${t("Ref")}: ${ctx.book.시트.length > 1 ? `${quoteSheet(sheet.이름)}!` : ""}${toA1(r, c)}`, + }), + view, + el("div", { className: "ss-steps__meta", text: t("Hint") }), + el("div", { + className: "ss-steps__foot", + children: [next, createButton({ label: t("Close"), variant: "ghost", onClick: close })], + }), + ); + shade.addEventListener("mousedown", (ev) => { + if (ev.target === shade) close(); + }); + document.addEventListener("keydown", onKey, true); + go(0); + ctx.root.append(shade); + next.focus(); + return close; +} diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 9487d333..7e9046da 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -82,6 +82,17 @@ function collectRefs( } } +/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */ +export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult { + try { + return evaluate(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)); + } +} + /** 문서를 참조로 쥠 — 만들 때 전부 풂. */ export function createCalcEngine(book: Workbook): CalcEngine { const parsed = new Map(); @@ -285,15 +296,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { 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); + const one = intersect(evalSafe(p.node, ctx), ctx); return one === null ? ZERO : one; } diff --git a/A00_Common/spreadsheet/spreadsheet_parser.ts b/A00_Common/spreadsheet/spreadsheet_parser.ts index 7279f7b0..4521067a 100644 --- a/A00_Common/spreadsheet/spreadsheet_parser.ts +++ b/A00_Common/spreadsheet/spreadsheet_parser.ts @@ -272,10 +272,19 @@ function prec(node: FormulaNode): number { const sheetHead = (sheet: string | null) => (sheet === null ? "" : `${quoteSheet(sheet)}!`); -/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만 */ -export function formulaToText(node: FormulaNode): string { +/** 나무 → 앞 `=` 뺀 식 글(참조 옮김 뒤 다시 적기) — 수는 원래 글(`text`) 그대로 · 괄호는 필요한 곳만. + * 계산 단계 보기용: `sub` 가 글을 주는 마디는 그 글(푼 값)로 갈음 · `mark` 마디는 \u0001 … \u0002 로 감쌈 */ +export function formulaToText( + node: FormulaNode, + sub?: (node: FormulaNode) => string | undefined, + mark?: FormulaNode, +): string { + const own = sub?.(node); + if (own !== undefined) return own; + if (node === mark) return `\u0001${formulaToText(node, sub)}\u0002`; + const text = (child: FormulaNode) => formulaToText(child, sub, mark); const wrap = (child: FormulaNode, need: boolean) => - need ? `(${formulaToText(child)})` : formulaToText(child); + need && sub?.(child) === undefined ? `(${text(child)})` : text(child); switch (node.type) { case "number": return node.text; @@ -308,6 +317,6 @@ export function formulaToText(node: FormulaNode): string { return `${wrap(node.left, prec(node.left) < p)}${node.op}${wrap(node.right, prec(node.right) <= p)}`; } case "call": - return `${node.name}(${node.args.map(formulaToText).join(",")})`; + return `${node.name}(${node.args.map(text).join(",")})`; } } diff --git a/A00_Common/spreadsheet/spreadsheet_trace.ts b/A00_Common/spreadsheet/spreadsheet_trace.ts new file mode 100644 index 00000000..d676a0ec --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_trace.ts @@ -0,0 +1,285 @@ +/* ============================================================================= + * 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(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 94d6f73c..52af031b 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -31,6 +31,10 @@ * A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250 * A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300 * A spreadsheet_sortfilter.css ~150 + * A spreadsheet_trace.ts 선행 · 종속 추적 화살표(한 단계씩 · SVG 겹판) ~270 + * A spreadsheet_eval_steps.ts 수식 계산 단계 보기 창 ~230 + * A spreadsheet_errcheck.ts 오류 검사 까닭 · ⚠ 단추 · 풍선(세모는 C 가 그림) ~200 + * A spreadsheet_analysis.css 위 셋 화면 스타일 ~200 * A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80 * B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120 * B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400 diff --git a/resources/tester/spreadsheet/harness_analysis.html b/resources/tester/spreadsheet/harness_analysis.html new file mode 100644 index 00000000..a0eb863d --- /dev/null +++ b/resources/tester/spreadsheet/harness_analysis.html @@ -0,0 +1,27 @@ + + + + + 수식 분석 시험 틀 + + + + +
+ + + + +
+
+ + + + diff --git a/resources/tester/spreadsheet/harness_analysis.ts b/resources/tester/spreadsheet/harness_analysis.ts new file mode 100644 index 00000000..7e754fbb --- /dev/null +++ b/resources/tester/spreadsheet/harness_analysis.ts @@ -0,0 +1,72 @@ +/* 수식 분석 화면 시험 틀 — 진짜 부품(createSpreadsheet)에 추적 화살 · 수식 계산 창 · 오류 ⚠ 를 붙임. + * sub7 잇기 전이라 `inspect` 로 문맥(ctx)을 꺼내고, grid.render 뒤 refresh 를 부름. + * 번들: node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/harness.js (git 밖) */ +import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet"; +import { attachTrace } from "../../../A00_Common/spreadsheet/spreadsheet_trace"; +import { openEvalSteps } from "../../../A00_Common/spreadsheet/spreadsheet_eval_steps"; +import { attachErrorTip } from "../../../A00_Common/spreadsheet/spreadsheet_errcheck"; +import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; +import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +const w = window as unknown as Record; + +const book: Workbook = { + 종류: "통합문서", + 판: 1, + 이름: "harness", + 서식: [{}], + 시트: [ + { + id: "s1", + 이름: "분석", + 칸: { + A1: { 값: "단가" }, + B1: { 값: "수량" }, + C1: { 값: "금액" }, + A2: { 값: 1200 }, + B2: { 값: 3 }, + C2: { 식: "A2*B2" }, + A3: { 값: 800 }, + B3: { 값: "5" }, + C3: { 식: "A3*B3" }, + A4: { 값: 50 }, + B4: { 값: 0 }, + C4: { 식: "A4/B4" }, + C6: { 식: "SUM(C2:C3)+IF(B2>2,C2*10%,0)" }, + D6: { 식: "C6*2" }, + E2: { 식: "E3+1" }, + E3: { 식: "E2+1" }, + }, + }, + { id: "s2", 이름: "요약", 칸: { A1: { 식: "분석!D6" } } }, + ], + 활성: "s1", +}; + +const host = document.getElementById("host")!; +w.ss = createSpreadsheet(host, book, { inspect: true }); +const c = w.__aisloSheet as SpreadsheetContext; +const trace = attachTrace(c); +const tip = attachErrorTip(c, trace); +const render = c.grid.render.bind(c.grid); +c.grid.render = () => { + render(); + trace.refresh(); + tip.refresh(); +}; +const renderSel = c.grid.renderSelection.bind(c.grid); +c.grid.renderSelection = () => { + renderSel(); + tip.refresh(); +}; +w.ssCtx = c; +w.ssTrace = trace; +w.ssTip = tip; +w.ssSteps = () => openEvalSteps(c); +for (const [id, run] of [ + ["btn-prec", () => trace.precedents()], + ["btn-dep", () => trace.dependents()], + ["btn-clear", () => trace.clear()], + ["btn-steps", () => openEvalSteps(c)], +] as const) + document.getElementById(id)!.addEventListener("click", run); diff --git a/resources/tester/spreadsheet/harness_analysis_bundle.cjs b/resources/tester/spreadsheet/harness_analysis_bundle.cjs new file mode 100644 index 00000000..e1c2b08a --- /dev/null +++ b/resources/tester/spreadsheet/harness_analysis_bundle.cjs @@ -0,0 +1,29 @@ +/* 수식 분석 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는