@casualoffice/sheets 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/{api-DJhuCjhn.d.cts → api-cyO4EA4_.d.cts} +1 -1
  2. package/dist/{api-DJhuCjhn.d.ts → api-cyO4EA4_.d.ts} +1 -1
  3. package/dist/{attachCollab-BmCRT4V_.d.ts → attachCollab-BT4fVzxP.d.ts} +1 -1
  4. package/dist/{attachCollab-DIUQCCrq.d.cts → attachCollab-DHjb5XYK.d.cts} +1 -1
  5. package/dist/chrome.cjs +60 -3
  6. package/dist/chrome.cjs.map +1 -1
  7. package/dist/chrome.d.cts +3 -3
  8. package/dist/chrome.d.ts +3 -3
  9. package/dist/chrome.js +60 -3
  10. package/dist/chrome.js.map +1 -1
  11. package/dist/collab.d.cts +2 -2
  12. package/dist/collab.d.ts +2 -2
  13. package/dist/embed/embed-runtime.js +205 -161
  14. package/dist/{extensions-DZnqTEN2.d.ts → extensions-Cni7mbEm.d.ts} +1 -1
  15. package/dist/{extensions-FFqdLpk_.d.cts → extensions-DMmPnIYB.d.cts} +1 -1
  16. package/dist/index.cjs +6424 -1559
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +3 -3
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +6409 -1530
  21. package/dist/index.js.map +1 -1
  22. package/dist/sheets.cjs +5426 -561
  23. package/dist/sheets.cjs.map +1 -1
  24. package/dist/sheets.d.cts +5 -5
  25. package/dist/sheets.d.ts +5 -5
  26. package/dist/sheets.js +5415 -536
  27. package/dist/sheets.js.map +1 -1
  28. package/package.json +8 -7
  29. package/src/charts/ChartContextMenu.tsx +264 -0
  30. package/src/charts/ChartLayer.tsx +333 -0
  31. package/src/charts/ChartOverlay.tsx +293 -0
  32. package/src/charts/ChartsPanel.tsx +232 -0
  33. package/src/charts/FormatChartDialog.tsx +419 -0
  34. package/src/charts/InsertChartDialog.tsx +478 -0
  35. package/src/charts/build-option.ts +476 -0
  36. package/src/charts/charts-context.tsx +205 -0
  37. package/src/charts/echarts-init.ts +58 -0
  38. package/src/charts/hit-test.ts +130 -0
  39. package/src/charts/insert-chart.ts +106 -0
  40. package/src/charts/naming.ts +38 -0
  41. package/src/charts/render-to-png.ts +117 -0
  42. package/src/charts/resources.ts +108 -0
  43. package/src/charts/types.ts +239 -0
  44. package/src/charts/univer-dom.ts +102 -0
  45. package/src/chrome/CommentsPanel.tsx +418 -0
  46. package/src/chrome/HistoryPanel.tsx +304 -0
  47. package/src/chrome/InsertPivotDialog.tsx +74 -2
  48. package/src/chrome/PanelHost.tsx +56 -0
  49. package/src/chrome/PanelRail.tsx +90 -0
  50. package/src/chrome/PivotFieldsPanel.tsx +1021 -0
  51. package/src/chrome/TablesPanel.tsx +283 -0
  52. package/src/chrome/Toolbar.tsx +7 -1
  53. package/src/chrome/panel-context.tsx +55 -0
  54. package/src/chrome/panel-registry.ts +48 -0
  55. package/src/chrome/panel-shell.tsx +151 -0
  56. package/src/pivots/apply.ts +139 -0
  57. package/src/pivots/compute.ts +604 -0
  58. package/src/pivots/fields-model.ts +267 -0
  59. package/src/pivots/types.ts +137 -0
  60. package/src/sheets/CasualSheets.tsx +61 -35
  61. package/src/sheets/api.ts +37 -2
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type {
18
+ DateGrouping,
19
+ PivotAggregation,
20
+ PivotFieldRef,
21
+ PivotModel,
22
+ PivotValueField,
23
+ } from './types';
24
+ import { PIVOT_AGG_LABELS } from './types';
25
+
26
+ /**
27
+ * Pure compute step — turn raw source records into the laid-out cell
28
+ * grid that gets written into the workbook. No FUniver dependency
29
+ * here so it's trivially testable and so the same routine can run
30
+ * server-side later (e.g. for an "export pivot to CSV" path).
31
+ *
32
+ * Scope as of P2:
33
+ * - 1..N row fields (multi-row uses Excel's compact layout — see
34
+ * {@link computePivot} below).
35
+ * - Optional single column field → cross-tab / matrix layout (see
36
+ * {@link computeMatrix} below). When present, the value field(s)
37
+ * fan out across one block of columns per distinct column-field
38
+ * key, with a "Grand Total" block on the right and a Grand Total
39
+ * row at the bottom.
40
+ * - One or more value fields, each gets its own column (no column
41
+ * field) or its own column *within each* column-key block (with a
42
+ * column field).
43
+ * - Filters applied before bucketing (P1).
44
+ *
45
+ * Matrix layout (rows = [Region], cols = [Quarter], values = [Sum Sales]):
46
+ *
47
+ * [ Region | Q1 | Q2 | Grand Total ]
48
+ * [ North | 100 | 120 | 220 ]
49
+ * [ South | 80 | 95 | 175 ]
50
+ * [ Grand T. | 180 | 215 | 395 ]
51
+ *
52
+ * Single-row output (unchanged from P0):
53
+ *
54
+ * [ row-field | Sum of Sales | Avg of Sales ]
55
+ * [ North | 220 | 110 ]
56
+ * [ South | 175 | 87.5 ]
57
+ * [ Grand T. | 395 | 98.7 ]
58
+ *
59
+ * Multi-row compact layout (rows = [Region, Product], values = [Sum]):
60
+ *
61
+ * [ Region | Sum of Sales ]
62
+ * [ East | 300 ] ← outer subtotal on the label row
63
+ * [ A | 100 ] ← inner leaf, indent depth 1
64
+ * [ B | 200 ]
65
+ * [ West | 150 ]
66
+ * [ A | 150 ]
67
+ * [ Grand T. | 450 ]
68
+ *
69
+ * Indentation uses leading spaces in the label string (`' '.repeat
70
+ * (depth)`) — Univer's IStyleData has no first-class indent property,
71
+ * and spaces round-trip losslessly through xlsx.
72
+ */
73
+ export type PivotCell = string | number | null;
74
+ export type PivotGrid = PivotCell[][];
75
+
76
+ /** Metadata for each row of the output grid — lets drill-down map a
77
+ * clicked cell back to the composite key path that produced it
78
+ * without re-running the bucketing walk. */
79
+ export type PivotRowMeta =
80
+ | { kind: 'header' }
81
+ | { kind: 'subtotal'; keyPath: string[]; depth: number }
82
+ | { kind: 'leaf'; keyPath: string[]; depth: number }
83
+ | { kind: 'grand-total' };
84
+
85
+ /** Metadata for each COLUMN of a matrix (cross-tab) output grid — lets
86
+ * drill-down map a clicked column back to its column-field key. Only
87
+ * populated when the pivot has a column field; non-matrix pivots leave
88
+ * `colMeta` undefined.
89
+ *
90
+ * - `label` → the leading label column (row-field names).
91
+ * - `value` → a value cell scoped to a single column-field key.
92
+ * - `grand-total` → a value cell aggregating across all column keys
93
+ * (the right-hand "Grand Total" block).
94
+ */
95
+ export type PivotColMeta =
96
+ | { kind: 'label' }
97
+ | { kind: 'value'; colKeys: string[]; valueIndex: number }
98
+ | { kind: 'grand-total'; valueIndex: number };
99
+
100
+ export type PivotComputeResult = {
101
+ grid: PivotGrid;
102
+ rowMeta: PivotRowMeta[];
103
+ /** Present only for matrix (column-field) pivots. Index-aligned with
104
+ * the columns of every grid row. */
105
+ colMeta?: PivotColMeta[];
106
+ };
107
+
108
+ /**
109
+ * Raw records read from the workbook source range. Row 0 is the
110
+ * header row, rows 1.. are data. Cell values come in pre-coerced
111
+ * by the caller (`apply.ts` reads them via `getValue()` which already
112
+ * returns the right primitive type).
113
+ */
114
+ export type SourceMatrix = {
115
+ headers: string[];
116
+ records: PivotCell[][];
117
+ };
118
+
119
+ export function computePivot(source: SourceMatrix, model: PivotModel): PivotComputeResult {
120
+ if (model.values.length === 0) {
121
+ return { grid: [], rowMeta: [] };
122
+ }
123
+
124
+ // P2 — a column field switches the layout to a cross-tab / matrix.
125
+ // The value field(s) fan out horizontally across one block per
126
+ // distinct column key. Single-row + multi-row both supported. We
127
+ // only honour the first column field; nested column fields are a
128
+ // future follow-up (the model array shape allows it without a bump).
129
+ if ((model.cols?.length ?? 0) > 0) {
130
+ return computeMatrix(source, model);
131
+ }
132
+
133
+ // P1 — apply filter fields BEFORE bucketing. Each filter restricts
134
+ // records to those whose value in `column` is one of `allowedValues`
135
+ // (string-compared). Empty allowedValues excludes everything; the
136
+ // dialog UI never produces that shape (uses remove-filter), but
137
+ // compute defends against it.
138
+ const filters = model.filters ?? [];
139
+ const passesFilters = (rec: PivotCell[]): boolean => {
140
+ for (const f of filters) {
141
+ const allowed = new Set(f.allowedValues);
142
+ const v = rec[f.column];
143
+ const key = v == null ? '' : String(v);
144
+ if (!allowed.has(key)) return false;
145
+ }
146
+ return true;
147
+ };
148
+ const filteredRecords =
149
+ filters.length > 0 ? source.records.filter(passesFilters) : source.records;
150
+
151
+ // Header row — column 0 is the outermost row field name (compact
152
+ // layout uses one label column shared across all row-field levels);
153
+ // subsequent columns are the value-field headers in model order.
154
+ const rowFieldCols = model.rows.map((r) => r.column);
155
+ const hasRowField = rowFieldCols.length > 0;
156
+ const header: PivotCell[] = [];
157
+ if (hasRowField) {
158
+ header.push(source.headers[rowFieldCols[0]] ?? '');
159
+ }
160
+ for (const v of model.values) {
161
+ header.push(`${PIVOT_AGG_LABELS[v.agg]} of ${source.headers[v.column] ?? ''}`);
162
+ }
163
+
164
+ const grid: PivotGrid = [header];
165
+ const rowMeta: PivotRowMeta[] = [{ kind: 'header' }];
166
+
167
+ if (!hasRowField) {
168
+ // No row field — Grand Total only.
169
+ const total: PivotCell[] = [];
170
+ for (const v of model.values) {
171
+ total.push(
172
+ aggregate(
173
+ filteredRecords.map((r) => r[v.column]),
174
+ v.agg,
175
+ ),
176
+ );
177
+ }
178
+ grid.push(total);
179
+ rowMeta.push({ kind: 'grand-total' });
180
+ return { grid, rowMeta };
181
+ }
182
+
183
+ // Build the nested bucket tree. Each level keys by the value of the
184
+ // corresponding row field; leaves hold the contributing record list.
185
+ const root = buildTree(filteredRecords, model.rows);
186
+
187
+ // Walk the tree in compact-layout order. For each row-field level we
188
+ // emit either a subtotal row (intermediate levels) or a leaf row
189
+ // (innermost level), then recurse into children.
190
+ const walk = (node: TreeNode, keyPath: string[], depth: number): void => {
191
+ // Sort keys ascending — Excel default sort order.
192
+ const keys = [...node.children.keys()].sort((a, b) =>
193
+ a.localeCompare(b, undefined, { numeric: true }),
194
+ );
195
+ const isInnermost = depth === rowFieldCols.length - 1;
196
+ for (const key of keys) {
197
+ const child = node.children.get(key)!;
198
+ const path = [...keyPath, key];
199
+ const cells: PivotCell[] = [`${' '.repeat(depth)}${key}`];
200
+ for (const v of model.values) {
201
+ cells.push(
202
+ aggregate(
203
+ child.records.map((r) => r[v.column]),
204
+ v.agg,
205
+ ),
206
+ );
207
+ }
208
+ grid.push(cells);
209
+ rowMeta.push(
210
+ isInnermost
211
+ ? { kind: 'leaf', keyPath: path, depth }
212
+ : { kind: 'subtotal', keyPath: path, depth },
213
+ );
214
+ if (!isInnermost) walk(child, path, depth + 1);
215
+ }
216
+ };
217
+ walk(root, [], 0);
218
+
219
+ // Grand Total — aggregates ALL filtered records (regardless of row
220
+ // bucketing depth) so the total always matches the visible records.
221
+ const total: PivotCell[] = ['Grand Total'];
222
+ for (const v of model.values) {
223
+ total.push(
224
+ aggregate(
225
+ filteredRecords.map((r) => r[v.column]),
226
+ v.agg,
227
+ ),
228
+ );
229
+ }
230
+ grid.push(total);
231
+ rowMeta.push({ kind: 'grand-total' });
232
+
233
+ // "Show Values As → % of Grand Total" per value field. The grand-total row
234
+ // (just pushed) holds each value column's denominator; value columns start
235
+ // at index 1 (column 0 is the shared row-field label).
236
+ applyShowAsPercent(grid, model.values, 1);
237
+
238
+ return { grid, rowMeta };
239
+ }
240
+
241
+ /**
242
+ * Rewrite each value column flagged `showAs: 'pctOfGrandTotal'` as a percentage
243
+ * of that column's grand total (the last grid row). Mutates `grid` in place;
244
+ * the header row is left untouched and the grand-total cell becomes 100.0%.
245
+ */
246
+ function applyShowAsPercent(
247
+ grid: PivotGrid,
248
+ values: PivotValueField[],
249
+ valueColStart: number,
250
+ ): void {
251
+ if (grid.length < 2) return;
252
+ const lastRow = grid.length - 1;
253
+ values.forEach((v, vi) => {
254
+ // In the row-only layout a value column's "column total" is its grand
255
+ // total, so both percentage modes share the same denominator.
256
+ if (v.showAs !== 'pctOfGrandTotal' && v.showAs !== 'pctOfColumnTotal') return;
257
+ const col = valueColStart + vi;
258
+ const denom = Number(grid[lastRow][col]);
259
+ for (let r = 1; r < grid.length; r++) {
260
+ const raw = grid[r][col];
261
+ const n = typeof raw === 'number' ? raw : Number(raw);
262
+ grid[r][col] = denom && Number.isFinite(n) ? `${((n / denom) * 100).toFixed(1)}%` : '0.0%';
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Cross-tab / matrix layout. Active when `model.cols` is non-empty.
269
+ *
270
+ * Columns fan out by the distinct values of the (first) column field;
271
+ * within each column-key block there is one column per value field
272
+ * (one block === one column for the common single-value case). A
273
+ * right-hand "Grand Total" block aggregates across all column keys,
274
+ * and a bottom Grand Total row aggregates down each column.
275
+ *
276
+ * Rows reuse the same compact bucket walk as {@link computePivot} so
277
+ * multi-row matrices indent inner keys exactly like the non-matrix
278
+ * path. The difference is purely horizontal: each row's value cells
279
+ * are sliced per column key instead of being a single total.
280
+ */
281
+ function computeMatrix(source: SourceMatrix, model: PivotModel): PivotComputeResult {
282
+ const filters = model.filters ?? [];
283
+ const passesFilters = (rec: PivotCell[]): boolean => {
284
+ for (const f of filters) {
285
+ const allowed = new Set(f.allowedValues);
286
+ const v = rec[f.column];
287
+ const key = v == null ? '' : String(v);
288
+ if (!allowed.has(key)) return false;
289
+ }
290
+ return true;
291
+ };
292
+ const filteredRecords =
293
+ filters.length > 0 ? source.records.filter(passesFilters) : source.records;
294
+
295
+ const colFields = model.cols.map((c) => c.column);
296
+ const rowFieldCols = model.rows.map((r) => r.column);
297
+ const hasRowField = rowFieldCols.length > 0;
298
+ const values = model.values;
299
+ const multiValue = values.length > 1;
300
+
301
+ const tupleOf = (rec: PivotCell[]): string[] => colFields.map((c) => keyOf(rec[c]));
302
+
303
+ // Distinct column-key TUPLES present in the data (one entry per column field,
304
+ // outer-first), sorted level-by-level (numeric-aware). A single column field
305
+ // reduces to 1-element tuples → identical output to the pre-nesting path. A
306
+ // filter that empties a tuple drops that column (Excel — no empty columns).
307
+ const seenTuples = new Map<string, string[]>();
308
+ for (const rec of filteredRecords) {
309
+ const t = tupleOf(rec);
310
+ seenTuples.set(t.join('\u0000'), t);
311
+ }
312
+ let tuples = [...seenTuples.values()].sort((a, b) => {
313
+ for (let i = 0; i < a.length; i += 1) {
314
+ const c = a[i].localeCompare(b[i], undefined, { numeric: true });
315
+ if (c !== 0) return c;
316
+ }
317
+ return 0;
318
+ });
319
+ // A high-cardinality nest can explode the column count (and the single
320
+ // setValues write). Truncate + flag; the dialog also caps the depth.
321
+ const TUPLE_CAP = 2048;
322
+ const tuplesCapped = tuples.length > TUPLE_CAP;
323
+ if (tuplesCapped) tuples = tuples.slice(0, TUPLE_CAP);
324
+
325
+ const labelHeader = hasRowField ? (source.headers[rowFieldCols[0]] ?? '') : '';
326
+ const valueLabel = (vi: number): string =>
327
+ `${PIVOT_AGG_LABELS[values[vi].agg]} of ${source.headers[values[vi].column] ?? ''}`;
328
+
329
+ // colMeta — label, then one entry per (tuple, value) value cell, then the
330
+ // grand-total block (one per value field). Drill-down decodes `colKeys`.
331
+ const colMeta: PivotColMeta[] = [{ kind: 'label' }];
332
+ for (const t of tuples) {
333
+ for (let vi = 0; vi < values.length; vi += 1) {
334
+ colMeta.push({ kind: 'value', colKeys: t, valueIndex: vi });
335
+ }
336
+ }
337
+ for (let vi = 0; vi < values.length; vi += 1) {
338
+ colMeta.push({ kind: 'grand-total', valueIndex: vi });
339
+ }
340
+
341
+ const grid: PivotGrid = [];
342
+ const rowMeta: PivotRowMeta[] = [];
343
+
344
+ // ---- Header rows -------------------------------------------------
345
+ // One spanning row per column-field level (outer first), then — when there
346
+ // are multiple value fields — a value-field sub-header row. A key is shown at
347
+ // the start of its span and blanked across the rest (Univer has no cell merge
348
+ // in setValues; labels still read left-to-right and round-trip through xlsx).
349
+ for (let level = 0; level < colFields.length; level += 1) {
350
+ const row: PivotCell[] = [level === 0 ? labelHeader : ''];
351
+ let prevPrefix: string | null = null;
352
+ for (const t of tuples) {
353
+ const prefix = t.slice(0, level + 1).join('\u0000');
354
+ const show = prefix !== prevPrefix;
355
+ prevPrefix = prefix;
356
+ const label = t[level] === '' ? '(blank)' : t[level];
357
+ for (let vi = 0; vi < values.length; vi += 1) {
358
+ row.push(show && vi === 0 ? label : '');
359
+ }
360
+ }
361
+ // Grand-total block — "Grand Total" on the first level row, blank below.
362
+ for (let vi = 0; vi < values.length; vi += 1) {
363
+ row.push(level === 0 && vi === 0 ? 'Grand Total' : '');
364
+ }
365
+ grid.push(row);
366
+ rowMeta.push({ kind: 'header' });
367
+ }
368
+ if (multiValue) {
369
+ const sub: PivotCell[] = [''];
370
+ for (let i = 0; i < tuples.length; i += 1) {
371
+ for (let vi = 0; vi < values.length; vi += 1) sub.push(valueLabel(vi));
372
+ }
373
+ for (let vi = 0; vi < values.length; vi += 1) sub.push(valueLabel(vi));
374
+ grid.push(sub);
375
+ rowMeta.push({ kind: 'header' });
376
+ }
377
+ const headerRowCount = colFields.length + (multiValue ? 1 : 0);
378
+
379
+ // ---- Value rows --------------------------------------------------
380
+ // For each column tuple, slice the subset to records matching every column
381
+ // field, aggregate each value; finish with the across-all grand-total block.
382
+ const valueCellsFor = (records: PivotCell[][]): PivotCell[] => {
383
+ const cells: PivotCell[] = [];
384
+ for (const t of tuples) {
385
+ const slice = records.filter((rec) => colFields.every((c, i) => keyOf(rec[c]) === t[i]));
386
+ for (const v of values) {
387
+ cells.push(
388
+ aggregate(
389
+ slice.map((r) => r[v.column]),
390
+ v.agg,
391
+ ),
392
+ );
393
+ }
394
+ }
395
+ for (const v of values) {
396
+ cells.push(
397
+ aggregate(
398
+ records.map((r) => r[v.column]),
399
+ v.agg,
400
+ ),
401
+ );
402
+ }
403
+ return cells;
404
+ };
405
+
406
+ if (!hasRowField) {
407
+ // No row field — a single Grand Total row carrying the column split.
408
+ grid.push(['Grand Total', ...valueCellsFor(filteredRecords)]);
409
+ rowMeta.push({ kind: 'grand-total' });
410
+ return { grid, rowMeta, colMeta };
411
+ }
412
+
413
+ const root = buildTree(filteredRecords, model.rows);
414
+ const walk = (node: TreeNode, keyPath: string[], depth: number): void => {
415
+ const keys = [...node.children.keys()].sort((a, b) =>
416
+ a.localeCompare(b, undefined, { numeric: true }),
417
+ );
418
+ const isInnermost = depth === rowFieldCols.length - 1;
419
+ for (const key of keys) {
420
+ const child = node.children.get(key)!;
421
+ const path = [...keyPath, key];
422
+ grid.push([`${' '.repeat(depth)}${key}`, ...valueCellsFor(child.records)]);
423
+ rowMeta.push(
424
+ isInnermost
425
+ ? { kind: 'leaf', keyPath: path, depth }
426
+ : { kind: 'subtotal', keyPath: path, depth },
427
+ );
428
+ if (!isInnermost) walk(child, path, depth + 1);
429
+ }
430
+ };
431
+ walk(root, [], 0);
432
+
433
+ // Bottom Grand Total row — column totals + the overall total.
434
+ grid.push(['Grand Total', ...valueCellsFor(filteredRecords)]);
435
+ rowMeta.push({ kind: 'grand-total' });
436
+
437
+ // "Show Values As → % of Grand Total" for the cross-tab layout: every value
438
+ // cell of a flagged field becomes its share of that field's overall grand
439
+ // total (the bottom-right cell — last row's grand-total column for the field).
440
+ applyMatrixShowAsPercent(grid, colMeta, values, headerRowCount);
441
+
442
+ return { grid, rowMeta, colMeta };
443
+ }
444
+
445
+ /** Cross-tab analogue of {@link applyShowAsPercent}: divide every value cell of
446
+ * each `pctOfGrandTotal` field by that field's overall grand total (the bottom
447
+ * row's grand-total column). Mutates `grid` in place; header rows are skipped. */
448
+ function applyMatrixShowAsPercent(
449
+ grid: PivotGrid,
450
+ colMeta: PivotColMeta[],
451
+ values: PivotValueField[],
452
+ headerRows: number,
453
+ ): void {
454
+ const lastRow = grid.length - 1;
455
+ if (lastRow < headerRows) return;
456
+ values.forEach((v, vi) => {
457
+ const mode = v.showAs;
458
+ if (mode !== 'pctOfGrandTotal' && mode !== 'pctOfColumnTotal' && mode !== 'pctOfRowTotal') {
459
+ return;
460
+ }
461
+ const cols = colMeta
462
+ .map((m, i) => (m.kind !== 'label' && m.valueIndex === vi ? i : -1))
463
+ .filter((i) => i >= 0);
464
+ // Denominator per mode:
465
+ // Grand → the field's bottom-right total (one value).
466
+ // Column → each column's bottom total (per c; bottom row is touched last).
467
+ // Row → each row's grand-total cell (per r; read before mutating the row).
468
+ const gtCol = colMeta.findIndex((m) => m.kind === 'grand-total' && m.valueIndex === vi);
469
+ const grandDenom = gtCol >= 0 ? Number(grid[lastRow][gtCol]) : 0;
470
+ for (let r = headerRows; r < grid.length; r++) {
471
+ const rowDenom = mode === 'pctOfRowTotal' && gtCol >= 0 ? Number(grid[r][gtCol]) : 0;
472
+ for (const c of cols) {
473
+ const denom =
474
+ mode === 'pctOfColumnTotal'
475
+ ? Number(grid[lastRow][c])
476
+ : mode === 'pctOfRowTotal'
477
+ ? rowDenom
478
+ : grandDenom;
479
+ const n = Number(grid[r][c]);
480
+ grid[r][c] = denom && Number.isFinite(n) ? `${((n / denom) * 100).toFixed(1)}%` : '0.0%';
481
+ }
482
+ }
483
+ });
484
+ }
485
+
486
+ /** Coerce a cell value to its string bucket key (null/empty → ''). */
487
+ function keyOf(v: PivotCell): string {
488
+ return v == null ? '' : String(v);
489
+ }
490
+
491
+ /** Excel serial number → {year, month(0-11)} (UTC), inverting the 1900
492
+ * leap-bug offset our importer applies (parse-impl `excelSerialFromDate`). */
493
+ function partsFromSerial(serial: number): { y: number; m: number } {
494
+ const base = Date.UTC(1900, 0, 1);
495
+ let days = Math.floor(serial) - 1;
496
+ if (serial >= 60) days -= 1; // undo Excel's fictitious 1900-02-29
497
+ const d = new Date(base + days * 86_400_000);
498
+ return { y: d.getUTCFullYear(), m: d.getUTCMonth() };
499
+ }
500
+
501
+ /** Resolve a cell value to {year, month(0-11)}, handling both shapes dates take
502
+ * in this app: an Excel serial number (imported xlsx) and a date string like
503
+ * "2025/01/10" or "2025-01-10" (a DATE() formula / typed date). Returns null
504
+ * when the value isn't a recognisable date. */
505
+ function dateParts(value: PivotCell): { y: number; m: number } | null {
506
+ if (typeof value === 'number' && Number.isFinite(value)) return partsFromSerial(value);
507
+ const s = typeof value === 'string' ? value.trim() : '';
508
+ if (s === '') return null;
509
+ if (/^\d+(\.\d+)?$/.test(s)) return partsFromSerial(Number(s)); // numeric string → serial
510
+ const ymd = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(s);
511
+ if (ymd) return { y: Number(ymd[1]), m: Number(ymd[2]) - 1 };
512
+ const t = Date.parse(s);
513
+ if (!Number.isNaN(t)) {
514
+ const d = new Date(t);
515
+ return { y: d.getUTCFullYear(), m: d.getUTCMonth() };
516
+ }
517
+ return null;
518
+ }
519
+
520
+ /** Bucket a row-field value for date grouping. 'none' (or a value that isn't a
521
+ * recognisable date) returns the raw key; year/quarter/month derive a period
522
+ * key. Keys sort lexicographically into chronological order (e.g. "2025-01" <
523
+ * "2025-02", "2025-Q1" < "2025-Q2"). */
524
+ export function dateGroupKey(value: PivotCell, grouping: DateGrouping): string {
525
+ if (grouping === 'none') return keyOf(value);
526
+ const parts = dateParts(value);
527
+ if (!parts) return keyOf(value);
528
+ const { y, m } = parts;
529
+ if (grouping === 'year') return String(y);
530
+ if (grouping === 'quarter') return `${y}-Q${Math.floor(m / 3) + 1}`;
531
+ return `${y}-${String(m + 1).padStart(2, '0')}`; // month
532
+ }
533
+
534
+ type TreeNode = {
535
+ records: PivotCell[][];
536
+ children: Map<string, TreeNode>;
537
+ };
538
+
539
+ function buildTree(records: PivotCell[][], rowFields: PivotFieldRef[]): TreeNode {
540
+ const root: TreeNode = { records: [], children: new Map() };
541
+ for (const rec of records) {
542
+ let node = root;
543
+ node.records.push(rec);
544
+ for (const field of rowFields) {
545
+ const key = dateGroupKey(rec[field.column], field.grouping ?? 'none');
546
+ let child = node.children.get(key);
547
+ if (!child) {
548
+ child = { records: [], children: new Map() };
549
+ node.children.set(key, child);
550
+ }
551
+ child.records.push(rec);
552
+ node = child;
553
+ }
554
+ }
555
+ return root;
556
+ }
557
+
558
+ function aggregate(values: PivotCell[], agg: PivotAggregation): PivotCell {
559
+ const nums: number[] = [];
560
+ let nonNull = 0;
561
+ for (const v of values) {
562
+ if (v == null || v === '') continue;
563
+ nonNull++;
564
+ const n = typeof v === 'number' ? v : Number(v);
565
+ if (Number.isFinite(n)) nums.push(n);
566
+ }
567
+ switch (agg) {
568
+ case 'count':
569
+ return nonNull;
570
+ case 'sum':
571
+ return nums.length === 0 ? 0 : nums.reduce((a, b) => a + b, 0);
572
+ case 'average':
573
+ return nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length;
574
+ case 'min':
575
+ return nums.length === 0 ? null : Math.min(...nums);
576
+ case 'max':
577
+ return nums.length === 0 ? null : Math.max(...nums);
578
+ case 'distinctCount': {
579
+ // Count distinct non-empty values (compared as strings, like Excel's
580
+ // "Distinct Count"). Empty/blank cells are ignored.
581
+ const seen = new Set<string>();
582
+ for (const v of values) {
583
+ if (v == null || v === '') continue;
584
+ seen.add(String(v));
585
+ }
586
+ return seen.size;
587
+ }
588
+ }
589
+ }
590
+
591
+ /** Used by the panel to render a friendly auto-name like
592
+ * "Sum of Sales by Region" so a freshly inserted pivot has a label
593
+ * that explains itself. */
594
+ export function defaultPivotTitle(source: SourceMatrix, model: PivotModel): string {
595
+ const value = model.values[0];
596
+ if (!value) return 'PivotTable';
597
+ const valuePart = `${PIVOT_AGG_LABELS[value.agg]} of ${source.headers[value.column] ?? 'value'}`;
598
+ const rowFields = model.rows.map((r) => source.headers[r.column] ?? 'group');
599
+ const colFields = (model.cols ?? []).map((c) => source.headers[c.column] ?? 'group');
600
+ const byPart = rowFields.length === 0 ? '' : ` by ${rowFields.join(' / ')}`;
601
+ const acrossPart = colFields.length === 0 ? '' : ` across ${colFields.join(' / ')}`;
602
+ if (!byPart && !acrossPart) return valuePart;
603
+ return `${valuePart}${byPart}${acrossPart}`;
604
+ }