feat(spreadsheet): 3단계 — 아래 상태 표시줄

spreadsheet_statusbar.ts 새로 만듦 — 고른 범위 합계·평균·개수·숫자개수·최소·최대, 우클릭으로 보일 줄 고름, 확대 비율 자리(아직 안 이어짐).
계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 화면 붙이기·확대 이음은 sub7 몫(머리 주석에 적음).
시험 resources/tester/spreadsheet/test_stage3_statusbar.ts 9개 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
2026-09-27 21:14:09 +09:00
co-authored by Claude Sonnet 5
parent 647ae91469
commit 6275e48396
2 changed files with 169 additions and 0 deletions
@@ -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<number>();
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<StatKey>(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();
},
};
}
@@ -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 시험 끝");