From 49a8867892e944f204c0955997ab9d92314e10b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:30:51 +0900 Subject: [PATCH] =?UTF-8?q?perf(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=C2=B7=EA=B3=A0=EB=A6=84=20=EB=8B=A4=EC=8B=9C=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=86=8D=EB=8F=84(81-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- A00_Common/spreadsheet/spreadsheet.ts | 14 +++--- A00_Common/spreadsheet/spreadsheet_links.ts | 46 +++++++++++++++---- A00_Common/spreadsheet/spreadsheet_menu.ts | 4 +- .../spreadsheet/spreadsheet_statusbar.ts | 8 +++- .../spreadsheet/spreadsheet_view_types.ts | 3 +- .../spreadsheet/test_stage4_menu_perf.ts | 46 +++++++++++++++++++ 6 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 resources/tester/spreadsheet/test_stage4_menu_perf.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index dfb65ccc5..5596915d8 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -171,7 +171,7 @@ export function createSpreadsheet( } grid.render(); - select(ctx.selection); + select(ctx.selection, false, true); loadLinks(); if (opts.conditions && !readOnly) void import("./spreadsheet_condition").then((m) => { @@ -221,7 +221,7 @@ export function createSpreadsheet( if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]); else grid.render(); } - select(ctx.selection); + select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2) opts.onChange?.(structuredClone(ctx.book)); } @@ -235,7 +235,7 @@ export function createSpreadsheet( return out; } - function select(selection: Selection, reveal = false): void { + function select(selection: Selection, reveal = false, dataChanged = false): void { ctx.selection = selection; memory.set(selection.시트, selection); grid.renderSelection(); @@ -244,7 +244,7 @@ export function createSpreadsheet( grid.reveal(far.r, far.c); } ed.sync(); - for (const p of others) p.refresh(); + for (const p of others) p.refresh(dataChanged); const g = selection.범위[0]; opts.onSelect?.({ 시트: selection.시트, @@ -261,7 +261,7 @@ export function createSpreadsheet( ctx.book.활성 = sheetId; ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0); grid.render(); - select(ctx.selection, true); + select(ctx.selection, true, true); ed.focus(); } @@ -275,13 +275,13 @@ export function createSpreadsheet( memory.clear(); ctx.selection = selectCell(firstSheet(next), 0, 0); grid.render(); - select(ctx.selection); + select(ctx.selection, false, true); loadLinks(); }, recalc() { ctx.engine.rebuild(ctx.book); grid.render(); - for (const p of others) p.refresh(); + for (const p of others) p.refresh(true); return ctx.engine.snapshot(); }, getLinkedValues() { diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 7525085a4..e2d31e23a 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -358,7 +358,9 @@ export function mountLinks( bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } - /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ + /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 + * 읽기(slot — getBoundingClientRect) 를 칸마다 먼저 다 끝내고 쓰기(append)는 나중에 — 안 그러면 + * 읽기·쓰기가 번갈아 와서 매 칸마다 강제 레이아웃이 남(81-2②). */ function placeDots(): void { for (const d of dots) d.remove(); dots = []; @@ -368,11 +370,37 @@ export function mountLinks( // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); + + // 1) 읽기 — 칸마다 자리만 먼저 모음(DOM 변경 없음) + const cellSlots: Array<{ + list: CellLink[]; + layer: HTMLElement; + box: ReturnType["box"]; + }> = []; for (const [a1, list] of byCell) { const p = parseA1(a1); if (!p) continue; const { layer, box } = slot(ctx, p.r, p.c); if (!box.w || !box.h) continue; // 숨긴 행열 + cellSlots.push({ list, layer, box }); + } + const rangeSlots: Array<{ + mark: { 시트: string; 범위: string; 이름: string; 설명?: string }; + layer: HTMLElement; + box: ReturnType["box"]; + end: ReturnType["box"]; + }> = []; + for (const mark of opts?.표시범위 ?? []) { + const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; + if (!g) continue; + const { layer, box } = slot(ctx, g.r0, g.c0); + const end = slot(ctx, g.r1, g.c1).box; + if (!box.w || !box.h) continue; + rangeSlots.push({ mark, layer, box, end }); + } + + // 2) 쓰기 — 모은 자리에 한 번에 그림 + for (const { list, layer, box } of cellSlots) { const first = list[0]; const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } }); tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; @@ -392,12 +420,7 @@ export function mountLinks( dots.push(...marks); } // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) - for (const mark of opts?.표시범위 ?? []) { - const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; - if (!g) continue; - const { layer, box } = slot(ctx, g.r0, g.c0); - const end = slot(ctx, g.r1, g.c1).box; - if (!box.w || !box.h) continue; + for (const { mark, layer, box, end } of rangeSlots) { const frame = el("div", { className: "ss-link-range", attrs: { "data-range": mark.범위 }, @@ -542,12 +565,17 @@ export function mountLinks( ctx.grid.root.addEventListener("drop", onDropEvent); ctx.grid.root.addEventListener("mousemove", onHover); + let dotsReady = false; return { root: opts ? bar : null, apply: applyLinked, - refresh() { + // dataChanged 거짓(고름만 바뀜) 이면 점은 건너뜀 — 점은 선택에 안 매임(81-2②) + refresh(dataChanged = false) { renderBar(); - placeDots(); + if (dataChanged || !dotsReady) { + placeDots(); + dotsReady = true; + } }, destroy() { ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom); diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 4527ae2d9..603fa99e7 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -112,7 +112,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { } function rowMenuItems(rows: number[]): MapContextMenuItem[] { - const at = Math.min(...rows); + const at = rows[0]; // rows 는 늘 오름차순 연속(81-2③) — 전체 고름이면 104만 개라 Math.min(...rows) 는 스택 넘침 const 수 = rows.length; return [ [st("MenuCut"), () => cutCopy("cut")], @@ -152,7 +152,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { } function colMenuItems(cols: number[]): MapContextMenuItem[] { - const at = Math.min(...cols); + const at = cols[0]; // cols 도 늘 오름차순 연속 — 같은 까닭(81-2③) const 수 = cols.length; return [ [st("MenuCut"), () => cutCopy("cut")], diff --git a/A00_Common/spreadsheet/spreadsheet_statusbar.ts b/A00_Common/spreadsheet/spreadsheet_statusbar.ts index 43e9ad0d7..9fb6642a8 100644 --- a/A00_Common/spreadsheet/spreadsheet_statusbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_statusbar.ts @@ -10,6 +10,7 @@ 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 { computeExtent } from "./spreadsheet_grid_cells"; import type { CellRange, Scalar } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -31,13 +32,16 @@ export interface SelectionStats { /** 고른 범위(여럿) 안 칸 값을 모아 통계를 냄 — 엔진 값 기준(식 칸도 풀린 값). */ export function computeSelectionStats(ctx: SpreadsheetContext): SelectionStats { const sheet = ctx.selection.시트; + const extent = computeExtent(ctx.sheet()); // 쓴 범위까지만 셈(81-2 — 열 머리 전체 고름이면 104만 칸을 다 돎) 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 r1 = Math.min(range.r1, extent.rows - 1); + const c1 = Math.min(range.c1, extent.cols - 1); + for (let r = range.r0; r <= r1; r++) { + for (let c = range.c0; c <= c1; c++) { const key = r * 1_000_000 + c; if (seen.has(key)) continue; seen.add(key); diff --git a/A00_Common/spreadsheet/spreadsheet_view_types.ts b/A00_Common/spreadsheet/spreadsheet_view_types.ts index 1f5ccb96a..8903b6adc 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_types.ts @@ -117,6 +117,7 @@ export interface SpreadsheetContext { /** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */ export interface PartHandle { root: HTMLElement | null; - refresh(): void; + /** `dataChanged` — 문서가 실제로 바뀐 다시 그림(참) vs 고름만 바뀐 다시 그림(거짓 · 81-2 알약 점 건너뜀용) */ + refresh(dataChanged?: boolean): void; destroy(): void; } diff --git a/resources/tester/spreadsheet/test_stage4_menu_perf.ts b/resources/tester/spreadsheet/test_stage4_menu_perf.ts new file mode 100644 index 000000000..8cc4b2cbe --- /dev/null +++ b/resources/tester/spreadsheet/test_stage4_menu_perf.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * test_stage4_menu_perf.ts — 우클릭 속도(81-2③) 재현 시험. 돌리기: npx tsx <이 파일>. + * `spreadsheet_menu.ts` rowMenuItems/colMenuItems 의 `at` 계산 — 옛 Math.min(...rows) 는 + * 전체 고름(열 머리 104만 개) 배열을 스프레드해 콜스택을 넘침(RangeError) · 고친 `rows[0]` + * (rows 는 늘 오름차순 연속이라 첫 자리가 최소)는 크기와 무관하게 안전·상수 시간. + * `computeSelectionStats` 쪽(①)은 createMapContextMenu 가 끌어오는 css 임포트 때문에 + * 이 node 시험 환경에서 직접 불러오지 못함 — ORCA 화면 검증으로 수치 확인(81-2 보고 참고). + * ========================================================================== */ + +import { MAX_COLS, MAX_ROWS } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts"; +import { assertEqual } from "./test_stage2_helpers.ts"; + +const bigRows = Array.from({ length: MAX_ROWS }, (_, i) => i); // 열 머리 전체 고름 재현 + +let oldThrew = false; +try { + Math.min(...bigRows); // 옛 코드 — 이 크기에선 RangeError(콜스택 넘침) +} catch { + oldThrew = true; +} +if (!oldThrew) { + console.error( + "FAIL 옛 Math.min(...rows) 재현 실패 — 이 Node 가 다른 콜스택 한도를 씀(시험 전제 깨짐)", + ); + process.exitCode = 1; +} else { + console.log("ok 옛 Math.min(...rows) 는 104만 개에서 콜스택을 넘침(고치기 전 재현)"); +} + +const t0 = performance.now(); +const at = bigRows[0]; // 고친 코드(spreadsheet_menu.ts rowMenuItems) +const ms = performance.now() - t0; +assertEqual(at, 0, "rows[0] = 최소값(오름차순 연속)"); +if (ms >= 5) { + console.error(`FAIL rows[0] 은 5ms 안(상수 시간) — 실측 ${ms.toFixed(2)}ms`); + process.exitCode = 1; +} else { + console.log(`ok rows[0] ${ms.toFixed(3)}ms(크기 무관 상수 시간)`); +} + +// 열 머리(MAX_COLS) 도 같은 모양 +const bigCols = Array.from({ length: MAX_COLS }, (_, i) => i); +assertEqual(bigCols[0], 0, "cols[0] = 최소값"); + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_menu 우클릭 속도(81-2③) 시험 끝");