From 6275e48396c7f9f81a7c884d1bbeabbb23258be2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 21:14:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=95=84=EB=9E=98=20=EC=83=81=ED=83=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spreadsheet_statusbar.ts 새로 만듦 — 고른 범위 합계·평균·개수·숫자개수·최소·최대, 우클릭으로 보일 줄 고름, 확대 비율 자리(아직 안 이어짐). 계약 안 건드림 · 엔진 A·격자 C 안 건드림 — 화면 붙이기·확대 이음은 sub7 몫(머리 주석에 적음). 시험 resources/tester/spreadsheet/test_stage3_statusbar.ts 9개 통과. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- .../spreadsheet/spreadsheet_statusbar.ts | 122 ++++++++++++++++++ .../spreadsheet/test_stage3_statusbar.ts | 47 +++++++ 2 files changed, 169 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_statusbar.ts create mode 100644 resources/tester/spreadsheet/test_stage3_statusbar.ts 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 시험 끝");