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:
@@ -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 시험 끝");
|
||||
Reference in New Issue
Block a user