/* ============================================================================= * 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(); }, }; }