diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 9f068cd6..067f5f1a 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -257,3 +257,52 @@ .ss-hint__item.is-on { background: #ddf4ff; } + +/* 데이터 메뉴 — 패널 창 · 유효성 목록 ▼ */ +.ss-dialog { + position: absolute; + top: 4px; + right: 16px; + z-index: 21; + max-height: calc(100% - 8px); + overflow: auto; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); +} + +.ss-dialog[hidden], +.ss-list-arrow[hidden] { + display: none; +} + +.ss-dialog__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid #d0d7de; + font-weight: bold; +} + +.ss-dialog__body { + padding: 8px; +} + +.ss-list-arrow { + position: absolute; + z-index: 8; + width: 16px; + padding: 0; + border: 1px solid #8c959f; + background: #f6f8fa; + font-size: 9px; + cursor: pointer; +} + +.ss-validation__dropdown { + position: absolute; + z-index: 20; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index f53be2df..ba56a878 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -74,7 +74,7 @@ export interface SpreadsheetHandle { /** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */ function farCorner(sel: Selection): { r: number; c: number } { - const g = sel.범위[0]; + const g = sel.범위[sel.범위.length - 1]; const a = sel.기준; const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last; return { @@ -222,6 +222,7 @@ export function createSpreadsheet( const sheet = ctx.book.시트.find((s) => s.id === sheetId); if (!sheet || sheetId === ctx.selection.시트) return; ed.commit(); + if (ed.editing()) return; // 유효성에 막힘 ctx.book.활성 = sheetId; ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0); grid.render(); 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_autosum.ts b/A00_Common/spreadsheet/spreadsheet_autosum.ts new file mode 100644 index 00000000..defa1eda --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_autosum.ts @@ -0,0 +1,70 @@ +/* ============================================================================= + * spreadsheet_autosum.ts (3단계 · 주인 sub_laptop_4) + * 자동 합계(Alt+= · 도구 단추 Σ) — 엑셀 규칙: 활성 칸 바로 위가 숫자면 그 위 연속 숫자 칸을 + * 위로 죽 잡고, 아니면 바로 왼쪽이 숫자면 왼쪽으로 죽 잡음. 여러 칸을 고른 채면 그 고름 + * 안에서(활성 칸 위 또는 왼쪽만) 잡음. 아무것도 못 찾으면 빈 `=SUM()`. + * + * 잇는 법(D · E · sub7 — 지금은 하나도 안 이어짐): + * 1) `spreadsheet_keys.ts` 에 Alt+= 를 매핑해 `applyAutoSum(ctx)` 를 부름. + * 2) `spreadsheet_toolbar.ts` 에 Σ 단추를 두고 눌리면 `applyAutoSum(ctx)` 를 부름. + * 3) 지금은 칸에 식 글만 바로 넣음(편집기를 안 거침) — 사용자가 범위를 눈으로 고쳐 잡게 하려면 + * `ctx.editor.begin("=SUM(...)")` 로 편집 상태로 열고 커밋은 사용자가 Enter. + * ========================================================================== */ + +import { rangeToA1, toA1 } from "./spreadsheet_address"; +import { isFrac } from "./spreadsheet_functions_math"; +import type { CellRange } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +function isNumericCell(ctx: SpreadsheetContext, sheet: string, r: number, c: number): boolean { + if (r < 0 || c < 0) return false; + return isFrac(ctx.engine.value(sheet, r, c)); +} + +function verticalRun( + ctx: SpreadsheetContext, + sheet: string, + r: number, + c: number, + top: number, +): CellRange | null { + if (r - 1 < top || !isNumericCell(ctx, sheet, r - 1, c)) return null; + let start = r - 1; + while (start - 1 >= top && isNumericCell(ctx, sheet, start - 1, c)) start--; + return { r0: start, c0: c, r1: r - 1, c1: c }; +} + +function horizontalRun( + ctx: SpreadsheetContext, + sheet: string, + r: number, + c: number, + left: number, +): CellRange | null { + if (c - 1 < left || !isNumericCell(ctx, sheet, r, c - 1)) return null; + let start = c - 1; + while (start - 1 >= left && isNumericCell(ctx, sheet, r, start - 1)) start--; + return { r0: r, c0: start, r1: r, c1: c - 1 }; +} + +/** 활성 칸 기준 합산 범위를 찾음 — 없으면 null(빈 `=SUM()`). */ +export function autoSumRange(ctx: SpreadsheetContext): CellRange | null { + const sheet = ctx.selection.시트; + const { r, c } = ctx.selection.활성; + const sel = ctx.selection.범위[0]; + + if (sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0)) { + if (r > sel.r0) return { r0: sel.r0, c0: c, r1: r - 1, c1: c }; + if (c > sel.c0) return { r0: r, c0: sel.c0, r1: r, c1: c - 1 }; + } + + return verticalRun(ctx, sheet, r, c, 0) ?? horizontalRun(ctx, sheet, r, c, 0); +} + +/** 활성 칸에 `=SUM(...)` 을 바로 넣음(칸 명령 — 편집기를 안 거침). */ +export function applyAutoSum(ctx: SpreadsheetContext): void { + const range = autoSumRange(ctx); + const { r, c } = ctx.selection.활성; + const 식 = range ? `SUM(${rangeToA1(range)})` : "SUM()"; + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [toA1(r, c)]: { 식 } } }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts new file mode 100644 index 00000000..4b023d90 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * spreadsheet_data_menu.ts (주인 D) + * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. + * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · + * 조건부 서식 · 데이터 유효성 · 이름 관리(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 보이기/숨기기. + * 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저). + * ========================================================================== */ + +import "./spreadsheet_stage3.css"; // sub4 패널 스타일(잇는 쪽이 붙임) +import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { el } from "@ui/ui_template_elements"; +import { parseRange, toA1 } from "./spreadsheet_address"; +import { createCondFmtPanel } from "./spreadsheet_condfmt"; +import { attachFilter, toggleFilter } from "./spreadsheet_filter"; +import { createNameManagerPanel } from "./spreadsheet_names"; +import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog"; +import { + createValidationDropdown, + createValidationPanel, + findValidation, +} from "./spreadsheet_validation"; +import type { KeyHooks } from "./spreadsheet_keys"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + asc: "오름차순 정렬(활성 칸 열 기준)", + desc: "내림차순 정렬(활성 칸 열 기준)", + filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", + menu: "데이터 ▾", + sort: "정렬…", + filterItem: "자동 필터 켜기 · 끄기", + condfmt: "조건부 서식…", + validation: "데이터 유효성…", + names: "이름 관리…", + grid: "눈금선 보이기 · 숨기기", + close: "닫기", + list: "값 목록(Alt+↓)", +}; + +export interface DataMenu { + /** 수식 입력줄 앞에 둘 단추들 */ + buttons: HTMLElement[]; + keys: Pick; + part: PartHandle; +} + +export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu { + const filter = attachFilter(ctx); + const menu = createMapContextMenu("ss"); + ctx.root.append(menu.element); + let closeSort: (() => void) | null = null; + + const button = (text: string, title: string, run: (b: HTMLElement) => void, keep = false) => { + const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } }); + b.addEventListener("click", () => { + run(b); + if (!keep) back(); + }); + return b; + }; + + // ── 패널 창(조건부 서식 · 유효성 · 이름) — 한 번에 하나 ───────────────────── + const title = el("span", { className: "ss-dialog__title" }); + const body = el("div", { className: "ss-dialog__body" }); + const shut = el("button", { + className: "ss-find__btn", + text: "×", + attrs: { type: "button", title: TEXT.close }, + }); + const dialog = el("div", { + className: "ss-dialog", + attrs: { hidden: "" }, + children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], + }); + ctx.root.append(dialog); + let panel: PartHandle | null = null; + const closePanel = (): void => { + panel?.destroy(); + panel = null; + body.replaceChildren(); + dialog.setAttribute("hidden", ""); + }; + shut.addEventListener("click", () => (closePanel(), back())); + dialog.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + closePanel(); + back(); + }); + function openPanel(name: string, make: (c: SpreadsheetContext) => PartHandle): void { + closePanel(); + panel = make(ctx); + title.textContent = name.replace("…", ""); + if (panel.root) body.append(panel.root); + dialog.removeAttribute("hidden"); + } + + function openMenu(anchor: HTMLElement): void { + const r = anchor.getBoundingClientRect(); + const items: [string, () => void][] = [ + [TEXT.sort, () => ((closeSort = openSortDialog(ctx)), undefined)], + [TEXT.filterItem, () => (toggleFilter(ctx), back())], + [TEXT.condfmt, () => openPanel(TEXT.condfmt, createCondFmtPanel)], + [TEXT.validation, () => openPanel(TEXT.validation, createValidationPanel)], + [TEXT.names, () => openPanel(TEXT.names, createNameManagerPanel)], + [TEXT.grid, () => (toggleGridlines(), back())], + ]; + if (!ctx.readOnly) menu.open(r.left, r.bottom, items); + } + + function toggleGridlines(): void { + const s = ctx.sheet(); + ctx.dispatch({ 종류: "눈금선", 시트: s.id, 보임: s.보기?.눈금선 === false }); + } + + // ── 유효성 목록 — ▼ 단추 · Alt+↓ ───────────────────────────────────────── + let drop: HTMLElement | null = null; + const closeDrop = (): void => { + drop?.remove(); + drop = null; + }; + const arrow = el("button", { + className: "ss-list-arrow", + text: "▼", + attrs: { type: "button", hidden: "", title: TEXT.list }, + }); + arrow.addEventListener("mousedown", (e) => e.preventDefault()); + arrow.addEventListener("click", () => openList()); + ctx.root.append(arrow); + /** 칸 자리(부품 뿌리 기준) */ + const at = (r: number, c: number) => { + const { box } = ctx.grid.editorSlot(r, c); + const g = ctx.grid.root.getBoundingClientRect(); + const o = ctx.root.getBoundingClientRect(); + return { x: box.x + g.left - o.left, y: box.y + g.top - o.top, w: box.w, h: box.h }; + }; + function openList(): boolean { + const { r, c } = ctx.selection.활성; + const rule = findValidation(ctx.sheet(), r, c); + if (ctx.readOnly || !rule) return false; + closeDrop(); + drop = createValidationDropdown(ctx, r, c, rule, (value) => { + closeDrop(); + const key = toA1(r, c); + const n = Number(value); + const 값 = value !== "" && Number.isFinite(n) ? n : value; + const 서식 = ctx.sheet().칸[key]?.서식; + const cell = 서식 === undefined ? { 값 } : { 값, 서식 }; + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: { [key]: cell } }); + back(); + }); + if (!drop) return false; + const p = at(r, c); + drop.style.left = `${p.x}px`; // cellBox(스크롤 전 내용 좌표) 자리를 화면 자리로 고침 + drop.style.top = `${p.y + p.h}px`; + ctx.root.append(drop); + return true; + } + const onOutside = (e: MouseEvent): void => { + if (drop && !drop.contains(e.target as Node) && e.target !== arrow) closeDrop(); + }; + document.addEventListener("mousedown", onOutside, true); + + const buttons = ctx.readOnly + ? [] + : [ + button("↑", TEXT.asc, () => sortSelection(ctx, false)), + button("↓", TEXT.desc, () => sortSelection(ctx, true)), + button("필터", TEXT.filter, () => toggleFilter(ctx)), + button(TEXT.menu, TEXT.menu, openMenu, true), + ]; + + return { + buttons, + keys: { + filter: () => toggleFilter(ctx), + dropdown() { + const f = ctx.sheet().필터; + const g = f && parseRange(f.범위); + const { r, c } = ctx.selection.활성; + if (g && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); + return openList(); + }, + }, + part: { + root: null, + refresh() { + filter.refresh(); + panel?.refresh(); + closeDrop(); + const { r, c } = ctx.selection.활성; + const list = !ctx.readOnly && findValidation(ctx.sheet(), r, c)?.규칙.종류 === "목록"; + arrow.hidden = !list || ctx.editor.editing(); + if (!list) return; + const p = at(r, c); + arrow.style.left = `${p.x + p.w + 1}px`; + arrow.style.top = `${p.y}px`; + arrow.style.height = `${p.h}px`; + }, + destroy() { + document.removeEventListener("mousedown", onOutside, true); + closeSort?.(); + closePanel(); + closeDrop(); + filter.destroy(); + menu.element.remove(); + dialog.remove(); + arrow.remove(); + buttons.forEach((b) => b.remove()); + }, + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index ed3b9e90..5d5736b8 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -8,12 +8,14 @@ * 수식 입력줄(방향키가 글자 사이를 옮김). F2 가 둘을 바꿈. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { moveFormula } from "./spreadsheet_refshift"; import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help"; +import { defineName, resolveName } from "./spreadsheet_names"; import { tokenize } from "./spreadsheet_parser"; import { cellsOf, move, selectCell } from "./spreadsheet_selection"; +import { checkValue, findValidation } from "./spreadsheet_validation"; import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types"; import type { CellBox, @@ -329,10 +331,12 @@ export function createEditor(ctx: SpreadsheetContext): Editor { focus(); } - function write(cells: CellAddress[], value: string, from: CellAddress): void { - if (!st) return; + /** 칸에 적음 — 유효성(sub4) 「막음」 을 어기면 안 적고 false(편집 그대로 · 엑셀처럼 다시 치게) */ + function write(cells: CellAddress[], value: string, from: CellAddress): boolean { + if (!st) return true; const sheet = sheetOf(st.sheet); const out: Record = {}; + const warn = new Set(); for (const a of cells) { let v = value; if (v.length > 1 && v[0] === "=" && (a.r !== from.r || a.c !== from.c)) @@ -340,21 +344,29 @@ export function createEditor(ctx: SpreadsheetContext): Editor { const key = toA1(a.r, a.c); const next = textToCell(v, sheet.칸[key]); if (next === null && !sheet.칸[key]) continue; + const rule = next?.값 !== undefined ? findValidation(sheet, a.r, a.c) : undefined; + const check = rule && checkValue(rule, next!.값!); + if (check && !check.ok) { + if (check.어기면 === "막음") return (showToast(check.메시지, "error"), false); + warn.add(check.메시지); + } out[key] = next; } + for (const m of warn) showToast(m, "warning"); if (Object.keys(out).length) ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: out }); + return true; } function commit(): void { if (!st) return; const value = text(); - if (value !== st.original) write([st.at], value, st.at); + if (value !== st.original && !write([st.at], value, st.at)) return; end(); } function commitAll(): void { if (!st) return; - write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at); + if (!write(cellsOf(sheetOf(st.sheet), ctx.selection), text(), st.at)) return; end(); } @@ -441,6 +453,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor { focus(); }); + /** 이름 상자(sub4) — 있는 이름이면 그 범위로 · 없으면 지금 고름에 이름을 붙임(엑셀) */ + function nameBox(name: string): void { + const hit = resolveName(ctx.book, ctx.selection.시트, name); + if (hit) { + if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트); + const at = { r: hit.범위.r0, c: hit.범위.c0 }; + return ctx.select({ 시트: hit.시트, 범위: [hit.범위], 활성: at, 기준: { ...at } }, true); + } + const why = ctx.readOnly ? null : defineName(ctx, name, ctx.selection.범위[0]); + if (why) showToast(why, "error"); + sync(); + } + function goTo(value: string): void { const bang = value.lastIndexOf("!"); let sheet = ctx.sheet(); @@ -454,7 +479,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { const body = value.slice(bang + 1).replace(/\$/g, ""); const one = parseA1(body); const g = parseRange(body); - if (!g) return sync(); + if (!g) return nameBox(value.trim()); if (one) return ctx.select(selectCell(sheet, one.r, one.c), true); const at = { r: g.r0, c: g.c0 }; ctx.select({ 시트: sheet.id, 범위: [g], 활성: at, 기준: { ...at } }, true); 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_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts index 47bc79f1..863a8d27 100644 --- a/A00_Common/spreadsheet/spreadsheet_extras.ts +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -1,34 +1,30 @@ /* ============================================================================= * spreadsheet_extras.ts (주인 D) * 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) · - * 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) · - * 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓). + * 셀 서식 창(Ctrl+1) · 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) · + * 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 데이터 단추 · 메뉴는 `spreadsheet_data_menu.ts`. * 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; -import { parseRange } from "./spreadsheet_address"; import { parseClipboard } from "./spreadsheet_clipboard"; import { attachComments, getComment, setComment } from "./spreadsheet_comments"; +import { mountDataMenu } from "./spreadsheet_data_menu"; import type { Editor } from "./spreadsheet_editor"; -import { attachFilter, toggleFilter } from "./spreadsheet_filter"; import { mountFindPanel } from "./spreadsheet_find_panel"; +import { openFormatDialog } from "./spreadsheet_format_dialog"; import { createFormatPainter } from "./spreadsheet_format_painter"; import type { KeyHooks } from "./spreadsheet_keys"; import type { MouseHooks } from "./spreadsheet_mouse"; import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special"; -import { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; const TEXT = { + format: "셀 서식(Ctrl+1)", painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속", pasteTitle: "골라 붙여넣기", modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record, memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소", - sortAsc: "오름차순 정렬(활성 칸 열 기준)", - sortDesc: "내림차순 정렬(활성 칸 열 기준)", - sortDialog: "정렬 — 기준 여럿 · 머리 행", - filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)", }; export interface Extras { @@ -113,34 +109,29 @@ export function attachExtras( showBrush(); back(); }); - if (!ctx.readOnly) ed.bar.prepend(brush); - - // ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ─────────────────────── - const filter = attachFilter(ctx); - let closeSort: (() => void) | null = null; - const dataButton = (text: string, title: string, run: () => void, dialog = false) => { - const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } }); - b.addEventListener("click", () => { - run(); - if (!dialog) back(); + // 셀 서식 창 — Ctrl+1 을 브라우저 · ORCA 가 가로챌 때 누를 자리 + const fmt = el("button", { + className: "ss-brush", + text: "서식…", + attrs: { type: "button", title: TEXT.format }, + }); + /** 창이 닫히면(확인 · 취소) 초점을 격자로 */ + function openFormat(): void { + if (ctx.readOnly) return; + openFormatDialog(ctx); + const watch = new MutationObserver(() => { + if (document.querySelector(".ss-fmtdlg")) return; + watch.disconnect(); + back(); }); - return b; - }; - const data = [ - dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)), - dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)), - dataButton( - "정렬…", - TEXT.sortDialog, - () => { - closeSort?.(); - closeSort = openSortDialog(ctx); - }, - true, - ), - dataButton("필터", TEXT.filter, () => toggleFilter(ctx)), - ]; - if (!ctx.readOnly) brush.after(...data); + watch.observe(document.body, { childList: true }); + } + fmt.addEventListener("click", openFormat); + if (!ctx.readOnly) ed.bar.prepend(fmt, brush); + + // ── 데이터 단추 · 메뉴(정렬 · 필터 · 조건부 서식 · 유효성 · 이름 · 눈금선) ───────── + const data = mountDataMenu(ctx, back); + brush.after(...data.buttons); // ── 골라 붙여넣기 ───────────────────────────────────────────────────────── let armed = false; @@ -201,14 +192,13 @@ export function attachExtras( root: null, refresh: () => showBrush(), destroy() { - closeSort?.(); - data.forEach((b) => b.remove()); ctx.root.removeEventListener("paste", onPaste, true); toolbarRoot?.removeEventListener("click", onToolbarClick); toolbarRoot?.removeEventListener("change", back); memo.remove(); picker.remove(); brush.remove(); + fmt.remove(); }, }; @@ -217,15 +207,8 @@ export function attachExtras( find: (replace) => find.open(replace), pasteSpecial: () => (armed = true), comment: editMemo, - filter: () => toggleFilter(ctx), - dropdown() { - const f = ctx.sheet().필터; - const g = f && parseRange(f.범위); - const { r, c } = ctx.selection.활성; - if (!g || r !== g.r0 || c < g.c0 || c > g.c1) return false; - filter.open(c); - return true; - }, + formatDialog: openFormat, + ...data.keys, escape() { painter.clear(); sticky = false; @@ -248,6 +231,6 @@ export function attachExtras( put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침 }, }, - parts: [find, comments, filter, self], + parts: [find, comments, data.part, self], }; } diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index a92ef894..4211ccf6 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -1,8 +1,9 @@ /* ============================================================================= * spreadsheet_functions.ts (주인 B) - * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에 - * 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의 - * `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침). + * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` · + * `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` · + * `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 — + * 그 파일은 sub1 몫이라 안 고침). * 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음). * ========================================================================== */ @@ -10,11 +11,13 @@ import type { SheetFunction } from "./spreadsheet_types"; import { MATH_FUNCTIONS } from "./spreadsheet_functions_math"; import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc"; import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup"; +import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat"; const TABLE: Record = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS, ...LOOKUP_FUNCTIONS, + ...STAT_FUNCTIONS, }; export function getFunction(name: string): SheetFunction | undefined { @@ -119,3 +122,33 @@ export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [ { 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" }, { 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" }, ]; + +/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */ +export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [ + { 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" }, + { 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" }, + { 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" }, + { 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" }, + { 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" }, + { 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" }, + { 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" }, + { 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" }, + { 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" }, + { 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" }, + { 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" }, + { 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" }, + { 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" }, + { 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" }, + { 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" }, + { 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" }, + { 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" }, + { 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" }, + { + 이름: "NETWORKDAYS", + 꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])", + 뜻: "평일 일수(토·일 · 휴일 뺌)", + }, + { 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" }, + { 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" }, + { 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" }, +]; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts index dd33073f..5f3bcfc3 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -102,6 +102,58 @@ function dateToSerial(d: Date): number { return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; } +/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */ +function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue { + const start = serialToDate(startSerial); + const end = serialToDate(endSerial); + if (end.getTime() < start.getTime()) return mkErr("#NUM!"); + const u = unit.toUpperCase(); + const sy = start.getUTCFullYear(); + const sm = start.getUTCMonth(); + const sd = start.getUTCDate(); + const ey = end.getUTCFullYear(); + const em = end.getUTCMonth(); + const ed = end.getUTCDate(); + if (u === "Y") { + let y = ey - sy; + if (em < sm || (em === sm && ed < sd)) y--; + return y; + } + if (u === "M") { + let m = (ey - sy) * 12 + (em - sm); + if (ed < sd) m--; + return m; + } + if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY); + if (u === "YM") { + let m = em - sm; + if (ed < sd) m--; + return ((m % 12) + 12) % 12; + } + if (u === "MD") { + let d = ed - sd; + if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate(); + return d; + } + if (u === "YD") { + let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY); + if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY); + return days; + } + return mkErr("#NUM!"); +} + +/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */ +function timeParts(serial: number): { h: number; mnt: number; s: number } { + const dayFrac = serial - Math.floor(serial); + const totalSeconds = Math.round(dayFrac * 86400); + return { + h: Math.floor(totalSeconds / 3600) % 24, + mnt: Math.floor(totalSeconds / 60) % 60, + s: totalSeconds % 60, + }; +} + // ── 함수 표 ────────────────────────────────────────────────────────────── export const MISC_FUNCTIONS: Record = { @@ -466,4 +518,139 @@ export const MISC_FUNCTIONS: Record = { return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, + NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) }, + WEEKDAY: { + min: 1, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const typeArg = args.length > 1 ? num(args[1]()) : ONE; + if (isErr(typeArg)) return typeArg; + const type = Math.trunc(fracToDouble(typeArg)); + const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토 + if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1)); + if (type === 3) return frac(BigInt((dow + 6) % 7)); + return frac(BigInt(dow + 1)); + }, + }, + EDATE: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const months = Math.trunc(fracToDouble(mArg)); + const d = serialToDate(fracToDouble(n)); + const targetMonth = d.getUTCMonth() + months; + // 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음). + const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate(); + const day = Math.min(d.getUTCDate(), lastDay); + const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day)); + return fromDouble(dateToSerial(date)); + }, + }, + EOMONTH: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const months = Math.trunc(fracToDouble(mArg)); + const d = serialToDate(fracToDouble(n)); + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0)); + return fromDouble(dateToSerial(date)); + }, + }, + DATEDIF: { + min: 3, + max: 3, + call: (args) => { + const s = num(args[0]()); + if (isErr(s)) return s; + const e = num(args[1]()); + if (isErr(e)) return e; + const unit = str(args[2]()); + if (isErr(unit)) return unit; + const result = dateDiff(fracToDouble(s), fracToDouble(e), unit); + return isErr(result) ? result : frac(BigInt(result)); + }, + }, + NETWORKDAYS: { + min: 2, + max: 3, + call: (args) => { + const s = num(args[0]()); + if (isErr(s)) return s; + const e = num(args[1]()); + if (isErr(e)) return e; + let startSerial = Math.round(fracToDouble(s)); + let endSerial = Math.round(fracToDouble(e)); + let sign = 1; + if (startSerial > endSerial) { + [startSerial, endSerial] = [endSerial, startSerial]; + sign = -1; + } + const holidays = new Set(); + if (args.length > 2) { + const holidayArg = args[2](); + if (isRange(holidayArg)) { + for (let r = 0; r < holidayArg.rows; r++) { + for (let c = 0; c < holidayArg.cols; c++) { + const cell = holidayArg.at(r, c); + if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell))); + } + } + } else if (isFrac(holidayArg)) { + holidays.add(Math.round(fracToDouble(holidayArg))); + } + } + let count = 0; + for (let day = startSerial; day <= endSerial; day++) { + const dow = serialToDate(day).getUTCDay(); + if (dow === 0 || dow === 6) continue; + if (holidays.has(day)) continue; + count++; + } + return frac(BigInt(sign * count)); + }, + }, + HOUR: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h)); + }, + }, + MINUTE: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt)); + }, + }, + TIME: { + min: 3, + max: 3, + call: (args) => { + const hArg = num(args[0]()); + if (isErr(hArg)) return hArg; + const mArg = num(args[1]()); + if (isErr(mArg)) return mArg; + const sArg = num(args[2]()); + if (isErr(sArg)) return sArg; + const totalSeconds = + Math.trunc(fracToDouble(hArg)) * 3600 + + Math.trunc(fracToDouble(mArg)) * 60 + + Math.trunc(fracToDouble(sArg)); + const dayFraction = ((totalSeconds % 86400) + 86400) % 86400; + return fromDouble(dayFraction / 86400); + }, + }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_stat.ts b/A00_Common/spreadsheet/spreadsheet_functions_stat.ts new file mode 100644 index 00000000..b64e73e5 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_stat.ts @@ -0,0 +1,228 @@ +/* ============================================================================= + * spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27) + * 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL · + * PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. + * ========================================================================== */ + +import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac"; +import type { + ErrorValue, + EvalContext, + EvalResult, + LazyArg, + RangeValue, + SheetFunction, +} from "./spreadsheet_types"; +import { + collectNumbers, + fracToDouble, + fromDouble, + isErr, + isFrac, + isRange, + mkErr, + num, +} from "./spreadsheet_functions_math"; + +function rangeNumbers(range: RangeValue): Frac[] | ErrorValue { + const out: Frac[] = []; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + const cell = range.at(r, c); + if (isErr(cell)) return cell; + if (isFrac(cell)) out.push(cell); + } + } + return out; +} + +function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } { + const n = frac(BigInt(values.length)); + const mean = div(values.reduce(add, ZERO), n); + const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO); + return { mean, sumSq }; +} + +function sampleVariance(values: Frac[]): Frac | ErrorValue { + if (values.length < 2) return mkErr("#DIV/0!"); + return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1))); +} + +function populationVariance(values: Frac[]): Frac | ErrorValue { + if (values.length < 1) return mkErr("#DIV/0!"); + return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length))); +} + +function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue { + if (isErr(variance)) return variance; + return fromDouble(Math.sqrt(fracToDouble(variance))); +} + +/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */ +function percentileOf(values: Frac[], k: number): Frac | ErrorValue { + if (values.length === 0) return mkErr("#NUM!"); + if (k < 0 || k > 1) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + const pos = k * (sorted.length - 1); + const lowIdx = Math.floor(pos); + const highIdx = Math.ceil(pos); + if (lowIdx === highIdx) return sorted[lowIdx]; + const weight = fromDouble(pos - lowIdx); + if (isErr(weight)) return weight; + return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx]))); +} + +function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue { + const values = rangeNumbers(range); + if (isErr(values)) return values; + if (k < 1 || k > values.length) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + return sorted[largest ? sorted.length - k : k - 1]; +} + +function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult { + const target = num(args[0]()); + if (isErr(target)) return target; + const range = args[1](); + if (!isRange(range)) return mkErr("#VALUE!"); + const orderArg = args.length > 2 ? num(args[2]()) : ZERO; + if (isErr(orderArg)) return orderArg; + const ascending = cmp(orderArg, ZERO) !== 0; + let rank = 1; + let found = false; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + const cell = range.at(r, c); + if (!isFrac(cell)) continue; + if (cmp(cell, target) === 0) found = true; + if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++; + } + } + return found ? frac(BigInt(rank)) : mkErr("#N/A"); +} + +export const STAT_FUNCTIONS: Record = { + MEDIAN: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + if (values.length === 0) return mkErr("#NUM!"); + const sorted = [...values].sort((a, b) => cmp(a, b)); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[mid]; + return div(add(sorted[mid - 1], sorted[mid]), frac(2n)); + }, + }, + MODE: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + if (isErr(values)) return values; + let bestIdx = -1; + let bestCount = 1; + for (let i = 0; i < values.length; i++) { + let count = 0; + for (const v of values) if (cmp(values[i], v) === 0) count++; + if (count > bestCount) { + bestCount = count; + bestIdx = i; + } + } + return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx]; + }, + }, + STDEV: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(sampleVariance(values)); + }, + }, + "STDEV.S": { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(sampleVariance(values)); + }, + }, + "STDEV.P": { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : stdevOf(populationVariance(values)); + }, + }, + VAR: { + min: 1, + call: (args) => { + const values = collectNumbers(args, true, true); + return isErr(values) ? values : sampleVariance(values); + }, + }, + RANK: { min: 2, max: 3, call: rankCall }, + "RANK.EQ": { min: 2, max: 3, call: rankCall }, + LARGE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true); + }, + }, + SMALL: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false); + }, + }, + PERCENTILE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const kArg = num(args[1]()); + if (isErr(kArg)) return kArg; + const values = rangeNumbers(range); + if (isErr(values)) return values; + return percentileOf(values, fracToDouble(kArg)); + }, + }, + QUARTILE: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const qArg = num(args[1]()); + if (isErr(qArg)) return qArg; + const q = Math.trunc(fracToDouble(qArg)); + if (q < 0 || q > 4) return mkErr("#NUM!"); + const values = rangeNumbers(range); + if (isErr(values)) return values; + return percentileOf(values, q / 4); + }, + }, + COUNTBLANK: { + min: 1, + max: 1, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++; + } + return frac(BigInt(count)); + }, + }, +}; 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_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 4963d5b3..9aec9cba 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -576,6 +576,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { return { x, y, w: box.w, h: box.h }; } + /** `overlay` 안 자리 — `overlay` 자신이 이미 머리 칸(headColW·headRowH)만큼 밀려 있어 + * `screenBoxOf`(격자 뿌리 기준)를 그대로 쓰면 머리 칸만큼 두 번 밀림(찾은 버그 · 2026-09-27). */ + function overlayBoxOf(r: number, c: number): CellBox { + const box = boxOf(r, c); + const inFrozenCol = c < frozenCols; + const inFrozenRow = r < frozenRows; + return { + x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft, + y: inFrozenRow ? box.y : box.y - paneBR.scrollTop, + w: box.w, + h: box.h, + }; + } + function renderSelection(): void { let selLayer = overlay.querySelector(".aislo-grid__sel-layer"); if (!selLayer) { @@ -596,8 +610,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenCol = range.c0 < frozenCols; const inFrozenRow = range.r0 < frozenRows; const screen: CellBox = { - x: headColW() + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft), - y: headRowH() + (inFrozenRow ? box.y : box.y - paneBR.scrollTop), + x: inFrozenCol ? box.x : box.x - paneBR.scrollLeft, + y: inFrozenRow ? box.y : box.y - paneBR.scrollTop, w: box.w, h: box.h, }; @@ -611,13 +625,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { node.style.borderWidth = i === 0 ? "2px" : "1px"; selLayer!.append(node); } - const active = boxOf(sel.활성.r, sel.활성.c); - const inFrozenCol = sel.활성.c < frozenCols; - const inFrozenRow = sel.활성.r < frozenRows; + const active = overlayBoxOf(sel.활성.r, sel.활성.c); const handle = document.createElement("div"); handle.className = "aislo-grid__fill-handle"; - handle.style.left = `${headColW() + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`; - handle.style.top = `${headRowH() + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`; + handle.style.left = `${active.x + active.w - 4}px`; + handle.style.top = `${active.y + active.h - 4}px`; selLayer!.append(handle); } @@ -643,8 +655,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const inFrozenRow = ref.범위.r0 < frozenRows; const node = document.createElement("div"); node.className = "aislo-grid__ref-highlight"; - node.style.left = `${headColW() + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`; - node.style.top = `${headRowH() + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`; + node.style.left = `${inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft}px`; + node.style.top = `${inFrozenRow ? merged.y : merged.y - paneBR.scrollTop}px`; node.style.width = `${merged.w}px`; node.style.height = `${merged.h}px`; node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length]; diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index a23180e1..b90a88b0 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -41,6 +41,8 @@ export interface KeyHooks { comment?(): void; /** 고름 중 Esc(서식 붓 끄기 등) */ escape?(): void; + /** Ctrl+1 셀 서식 창 */ + formatDialog?(): void; /** Ctrl+Shift+L 필터 켜기 · 끄기 */ filter?(): void; /** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */ @@ -68,12 +70,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } if (ctrl) return (ed.commitAll(), true); ed.commit(); + if (ed.editing()) return true; // 유효성에 막힘 next(e.shiftKey ? -1 : 1, 0); return true; } case "Tab": if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성 ed.commit(); + if (ed.editing()) return true; next(0, e.shiftKey ? -1 : 1); return true; case "Escape": @@ -92,7 +96,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } if (e.shiftKey || ctrl) return false; // 글자 고르기 · 낱말 건너기는 textarea 에 맡김 ed.commit(); - go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); + if (!ed.editing()) go(move(ctx.sheet(), ctx.selection, arrow[0], arrow[1])); return true; } @@ -140,6 +144,10 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks if (!hooks.find) return false; hooks.find(k === "h"); return true; + case "1": + if (!hooks.formatDialog) return false; + hooks.formatDialog(); + return true; case "l": if (!e.shiftKey || !hooks.filter) return false; hooks.filter(); diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 9100497f..4d5c03b8 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -106,7 +106,7 @@ export function attachMouse( function fillDrag(): void { const sheet = ctx.sheet(); - const src = ctx.selection.범위[0]; + const src = ctx.selection.범위[ctx.selection.범위.length - 1]; let result = { target: src, clear: null as CellRange | null }; drag( (p) => { @@ -186,6 +186,7 @@ export function attachMouse( return; } ed.commit(); + if (ed.editing()) return; // 유효성에 막힘 } ed.focus(); if (ctx.readOnly && hit.kind === "fillHandle") hit.kind = "cell"; @@ -205,9 +206,17 @@ export function attachMouse( return drag((p) => ctx.select(selectCols(sheet, a, p.c, a))); } case "cell": { - const first = e.shiftKey + // Ctrl = 범위 더함(여러 범위) · Shift = 마지막 범위를 늘림 + const keep = + e.ctrlKey || e.metaKey + ? ctx.selection.범위 + : e.shiftKey + ? ctx.selection.범위.slice(0, -1) + : []; + const one = e.shiftKey ? selectSpan(sheet, ctx.selection.기준, hit) : selectCell(sheet, hit.r, hit.c); + const first = { ...one, 범위: [...keep, ...one.범위] }; ctx.select(first); const anchor = first.기준; let last = `${hit.r},${hit.c}`; @@ -215,7 +224,8 @@ export function attachMouse( (p) => { if (`${p.r},${p.c}` === last) return; last = `${p.r},${p.c}`; - ctx.select(selectSpan(sheet, anchor, p)); + const span = selectSpan(sheet, anchor, p); + ctx.select({ ...span, 범위: [...keep, ...span.범위] }); }, () => hooks.selected?.(), ); diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index b6533dff..1c9fcb4f 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -3,7 +3,8 @@ * 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) · * 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`. * 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함. - * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로). + * 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄 + * (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로). * ========================================================================== */ import { @@ -364,6 +365,93 @@ function generalFormat(value: Frac): string { return fracToString(value); } +// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ─────────────────────── +// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸. + +const MS_PER_DAY = 86400000; +const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; +const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"]; + +function toDouble(x: Frac): number { + return Number(x.n) / Number(x.d); +} + +function serialToDateUTC(serial: number): Date { + return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); +} + +type DatePart = + { k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 }; + +/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */ +function detectDateFormat(s: string): DatePart[] | null { + const parts: DatePart[] = []; + let hasDateToken = false; + let i = 0; + while (i < s.length) { + const ch = s[i]; + if (ch === '"') { + let j = i + 1; + let text = ""; + while (j < s.length && s[j] !== '"') { + text += s[j]; + j++; + } + parts.push({ k: "lit", text }); + i = j + 1; + continue; + } + if (ch === "\\" && i + 1 < s.length) { + parts.push({ k: "lit", text: s[i + 1] }); + i += 2; + continue; + } + const lower = ch.toLowerCase(); + if (lower === "y" || lower === "m" || lower === "d" || lower === "a") { + let j = i; + while (j < s.length && s[j].toLowerCase() === lower) j++; + const width = j - i; + if (lower === "a" && (width === 3 || width === 4)) { + parts.push({ k: "a", width }); + hasDateToken = true; + } else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) { + parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 }); + hasDateToken = true; + } else { + parts.push({ k: "lit", text: s.slice(i, j) }); + } + i = j; + continue; + } + parts.push({ k: "lit", text: ch }); + i++; + } + return hasDateToken ? parts : null; +} + +function renderDatePart(part: DatePart, date: Date): string { + if (part.k === "lit") return part.text; + if (part.k === "y") { + const y = date.getUTCFullYear(); + return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0"); + } + if (part.k === "m") { + const m = date.getUTCMonth() + 1; + return part.width === 1 ? String(m) : String(m).padStart(2, "0"); + } + if (part.k === "d") { + const d = date.getUTCDate(); + return part.width === 1 ? String(d) : String(d).padStart(2, "0"); + } + const wd = WEEKDAY_SHORT[date.getUTCDay()]; + return part.width === 3 ? wd : `${wd}요일`; +} + +function renderDateSection(parts: DatePart[], value: Frac): string { + const date = serialToDateUTC(toDouble(value)); + return parts.map((p) => renderDatePart(p, date)).join(""); +} + function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } { const isNeg = cmp(value, ZERO) < 0; const isZero = cmp(value, ZERO) === 0; @@ -385,6 +473,12 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText { const sections = splitSections(code); const { code: chosen, forceMinus } = chooseSection(sections, value); const { rest, color } = extractColor(chosen); + const datePlan = detectDateFormat(rest); + if (datePlan) { + const extra: Partial = {}; + if (color) extra.색 = color; + return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra }; + } const { toks, fillChar } = tokenize(rest); const extra: Partial = {}; if (color) extra.색 = color; 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_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 779324dc..310846e5 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -202,7 +202,7 @@ export function move( if (to.r === edge.r && to.c === edge.c) return selectCell(sheet, from.r, from.c); return selectCell(sheet, to.r, to.c); } - const g = sel.범위[0] ?? span(sel.활성, sel.활성); + const g = sel.범위[sel.범위.length - 1] ?? span(sel.활성, sel.활성); // 여러 범위면 마지막(활성) 범위 const anchor = sel.기준; // 움직이는 끝 = 닻에서 먼 쪽 const far = { @@ -218,7 +218,7 @@ export function move( } return { 시트: sheet.id, - 범위: [expand(span(anchor, to), list)], + 범위: [...sel.범위.slice(0, -1), expand(span(anchor, to), list)], 활성: { ...sel.활성 }, 기준: { ...anchor }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts b/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts new file mode 100644 index 00000000..a254ed7d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sheet_extra.ts @@ -0,0 +1,113 @@ +/* ============================================================================= + * spreadsheet_sheet_extra.ts (3단계 · 주인 sub_laptop_4) + * 시트 숨기기 · 보이기 · 시트 탭 색. 저장 자리 `Sheet.숨김` · `Sheet.탭색`(0 계약에 방금 더함). + * 이 파일은 관리 창(전체 시트 목록 + 숨김 · 색 조작)만 그림 — 실제 탭 줄(`spreadsheet_tabs.ts`, + * E 파일)을 안 건드림. + * + * 잇는 법(E `spreadsheet_tabs.ts` · sub7 — 지금은 하나도 안 이어짐): + * 1) `mountTabs` 의 `render()` 가 `ctx.book.시트` 를 돌 때 `숨김` 인 시트는 탭을 안 그림. + * 2) 탭 엘리먼트에 `sheet.탭색` 이 있으면 밑줄 · 배경을 그 색으로(엑셀처럼). + * 3) 숨은 시트를 다시 보이려면 탭 우클릭 메뉴에 [숨긴 시트 보이기] 항목을 두고 이 파일의 + * `createSheetExtraPanel(ctx)` 를 열거나, `showSheet(ctx, id)` 를 바로 부름. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { Sheet } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함. + +function applySheet(ctx: SpreadsheetContext, sheetId: string, next: Sheet): void { + ctx.dispatch({ 종류: "시트통째", 시트: { ...next, id: sheetId } }); +} + +/** 시트를 숨김 — 마지막 보이는 시트는 못 숨김(까닭 글 돌려줌 · 되면 null). 활성 시트를 숨기면 + * 다른 보이는 시트로 활성을 옮김. */ +export function hideSheet(ctx: SpreadsheetContext, id: string): string | null { + const others = ctx.book.시트.filter((s) => s.id !== id && !s.숨김); + if (!others.length) return "마지막 보이는 시트는 못 숨김"; + const sheet = ctx.book.시트.find((s) => s.id === id); + if (!sheet || sheet.숨김) return null; + applySheet(ctx, id, { ...sheet, 숨김: true }); + if (ctx.book.활성 === id) ctx.showSheet(others[0].id); + return null; +} + +/** 숨긴 시트를 다시 보임. */ +export function unhideSheet(ctx: SpreadsheetContext, id: string): void { + const sheet = ctx.book.시트.find((s) => s.id === id); + if (!sheet || !sheet.숨김) return; + applySheet(ctx, id, { ...sheet, 숨김: false }); +} + +/** 탭 색 — `color` 가 null 이면 색을 지움(기본 탭 색으로). */ +export function setSheetTabColor(ctx: SpreadsheetContext, id: string, color: string | null): void { + const sheet = ctx.book.시트.find((s) => s.id === id); + if (!sheet) return; + const next: Sheet = { ...sheet }; + if (color) next.탭색 = color; + else delete next.탭색; + applySheet(ctx, id, next); +} + +/** 시트 관리 창 — 전체 시트 목록 + 숨김 토글 · 탭 색 고르기. */ +export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle { + const root = el("div", { className: "ss-sheetextra" }); + const list = el("ul", { className: "ss-sheetextra__list" }); + let error: HTMLElement | null = null; + + const showError = (msg: string | null): void => { + error?.remove(); + error = null; + if (!msg) return; + error = el("p", { text: msg }); + error.style.color = "var(--color-danger, red)"; + root.append(error); + }; + + function render(): void { + list.replaceChildren( + ...ctx.book.시트.map((sheet) => { + const li = el("li", { className: "ss-sheetextra__row" }); + const label = el("span", { text: sheet.이름 + (sheet.숨김 ? "(숨김)" : "") }); + const colorInput = el("input", { + attrs: { type: "color", value: sheet.탭색 ?? "#ffffff" }, + }) as HTMLInputElement; + colorInput.addEventListener("change", () => + setSheetTabColor(ctx, sheet.id, colorInput.value), + ); + const colorClear = createButton({ label: "색 지움", variant: "ghost" }); + colorClear.addEventListener("click", () => { + setSheetTabColor(ctx, sheet.id, null); + render(); + }); + const toggle = createButton({ + label: sheet.숨김 ? "보이기" : "숨기기", + variant: "ghost", + }); + toggle.addEventListener("click", () => { + if (sheet.숨김) { + unhideSheet(ctx, sheet.id); + showError(null); + } else { + showError(hideSheet(ctx, sheet.id)); + } + render(); + }); + li.append(label, colorInput, colorClear, toggle); + return li; + }), + ); + } + + root.append(el("h4", { text: "시트 관리" }), list); + render(); + + return { + root, + refresh: render, + destroy(): void { + root.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css index 9f7d71cd..68cf46de 100644 --- a/A00_Common/spreadsheet/spreadsheet_stage3.css +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -1,11 +1,13 @@ /* ============================================================================= * spreadsheet_stage3.css (주인 sub_laptop_4) - * 조건부 서식 · 데이터 유효성 · 이름 정의 관리 창 — 화면 스타일 한 벌. 색 · 간격은 테마 토큰만. + * 조건부 서식 · 데이터 유효성 · 이름 정의 · 상태 표시줄 · 시트 관리 창 — 화면 스타일 한 벌. + * 색 · 간격은 테마 토큰만. * ========================================================================== */ .ss-condfmt, .ss-validation, -.ss-names { +.ss-names, +.ss-sheetextra { display: flex; flex-direction: column; gap: var(--spacing-8); @@ -18,7 +20,8 @@ .ss-condfmt__list, .ss-validation__list, -.ss-names__list { +.ss-names__list, +.ss-sheetextra__list { list-style: none; margin: 0; padding: 0; @@ -29,7 +32,8 @@ .ss-condfmt__row, .ss-validation__row, -.ss-names__row { +.ss-names__row, +.ss-sheetextra__row { display: flex; align-items: center; gap: var(--spacing-4); @@ -85,3 +89,23 @@ .ss-validation__dropdown-item:hover { background: var(--color-mist-violet); } + +.ss-statusbar { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: 2px var(--spacing-8); + background: var(--color-surface); + border-top: 1px solid var(--color-border); + font-size: 0.8rem; +} + +.ss-statusbar__stats { + display: flex; + gap: var(--spacing-8); + flex: 1; +} + +.ss-statusbar__zoom { + color: var(--color-border); +} diff --git a/A00_Common/spreadsheet/spreadsheet_statusbar.ts b/A00_Common/spreadsheet/spreadsheet_statusbar.ts new file mode 100644 index 00000000..fb7ce797 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_statusbar.ts @@ -0,0 +1,122 @@ +/* ============================================================================= + * spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4) + * 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대. + * 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐). + * 확대 비율 자리는 칸만 둠 — 실제 격자 확대와는 아직 안 이어짐(아래 「잇는 법」 2). + * + * 잇는 법(D · C · sub7 — 지금은 하나도 안 이어짐): + * 1) `spreadsheet.ts` 가 `createStatusBar(ctx)` 를 한 번 부르고 `root` 를 화면 맨 아래 붙임. + * `ctx.select()` 뒤(그리고 `dispatch` 로 값이 바뀐 뒤) `refresh()` 를 부름 — 스스로 구독 안 함. + * 2) 확대 비율을 실제로 쓰려면 `GridHandle` 에 `zoom()` · `setZoom()` 을 계약(0)에 더하고(격자 C 몫), + * 이 파일의 확대 자리를 그 값으로 채우고 바꾸면 `setZoom` 을 부르게 이음. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { isFrac, fracToDouble } from "./spreadsheet_functions_math"; +import type { CellRange, Scalar } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +// css 는 stage3.css 에 얹음(잇는 법 머리 참고) — 실제로 붙일 때 `import "./spreadsheet_stage3.css";` 더함. + +export type StatKey = "합계" | "평균" | "개수" | "숫자개수" | "최소" | "최대"; + +export const STAT_KEYS: StatKey[] = ["합계", "평균", "개수", "숫자개수", "최소", "최대"]; + +export interface SelectionStats { + 합계: number | null; + 평균: number | null; + 개수: number; + 숫자개수: number; + 최소: number | null; + 최대: number | null; +} + +/** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */ +export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats { + const sheet = ctx.selection.시트; + const nums: number[] = []; + let 개수 = 0; + const seen = new Set(); + + const visit = (range: CellRange): void => { + for (let r = range.r0; r <= range.r1; r++) { + for (let c = range.c0; c <= range.c1; c++) { + const key = r * 1_000_000 + c; + if (seen.has(key)) continue; + seen.add(key); + const value: Scalar = ctx.engine.value(sheet, r, c); + if (value === null) continue; + 개수++; + if (isFrac(value)) nums.push(fracToDouble(value)); + } + } + }; + for (const range of ctx.selection.범위) visit(range); + + return { + 합계: nums.length ? nums.reduce((a, b) => a + b, 0) : null, + 평균: nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null, + 개수, + 숫자개수: nums.length, + 최소: nums.length ? Math.min(...nums) : null, + 최대: nums.length ? Math.max(...nums) : null, + }; +} + +function statText(key: StatKey, stats: SelectionStats): string { + const value = stats[key]; + if (key === "개수" || key === "숫자개수") return `${key}: ${value}`; + return `${key}: ${value === null ? "—" : formatNumber(value)}`; +} + +function formatNumber(n: number): string { + return Number.isInteger(n) + ? String(n) + : n.toLocaleString(undefined, { maximumFractionDigits: 4 }); +} + +/** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */ +export function createStatusBar(ctx: SpreadsheetContext): PartHandle { + const root = el("div", { className: "ss-statusbar" }); + const stats = el("div", { className: "ss-statusbar__stats" }); + const zoom = el("span", { className: "ss-statusbar__zoom", text: "확대 100%" }); + const menu = createMapContextMenu("ss-statusbar"); + root.append(stats, zoom, menu.element); + + let visible = new Set(STAT_KEYS); + + function render(): void { + const s = computeSelectionStats(ctx); + stats.replaceChildren( + ...STAT_KEYS.filter((k) => visible.has(k)).map((k) => + el("span", { className: "ss-statusbar__item", text: statText(k, s) }), + ), + ); + } + + root.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + const box = root.getBoundingClientRect(); + const items: MapContextMenuItem[] = STAT_KEYS.map((k) => [ + `${visible.has(k) ? "✓ " : ""}${k}`, + () => { + if (visible.has(k)) visible.delete(k); + else visible.add(k); + render(); + }, + ]); + menu.open(ev.clientX - box.left, ev.clientY - box.top, items); + }); + + render(); + + return { + root, + refresh: render, + destroy(): void { + menu.close(); + root.remove(); + }, + }; +} 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..b86ae188 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 @@ -55,7 +59,10 @@ * 3 spreadsheet_condfmt.ts 조건부 서식 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~260 * 3 spreadsheet_validation.ts 데이터 유효성 규칙 · 관리 창(주인 sub_laptop_4 · 안 이어짐) ~230 * 3 spreadsheet_names.ts 이름 정의 · 이름 관리 창(주인 sub_laptop_4 · 안 이어짐) ~220 - * 3 spreadsheet_stage3.css 위 셋 화면 스타일 한 벌 ~90 + * 3 spreadsheet_statusbar.ts 아래 상태 표시줄 — 합계 · 평균 · 개수 등(주인 sub_laptop_4 · 안 이어짐) ~150 + * 3 spreadsheet_autosum.ts 자동 합계(Alt+= · Σ, 주인 sub_laptop_4 · 안 이어짐) ~90 + * 3 spreadsheet_sheet_extra.ts 시트 숨김 · 보이기 · 탭 색(주인 sub_laptop_4 · 계약 더함 · 안 이어짐) ~150 + * 3 spreadsheet_stage3.css 위 여섯 화면 스타일 한 벌 ~140 * F common_util/common_util_spreadsheet_node.ts Node 진입(재계산 · 폴더 밖) ~40 * F common_util/common_util_spreadsheet.py 파이썬 껍데기(common_util_node_bundle) ~60 * F package.json `build:spreadsheet` · M02 `basis` 종류(Store · Layers) · 옛 산출근거 옮김 · @@ -172,6 +179,10 @@ export interface Sheet { /** 이 시트에서 정의한 이름 — 범위는 이 시트 안(`spreadsheet_names.ts`). 식 나무의 `{type:"name"}` 는 * A 의 eval 이 `resolveName` 으로 풀어야 함(지금 안 이어짐 — 8-3 절 참고). */ 이름정의?: NamedRange[]; + /** 시트 숨김(`spreadsheet_sheet_extra.ts`) — 탭은 `spreadsheet_tabs.ts`(E)가 이 값으로 걸러야 함(안 이어짐). */ + 숨김?: boolean; + /** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */ + 탭색?: string; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ 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 는