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
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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<string, [string, string]> = {
|
||||
"#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<string>();
|
||||
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<string, Cell> = {};
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, { r: number; c: number }>();
|
||||
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<FormulaNode, Scalar>();
|
||||
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<FormulaNode, string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, ParseResult>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(",")})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>수식 분석 시험 틀</title>
|
||||
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
#bar { margin: 8px; display: flex; gap: 8px; }
|
||||
#host { height: 520px; margin: 8px; border: 1px solid #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bar">
|
||||
<button id="btn-prec">선행 참조 추적</button>
|
||||
<button id="btn-dep">종속 참조 추적</button>
|
||||
<button id="btn-clear">화살표 지우기</button>
|
||||
<button id="btn-steps">수식 계산</button>
|
||||
</div>
|
||||
<div id="host"></div>
|
||||
<script>
|
||||
window.ssErrors = [];
|
||||
window.addEventListener("error", (e) => window.ssErrors.push(String(e.error?.stack || e.message)));
|
||||
</script>
|
||||
<script src="../../../tmp/spreadsheet_analysis/harness.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,29 @@
|
||||
/* 수식 분석 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는 <style> 로 넣음 · @ui 별칭.
|
||||
* node harness_analysis_bundle.cjs → tmp/spreadsheet_analysis/harness.js (git 밖) */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..", "..");
|
||||
const { build } = require(path.join(ROOT, "config", "node_modules", "rolldown"));
|
||||
|
||||
build({
|
||||
input: path.join(__dirname, "harness_analysis.ts"),
|
||||
resolve: { alias: { "@ui": path.join(ROOT, "ui_template") } },
|
||||
plugins: [
|
||||
{
|
||||
name: "css-inline",
|
||||
load(id) {
|
||||
if (!id.endsWith(".css")) return null;
|
||||
const css = JSON.stringify(fs.readFileSync(id, "utf8"));
|
||||
const code = `const s = document.createElement("style"); s.textContent = ${css}; document.head.append(s);`;
|
||||
return { code, moduleType: "js" };
|
||||
},
|
||||
},
|
||||
],
|
||||
output: { file: path.join(ROOT, "tmp", "spreadsheet_analysis", "harness.js"), format: "iife" },
|
||||
}).then(
|
||||
() => console.log("ok"),
|
||||
(e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음.
|
||||
* sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
|
||||
* sub7 잇기 전이라 `inspect` 로 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름.
|
||||
* 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */
|
||||
import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import {
|
||||
openSortDialog,
|
||||
sortSelection,
|
||||
@@ -11,12 +11,6 @@ import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreads
|
||||
import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
let ctx: SpreadsheetContext | null = null;
|
||||
const origTabs = parts.mountTabs;
|
||||
parts.mountTabs = (c: SpreadsheetContext) => {
|
||||
ctx = c;
|
||||
return origTabs(c);
|
||||
};
|
||||
|
||||
const rows: [string, string, number][] = [
|
||||
["터파기", "토사", 12.5],
|
||||
@@ -54,9 +48,10 @@ const book: Workbook = {
|
||||
|
||||
const host = document.getElementById("host")!;
|
||||
w.ss = createSpreadsheet(host, book, {
|
||||
inspect: true,
|
||||
onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1),
|
||||
});
|
||||
const c = ctx as unknown as SpreadsheetContext;
|
||||
const c = w.__aisloSheet as SpreadsheetContext;
|
||||
const filter = attachFilter(c);
|
||||
const render = c.grid.render.bind(c.grid);
|
||||
c.grid.render = () => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/* 수식 분석 엔진 시험 헬퍼 — 추적 화살표 단계 · 수식 계산 걸음 · 오류 검사 까닭을 묶음마다 돌려 틀린 것 목록을 JSON 으로.
|
||||
* 적재 · 가짜 함수는 `helper_spreadsheet_engine.cjs` 것을 씀.
|
||||
*/
|
||||
const { A, G, load } = require("./helper_spreadsheet_engine.cjs");
|
||||
const TR = load("spreadsheet_trace");
|
||||
const ST = load("spreadsheet_eval_steps");
|
||||
const EC = load("spreadsheet_errcheck");
|
||||
|
||||
const results = {};
|
||||
let group = "";
|
||||
const eq = (label, got, want) => {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
if (g !== w) results[group].push(`${label}: got ${g} want ${w}`);
|
||||
};
|
||||
const section = (name, fn) => {
|
||||
group = name;
|
||||
results[name] = [];
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
results[name].push(`예외: ${e && e.stack ? e.stack : e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cellsOf = (cells) => {
|
||||
const 칸 = {};
|
||||
for (const [k, v] of Object.entries(cells))
|
||||
칸[k] = typeof v === "string" && v.startsWith("=") ? { 식: v.slice(1) } : { 값: v };
|
||||
return 칸;
|
||||
};
|
||||
function book(cells, sheet2) {
|
||||
const 시트 = [{ id: "s1", 이름: "본", 칸: cellsOf(cells) }];
|
||||
if (sheet2) 시트.push({ id: "s2", 이름: "둘", 칸: cellsOf(sheet2) });
|
||||
return { 종류: "통합문서", 판: 1, 이름: "t", 서식: [{}], 시트 };
|
||||
}
|
||||
const at = (a1, 시트 = "s1") => ({ 시트, ...A.parseA1(a1) });
|
||||
const name = (a) => `${a.시트}!${A.toA1(a.r, a.c)}`;
|
||||
const arrowText = (a) =>
|
||||
`${a.from.시트}!${A.rangeToA1(a.from.범위)}>${name(a.to)}${a.오류 ? "!" : ""}`;
|
||||
|
||||
// ── 1. 선행 · 종속 추적 ──────────────────────────────────────────────────────
|
||||
section("trace", () => {
|
||||
const b = book(
|
||||
{ A1: 1, A2: 2, B1: "=A1+A2", C1: "=B1*2", D1: "=SUM(A1:A2)", A3: "=1/0", B3: "=A3+1" },
|
||||
{ E1: "=본!C1" },
|
||||
);
|
||||
const e = G.createCalcEngine(b);
|
||||
const st = { arrows: [], opened: new Set() };
|
||||
// 선행: C1 → 한 단계씩
|
||||
eq("선행 1", TR.traceStep(b, e, st, "선행", at("C1")), true);
|
||||
eq("선행 1 화살", st.arrows.map(arrowText), ["s1!B1>s1!C1"]);
|
||||
eq("선행 2", TR.traceStep(b, e, st, "선행", at("C1")), true);
|
||||
eq("선행 2 화살", st.arrows.map(arrowText).slice(1), ["s1!A1>s1!B1", "s1!A2>s1!B1"]);
|
||||
eq("선행 3 더 없음", TR.traceStep(b, e, st, "선행", at("C1")), false);
|
||||
|
||||
// 종속: A1 → B1 · D1(범위로) → C1 → 둘!E1(다른 시트)
|
||||
const sd = { arrows: [], opened: new Set() };
|
||||
TR.traceStep(b, e, sd, "종속", at("A1"));
|
||||
eq("종속 1", sd.arrows.map((a) => name(a.to)).sort(), ["s1!B1", "s1!D1"]);
|
||||
TR.traceStep(b, e, sd, "종속", at("A1"));
|
||||
eq("종속 2", sd.arrows.map((a) => name(a.to)).slice(2), ["s1!C1"]);
|
||||
TR.traceStep(b, e, sd, "종속", at("A1"));
|
||||
eq("종속 3 다른 시트", sd.arrows.map(arrowText).slice(3), ["s1!C1>s2!E1"]);
|
||||
eq("종속 4 더 없음", TR.traceStep(b, e, sd, "종속", at("A1")), false);
|
||||
|
||||
// 범위 선행 · 오류 화살
|
||||
const sr = { arrows: [], opened: new Set() };
|
||||
TR.traceStep(b, e, sr, "선행", at("D1"));
|
||||
eq("범위 화살", sr.arrows.map(arrowText), ["s1!A1:A2>s1!D1"]);
|
||||
const se = { arrows: [], opened: new Set() };
|
||||
TR.traceStep(b, e, se, "선행", at("B3"));
|
||||
eq("오류 칸 화살 빨강", se.arrows.map(arrowText), ["s1!A3>s1!B3!"]);
|
||||
eq("값 칸은 선행 없음", TR.traceStep(b, e, { arrows: [], opened: new Set() }, "선행", at("A1")), false);
|
||||
eq(
|
||||
"dependentsOf",
|
||||
TR.dependentsOf(b, e, "s1", 0, 1).map(name),
|
||||
["s1!C1"],
|
||||
);
|
||||
});
|
||||
|
||||
// ── 2. 수식 계산 걸음 ────────────────────────────────────────────────────────
|
||||
const stepsOf = (cells, a1) => {
|
||||
const b = book(cells);
|
||||
const e = G.createCalcEngine(b);
|
||||
const p = A.parseA1(a1);
|
||||
return ST.evalSteps(b, e, "s1", p.r, p.c).map((s) =>
|
||||
s.밑줄 ? `${s.글.slice(0, s.밑줄[0])}[${s.글.slice(s.밑줄[0], s.밑줄[1])}]${s.글.slice(s.밑줄[1])}` : s.글,
|
||||
);
|
||||
};
|
||||
section("eval_steps", () => {
|
||||
eq("차례 · 빈 칸 0 · 범위는 함수가", stepsOf({ A1: 3, A3: 0, B1: "=A1*2+SUM(A1:A3)-A2" }, "B1"), [
|
||||
"[A1]*2+SUM(A1:A3)-A2",
|
||||
"[3*2]+SUM(A1:A3)-A2",
|
||||
"6+[SUM(A1:A3)]-A2",
|
||||
"[6+3]-A2",
|
||||
"9-[A2]",
|
||||
"[9-0]",
|
||||
"9",
|
||||
]);
|
||||
eq("괄호 안 밑줄", stepsOf({ A1: 3, B1: "=(A1+1)*2" }, "B1"), [
|
||||
"([A1]+1)*2",
|
||||
"([3+1])*2",
|
||||
"[4*2]",
|
||||
"8",
|
||||
]);
|
||||
eq("IF 는 첫 인자만 먼저 · 안 고른 쪽 오류 무시", stepsOf({ A1: 3, B1: '=IF(A1>2,"큼",1/0)' }, "B1"), [
|
||||
'IF([A1]>2,"큼",1/0)',
|
||||
'IF([3>2],"큼",1/0)',
|
||||
'[IF(TRUE,"큼",1/0)]',
|
||||
'"큼"',
|
||||
]);
|
||||
eq("글 따옴표", stepsOf({ A1: 'a"b', B1: '=A1&"x"' }, "B1"), [
|
||||
'[A1]&"x"',
|
||||
'["a""b"&"x"]',
|
||||
'"a""bx"',
|
||||
]);
|
||||
eq("음수 글자는 그대로", stepsOf({ B1: "=-5+1" }, "B1"), ["[-5+1]", "-4"]);
|
||||
eq("오류 전파", stepsOf({ A1: 0, B1: "=1/A1+1" }, "B1"), [
|
||||
"1/[A1]+1",
|
||||
"[1/0]+1",
|
||||
"[#DIV/0!+1]",
|
||||
"#DIV/0!",
|
||||
]);
|
||||
eq("값 칸은 null", ST.evalSteps(book({ A1: 1 }), G.createCalcEngine(book({ A1: 1 })), "s1", 0, 0), null);
|
||||
eq("상수 식", stepsOf({ B1: "=5" }, "B1"), ["5"]);
|
||||
});
|
||||
|
||||
// ── 3. 오류 검사 까닭 ────────────────────────────────────────────────────────
|
||||
section("errcheck", () => {
|
||||
const b = book({
|
||||
A1: "1,234",
|
||||
A2: "abc",
|
||||
A3: "12a",
|
||||
A4: 5,
|
||||
A5: "=1/0",
|
||||
A6: "=A7",
|
||||
A7: "=A6",
|
||||
A8: " 1e3 ",
|
||||
A9: "=A5+1",
|
||||
A10: "=A4*2",
|
||||
});
|
||||
const e = G.createCalcEngine(b);
|
||||
const w = (a1) => {
|
||||
const p = A.parseA1(a1);
|
||||
const x = EC.cellWarning(b, e, "s1", p.r, p.c);
|
||||
return x && x.종류;
|
||||
};
|
||||
eq(
|
||||
"종류",
|
||||
["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "B1"].map(w),
|
||||
["글숫자", null, null, null, "오류", "순환", "순환", "글숫자", "오류", null, null],
|
||||
);
|
||||
const why = EC.cellWarning(b, e, "s1", 4, 0).까닭;
|
||||
eq("#DIV/0! 까닭", why.startsWith("#DIV/0! 0 으로 나눔"), true);
|
||||
eq("순환 까닭", EC.cellWarning(b, e, "s1", 5, 0).까닭.includes("A6 → A7"), true);
|
||||
eq("numberLike", ["1,234", " 1e3 ", "1,23", ".5", "-", "+7"].map(EC.numberLike), [
|
||||
1234,
|
||||
1000,
|
||||
null,
|
||||
0.5,
|
||||
null,
|
||||
7,
|
||||
]);
|
||||
});
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -21,6 +21,7 @@ function loadTs(file) {
|
||||
cache.set(full, mod);
|
||||
const req = (r) => {
|
||||
if (r === "./spreadsheet_functions") return fakeFunctions;
|
||||
if (r.endsWith(".css")) return {};
|
||||
if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`));
|
||||
if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
return require(r);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""산출근거 스프레드시트 수식 분석 — 엑셀 규칙표.
|
||||
|
||||
선행 · 종속 추적(누를 때마다 한 단계 · 범위 화살 · 오류 칸 빨강 · 다른 시트 · 더 없으면 false) ·
|
||||
수식 계산 걸음(왼쪽 먼저 · 빈 칸 0 · 범위는 함수가 · IF 첫 인자만 · 괄호 안 밑줄 · 오류 전파) ·
|
||||
오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글).
|
||||
화면(화살 겹판 · 계산 창 · ⚠ 풍선)은 harness_analysis.html 을 ORCA 로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GROUPS = ["trace", "eval_steps", "errcheck"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_spreadsheet_analysis.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group", GROUPS)
|
||||
def test_묶음(results, group):
|
||||
assert group in results
|
||||
assert results[group] == []
|
||||
Reference in New Issue
Block a user