@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.
- package/dist/{api-DJhuCjhn.d.cts → api-cyO4EA4_.d.cts} +1 -1
- package/dist/{api-DJhuCjhn.d.ts → api-cyO4EA4_.d.ts} +1 -1
- package/dist/{attachCollab-BmCRT4V_.d.ts → attachCollab-BT4fVzxP.d.ts} +1 -1
- package/dist/{attachCollab-DIUQCCrq.d.cts → attachCollab-DHjb5XYK.d.cts} +1 -1
- package/dist/chrome.cjs +60 -3
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.d.cts +3 -3
- package/dist/chrome.d.ts +3 -3
- package/dist/chrome.js +60 -3
- package/dist/chrome.js.map +1 -1
- package/dist/collab.d.cts +2 -2
- package/dist/collab.d.ts +2 -2
- package/dist/embed/embed-runtime.js +205 -161
- package/dist/{extensions-DZnqTEN2.d.ts → extensions-Cni7mbEm.d.ts} +1 -1
- package/dist/{extensions-FFqdLpk_.d.cts → extensions-DMmPnIYB.d.cts} +1 -1
- package/dist/index.cjs +6424 -1559
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +6409 -1530
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +5426 -561
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.d.cts +5 -5
- package/dist/sheets.d.ts +5 -5
- package/dist/sheets.js +5415 -536
- package/dist/sheets.js.map +1 -1
- package/package.json +8 -7
- package/src/charts/ChartContextMenu.tsx +264 -0
- package/src/charts/ChartLayer.tsx +333 -0
- package/src/charts/ChartOverlay.tsx +293 -0
- package/src/charts/ChartsPanel.tsx +232 -0
- package/src/charts/FormatChartDialog.tsx +419 -0
- package/src/charts/InsertChartDialog.tsx +478 -0
- package/src/charts/build-option.ts +476 -0
- package/src/charts/charts-context.tsx +205 -0
- package/src/charts/echarts-init.ts +58 -0
- package/src/charts/hit-test.ts +130 -0
- package/src/charts/insert-chart.ts +106 -0
- package/src/charts/naming.ts +38 -0
- package/src/charts/render-to-png.ts +117 -0
- package/src/charts/resources.ts +108 -0
- package/src/charts/types.ts +239 -0
- package/src/charts/univer-dom.ts +102 -0
- package/src/chrome/CommentsPanel.tsx +418 -0
- package/src/chrome/HistoryPanel.tsx +304 -0
- package/src/chrome/InsertPivotDialog.tsx +74 -2
- package/src/chrome/PanelHost.tsx +56 -0
- package/src/chrome/PanelRail.tsx +90 -0
- package/src/chrome/PivotFieldsPanel.tsx +1021 -0
- package/src/chrome/TablesPanel.tsx +283 -0
- package/src/chrome/Toolbar.tsx +7 -1
- package/src/chrome/panel-context.tsx +55 -0
- package/src/chrome/panel-registry.ts +48 -0
- package/src/chrome/panel-shell.tsx +151 -0
- package/src/pivots/apply.ts +139 -0
- package/src/pivots/compute.ts +604 -0
- package/src/pivots/fields-model.ts +267 -0
- package/src/pivots/types.ts +137 -0
- package/src/sheets/CasualSheets.tsx +61 -35
- 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
|
+
}
|