- 추적 화살표(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
194 lines
7.8 KiB
TypeScript
194 lines
7.8 KiB
TypeScript
/* =============================================================================
|
|
* 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();
|
|
},
|
|
};
|
|
}
|