/* ============================================================================= * spreadsheet_statusbar.ts (3단계 · 주인 sub_laptop_4) * 아래 상태 표시줄 — 고른 범위의 합계 · 평균 · 개수(COUNTA) · 숫자 개수(COUNT) · 최소 · 최대. * 엑셀처럼 우클릭으로 보일 줄 고름(이 표시줄만의 취향 — 문서에는 안 남음 · 계약 손 안 댐). * 확대 비율 — `ctx.grid.zoom()` 으로 처음 값 · 격자 root 의 `aislo-grid-zoom` 사건(sub6, 2026-09-27)을 * 들어 Ctrl+휠로 바뀔 때마다 다시 씀. `setZoom` 은 아직 계약에 없음(격자만 바꿈 · 상태줄은 읽기만). * 화면 붙이기는 `spreadsheet.ts`(D)가 `createStatusBar(ctx)` 로 이미 함(root 를 격자 아래). * ========================================================================== */ 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 }); } function zoomText(ratio: number): string { return `확대 ${Math.round(ratio * 100)}%`; } /** 아래 상태 표시줄 — 우클릭 메뉴로 보일 줄 고름. */ 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: zoomText(ctx.grid.zoom()), }); const menu = createMapContextMenu("ss-statusbar"); root.append(stats, zoom, menu.element); const onZoom = (ev: Event): void => { const ratio = (ev as CustomEvent<{ zoom: number }>).detail?.zoom; if (typeof ratio === "number") zoom.textContent = zoomText(ratio); }; ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom); 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 { ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom); menu.close(); root.remove(); }, }; }