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/resources/tester/spreadsheet/test_stage3_statusbar.ts b/resources/tester/spreadsheet/test_stage3_statusbar.ts new file mode 100644 index 00000000..e4e6aa19 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage3_statusbar.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * test_stage3_statusbar.ts — spreadsheet_statusbar 시험. 돌리기: npx tsx <이 파일>. + * ========================================================================== */ + +import { computeSelectionStats } from "../../../A00_Common/spreadsheet/spreadsheet_statusbar.ts"; +import { + assertEqual, + fracLiteral, + makeBook, + makeCtx, + makeSheet, + v, +} from "./test_stage2_helpers.ts"; + +const sheet = makeSheet("s1", { A1: v("사과"), A2: v("배") }); +const book = makeBook([sheet]); + +const ctx = makeCtx(book, "s1", { + s1: { A1: fracLiteral(10), A2: fracLiteral(20), A3: "글자" }, +}); +ctx.selection.범위 = [{ r0: 0, c0: 0, r1: 2, c1: 0 }]; + +const stats = computeSelectionStats(ctx); +assertEqual(stats.합계, 10 + 20, "합계 — 숫자만 더함"); +assertEqual(stats.평균, 15, "평균"); +assertEqual(stats.개수, 3, "개수 — 빈칸 아닌 칸 전부(COUNTA)"); +assertEqual(stats.숫자개수, 2, "숫자개수 — 숫자만(COUNT)"); +assertEqual(stats.최소, 10, "최소"); +assertEqual(stats.최대, 20, "최대"); + +// 빈 범위 — 다 null · 0 +const empty = makeCtx(book, "s1"); +empty.selection.범위 = [{ r0: 5, c0: 5, r1: 5, c1: 5 }]; +const emptyStats = computeSelectionStats(empty); +assertEqual(emptyStats.합계, null, "빈 범위 합계는 null"); +assertEqual(emptyStats.개수, 0, "빈 범위 개수는 0"); + +// 겹치는 범위는 한 번만 셈 +const overlap = makeCtx(book, "s1", { s1: { A1: fracLiteral(5) } }); +overlap.selection.범위 = [ + { r0: 0, c0: 0, r1: 0, c1: 0 }, + { r0: 0, c0: 0, r1: 0, c1: 0 }, +]; +assertEqual(computeSelectionStats(overlap).숫자개수, 1, "겹치는 범위는 한 번만 셈"); + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_statusbar 시험 끝");