@peter.prib/renderengine 0.13.0 → 0.14.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/README.md +454 -302
- package/dist/renderengine-workspace.umd.js +2203 -113
- package/dist/renderengine.umd.js +2203 -113
- package/documentation/REFERENCE.md +30 -1
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AreaChart.js +20 -3
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BoxPlotChart.js +2 -2
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/ChordDiagram.js +4 -4
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/FunnelChart.js +1 -1
- package/src/charting/charts/HeatmapChart.js +4 -1
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +5 -4
- package/src/charting/charts/RadialBarChart.js +2 -2
- package/src/charting/charts/SankeyDiagram.js +4 -4
- package/src/charting/charts/ScatterChart.js +6 -3
- package/src/charting/charts/StackedBarChart.js +82 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
- package/src/charting/charts/StreamGraphChart.js +8 -0
- package/src/charting/charts/SunburstChart.js +2 -2
- package/src/charting/charts/TreemapChart.js +4 -4
- package/src/charting/charts/ViolinChart.js +2 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +544 -21
- package/src/index.js +98 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TableChart } from '../charts/TableChart.js';
|
|
2
|
+
import { compileFormula } from '../../kernelExpr.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* PivotView — the state of a pivot grid (a cube's grid: dimensions on a row axis and a column axis,
|
|
@@ -28,6 +29,16 @@ import { TableChart } from '../charts/TableChart.js';
|
|
|
28
29
|
export class PivotView {
|
|
29
30
|
static ROLLUP_COLUMN = '_rollup';
|
|
30
31
|
|
|
32
|
+
static AGGREGATIONS = [
|
|
33
|
+
{ key: 'sum', label: 'Sum' },
|
|
34
|
+
{ key: 'count', label: 'Count' },
|
|
35
|
+
{ key: 'avg', label: 'Average' },
|
|
36
|
+
{ key: 'median', label: 'Median' },
|
|
37
|
+
{ key: 'min', label: 'Minimum' },
|
|
38
|
+
{ key: 'max', label: 'Maximum' },
|
|
39
|
+
{ key: 'stddev', label: 'Std Dev' },
|
|
40
|
+
];
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* @param {Array<object>} rows result rows: dimension values, measure values, and `_rollup`
|
|
33
44
|
* @param {object} [options]
|
|
@@ -55,6 +66,7 @@ export class PivotView {
|
|
|
55
66
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
56
67
|
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
57
68
|
* measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
|
|
69
|
+
* @param {string[]} [options.visibleMeasures] subset of `measures` currently shown in the grid and chart
|
|
58
70
|
*/
|
|
59
71
|
constructor(rows, options = {}) {
|
|
60
72
|
this.onChange = options.onChange ?? null;
|
|
@@ -70,6 +82,18 @@ export class PivotView {
|
|
|
70
82
|
this.lightRules = options.trafficLights ?? null;
|
|
71
83
|
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
72
84
|
this.lights = this.lightRules ? 'rules' : null;
|
|
85
|
+
/** Optional sort on the row axis: `{ columnKey, measure, direction: 'asc'|'desc' }`, or null. */
|
|
86
|
+
this.rowSort = null;
|
|
87
|
+
/** Optional Top-N / Bottom-N filter on row siblings: `{ count, columnKey, measure, direction: 'desc'|'asc' }`, or null. */
|
|
88
|
+
this.topN = null;
|
|
89
|
+
/** Post-rollup derived formula measures: `Map<name, { name, formula, params, fn }>`. */
|
|
90
|
+
this.derivedMeasures = new Map();
|
|
91
|
+
/** Summary / aggregation mode: `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`. */
|
|
92
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === options.aggregation) ? options.aggregation : 'sum';
|
|
93
|
+
/** Optional raw fact rows behind the cube, enabling exact cell-level Count, Average, Median, Min, Max, and Std Dev. */
|
|
94
|
+
this.facts = Array.isArray(options.facts) ? options.facts : null;
|
|
95
|
+
/** How cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
96
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(options.valueMode) ? options.valueMode : 'value';
|
|
73
97
|
this._load(rows, options);
|
|
74
98
|
}
|
|
75
99
|
|
|
@@ -86,6 +110,10 @@ export class PivotView {
|
|
|
86
110
|
_load(rows, options) {
|
|
87
111
|
this._options = options;
|
|
88
112
|
this.rows = rows;
|
|
113
|
+
if (Array.isArray(options.facts)) this.facts = options.facts;
|
|
114
|
+
if (options.aggregation && PivotView.AGGREGATIONS.some(a => a.key === options.aggregation)) {
|
|
115
|
+
this.aggregation = options.aggregation;
|
|
116
|
+
}
|
|
89
117
|
this.rowAxis = [...(options.rowAxis ?? [])];
|
|
90
118
|
this.columnAxis = [...(options.columnAxis ?? [])];
|
|
91
119
|
this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
|
|
@@ -94,9 +122,42 @@ export class PivotView {
|
|
|
94
122
|
const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
|
|
95
123
|
this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
|
|
96
124
|
this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
|
|
97
|
-
|
|
125
|
+
if (Array.isArray(options.derivedMeasures)) {
|
|
126
|
+
this.derivedMeasures.clear();
|
|
127
|
+
for (const spec of options.derivedMeasures) {
|
|
128
|
+
if (spec?.name && spec.formula) this.derivedMeasures.set(spec.name, PivotView._compileDerived(spec));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const baseMeasures = options.measures ?? this._defaultMeasures(rows, options.columns);
|
|
132
|
+
const derivedNames = [...this.derivedMeasures.keys()].filter(m => !baseMeasures.includes(m));
|
|
133
|
+
this.allMeasures = [...baseMeasures, ...derivedNames];
|
|
134
|
+
const keptMeasures = (options.visibleMeasures ?? this.measures ?? this.allMeasures).filter(m => this.allMeasures.includes(m));
|
|
135
|
+
this.measures = keptMeasures.length > 0 ? keptMeasures : [...this.allMeasures];
|
|
98
136
|
this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
|
|
99
137
|
this._index();
|
|
138
|
+
if (options.topN && options.topN.count > 0) {
|
|
139
|
+
this.topN = {
|
|
140
|
+
count: options.topN.count,
|
|
141
|
+
columnKey: options.topN.columnKey ?? this.columnRoot.key,
|
|
142
|
+
measure: options.topN.measure ?? this.measures[0],
|
|
143
|
+
direction: options.topN.direction === 'asc' ? 'asc' : 'desc',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
static _compileDerived({ name, formula, params = null }) {
|
|
149
|
+
if (typeof formula === 'function') {
|
|
150
|
+
return { name, formula, params, fn: (row) => formula(row) };
|
|
151
|
+
}
|
|
152
|
+
const builtins = new Set(['abs', 'min', 'max', 'sqrt', 'log', 'exp', 'floor', 'ceil', 'round', 'clamp']);
|
|
153
|
+
const paramList = Array.isArray(params) && params.length > 0
|
|
154
|
+
? params
|
|
155
|
+
: [...new Set((String(formula).match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter(id => !builtins.has(id)))];
|
|
156
|
+
const compiled = compileFormula(String(formula), paramList);
|
|
157
|
+
const fn = (row) => (paramList.some(p => typeof row[p] !== 'number')
|
|
158
|
+
? null
|
|
159
|
+
: compiled(...paramList.map(p => row[p])));
|
|
160
|
+
return { name, formula, params: paramList, fn };
|
|
100
161
|
}
|
|
101
162
|
|
|
102
163
|
_defaultMeasures(rows, columns) {
|
|
@@ -128,6 +189,7 @@ export class PivotView {
|
|
|
128
189
|
|
|
129
190
|
_index() {
|
|
130
191
|
this.cells = new Map();
|
|
192
|
+
this._sampleCache = new Map();
|
|
131
193
|
this.unplaced = 0;
|
|
132
194
|
this.rowRoot = PivotView._node([], null);
|
|
133
195
|
this.columnRoot = PivotView._node([], null);
|
|
@@ -176,12 +238,35 @@ export class PivotView {
|
|
|
176
238
|
|
|
177
239
|
/**
|
|
178
240
|
* The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
|
|
179
|
-
* members below it, indented; the grand total last. `{ node, depth }`.
|
|
241
|
+
* members below it, indented; the grand total last. `{ node, depth }`. When `rowSort` is set,
|
|
242
|
+
* siblings at each level are ordered by that column/measure (nulls last).
|
|
180
243
|
*/
|
|
181
244
|
visibleRows() {
|
|
182
245
|
const out = [];
|
|
246
|
+
const sortList = (list, spec) => {
|
|
247
|
+
const { columnKey, measure, direction } = spec;
|
|
248
|
+
const sign = direction === 'desc' ? -1 : 1;
|
|
249
|
+
const colNode = this._findColumnNode(columnKey) ?? this.columnRoot;
|
|
250
|
+
const col = { node: colNode, measure: this.measures.includes(measure) ? measure : this.measures[0] };
|
|
251
|
+
return [...list].sort((a, b) => {
|
|
252
|
+
const va = this.value(a, col), vb = this.value(b, col);
|
|
253
|
+
if (va == null) return vb == null ? 0 : 1;
|
|
254
|
+
if (vb == null) return -1;
|
|
255
|
+
return sign * (va - vb);
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
const order = (children) => {
|
|
259
|
+
let list = [...children.values()];
|
|
260
|
+
if (this.topN && this.topN.count > 0) {
|
|
261
|
+
list = sortList(list, this.topN).slice(0, this.topN.count);
|
|
262
|
+
}
|
|
263
|
+
if (this.rowSort) {
|
|
264
|
+
list = sortList(list, this.rowSort);
|
|
265
|
+
}
|
|
266
|
+
return list;
|
|
267
|
+
};
|
|
183
268
|
const walk = (node) => {
|
|
184
|
-
for (const child of node.children
|
|
269
|
+
for (const child of order(node.children)) {
|
|
185
270
|
out.push({ node: child, depth: child.path.length - 1 });
|
|
186
271
|
if (!this.isCollapsed('rows', child)) walk(child);
|
|
187
272
|
}
|
|
@@ -191,6 +276,19 @@ export class PivotView {
|
|
|
191
276
|
return out;
|
|
192
277
|
}
|
|
193
278
|
|
|
279
|
+
_findColumnNode(key) {
|
|
280
|
+
if (!key || key === this.columnRoot.key) return this.columnRoot;
|
|
281
|
+
const walk = (node) => {
|
|
282
|
+
for (const child of node.children.values()) {
|
|
283
|
+
if (child.key === key) return child;
|
|
284
|
+
const found = walk(child);
|
|
285
|
+
if (found) return found;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
};
|
|
289
|
+
return walk(this.columnRoot);
|
|
290
|
+
}
|
|
291
|
+
|
|
194
292
|
/**
|
|
195
293
|
* The value columns drawn, left to right: each column-axis member's members (unless it is
|
|
196
294
|
* collapsed, or has none), then its own total; the grand total last. One per measure for each.
|
|
@@ -258,10 +356,140 @@ export class PivotView {
|
|
|
258
356
|
return out.map(({ id, ...cell }) => cell);
|
|
259
357
|
}
|
|
260
358
|
|
|
261
|
-
/**
|
|
359
|
+
/** Evaluates `measure` on a single data or fact row (handling derived formulas). */
|
|
360
|
+
_rowMeasureValue(row, measure) {
|
|
361
|
+
if (!row) return null;
|
|
362
|
+
if (this.derivedMeasures.has(measure)) {
|
|
363
|
+
let scope = row;
|
|
364
|
+
if (this.derivedMeasures.size > 1) {
|
|
365
|
+
scope = { ...row };
|
|
366
|
+
for (const [name, d] of this.derivedMeasures) {
|
|
367
|
+
const val = d.fn(scope);
|
|
368
|
+
scope[name] = Number.isFinite(val) ? Math.round(val * 1000) / 1000 : null;
|
|
369
|
+
if (name === measure) return scope[name];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const d = this.derivedMeasures.get(measure);
|
|
373
|
+
const v = d.fn(scope);
|
|
374
|
+
return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
|
|
375
|
+
}
|
|
376
|
+
return row[measure] ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Collects the sample of numeric values behind `(rowNode, colNode, measure)` for statistical
|
|
381
|
+
* aggregations (`count`, `avg`, `median`, `min`, `max`, `stddev`):
|
|
382
|
+
* 1) From `this.facts` when raw fact rows are supplied,
|
|
383
|
+
* 2) Else from `row._values?.[measure]` when pre-attached,
|
|
384
|
+
* 3) Else from the descendant leaf cells in `this.cells`.
|
|
385
|
+
*/
|
|
386
|
+
_cellSample(rowNode, colNode, measure) {
|
|
387
|
+
const cacheKey = `${rowNode.key}|${colNode.key}|${measure}`;
|
|
388
|
+
if (this._sampleCache?.has(cacheKey)) return this._sampleCache.get(cacheKey);
|
|
389
|
+
let values = [];
|
|
390
|
+
const row = this.cell(rowNode, colNode);
|
|
391
|
+
if (Array.isArray(this.facts) && this.facts.length > 0) {
|
|
392
|
+
const matching = this.facts.filter(f => rowNode.path.every((v, i) => f[this.rowAxis[i]] === v)
|
|
393
|
+
&& colNode.path.every((v, j) => f[this.columnAxis[j]] === v)
|
|
394
|
+
&& this.pageAxis.every(p => p.value === null || String(f[p.dimension]) === String(p.value))
|
|
395
|
+
&& this.memberFilters.every(m => (m.keep ? m.keep.includes(f[m.dimension]) : !m.remove?.includes(f[m.dimension]))));
|
|
396
|
+
values = matching.map(f => this._rowMeasureValue(f, measure)).filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
397
|
+
} else if (Array.isArray(row?._values?.[measure])) {
|
|
398
|
+
values = row._values[measure].filter(v => typeof v === 'number' && Number.isFinite(v));
|
|
399
|
+
} else {
|
|
400
|
+
const leaves = (node) => {
|
|
401
|
+
if (node.children.size === 0) return [node];
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const c of node.children.values()) out.push(...leaves(c));
|
|
404
|
+
return out;
|
|
405
|
+
};
|
|
406
|
+
const rLeaves = leaves(rowNode);
|
|
407
|
+
const cLeaves = leaves(colNode);
|
|
408
|
+
for (const r of rLeaves) {
|
|
409
|
+
for (const c of cLeaves) {
|
|
410
|
+
const leafRow = this.cell(r, c);
|
|
411
|
+
const v = this._rowMeasureValue(leafRow, measure);
|
|
412
|
+
if (typeof v === 'number' && Number.isFinite(v)) values.push(v);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
this._sampleCache?.set(cacheKey, values);
|
|
417
|
+
return values;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Computes `mode` (`'sum'|'count'|'avg'|'median'|'min'|'max'|'stddev'`) over `values`. */
|
|
421
|
+
static aggregateValues(values, mode = 'sum', sumValue = null, countOverride = null) {
|
|
422
|
+
if (mode === 'count') {
|
|
423
|
+
if (typeof countOverride === 'number') return countOverride;
|
|
424
|
+
return Array.isArray(values) ? values.length : 0;
|
|
425
|
+
}
|
|
426
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
427
|
+
return mode === 'sum' ? sumValue : null;
|
|
428
|
+
}
|
|
429
|
+
const round3 = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null);
|
|
430
|
+
switch (mode) {
|
|
431
|
+
case 'sum':
|
|
432
|
+
return sumValue !== null ? sumValue : round3(values.reduce((a, b) => a + b, 0));
|
|
433
|
+
case 'avg': {
|
|
434
|
+
if (typeof countOverride === 'number' && countOverride > 0 && typeof sumValue === 'number') {
|
|
435
|
+
return round3(sumValue / countOverride);
|
|
436
|
+
}
|
|
437
|
+
return round3(values.reduce((a, b) => a + b, 0) / values.length);
|
|
438
|
+
}
|
|
439
|
+
case 'median': {
|
|
440
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
441
|
+
const mid = Math.floor(sorted.length / 2);
|
|
442
|
+
const med = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
443
|
+
return round3(med);
|
|
444
|
+
}
|
|
445
|
+
case 'min':
|
|
446
|
+
return round3(Math.min(...values));
|
|
447
|
+
case 'max':
|
|
448
|
+
return round3(Math.max(...values));
|
|
449
|
+
case 'stddev': {
|
|
450
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
451
|
+
const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length;
|
|
452
|
+
return round3(Math.sqrt(variance));
|
|
453
|
+
}
|
|
454
|
+
default:
|
|
455
|
+
return sumValue;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The value at a row and column, from the row the data gave for that pair (or a post-rollup derived formula, or the active `aggregation`); null when it gave none. */
|
|
262
460
|
value(rowNode, column) {
|
|
263
461
|
const row = this.cell(rowNode, column.node);
|
|
264
|
-
|
|
462
|
+
if (row === null) return null;
|
|
463
|
+
const raw = this._rowMeasureValue(row, column.measure);
|
|
464
|
+
if (!this.aggregation || this.aggregation === 'sum') return raw;
|
|
465
|
+
const sample = this._cellSample(rowNode, column.node, column.measure);
|
|
466
|
+
return PivotView.aggregateValues(sample, this.aggregation, raw, row._count);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The value at a row and column in the active `valueMode`: the raw value (`'value'`), or its share
|
|
471
|
+
* (`0..100`) of the row total (`'rowPercent'`), column total (`'columnPercent'`), or grand total
|
|
472
|
+
* (`'totalPercent'`). Returns null when the cell or its reference total is missing or zero.
|
|
473
|
+
*/
|
|
474
|
+
displayValue(rowNode, column) {
|
|
475
|
+
const v = this.value(rowNode, column);
|
|
476
|
+
if (this.valueMode === 'value' || typeof v !== 'number') return v;
|
|
477
|
+
const base = this.valueMode === 'rowPercent'
|
|
478
|
+
? this.value(rowNode, { node: this.columnRoot, measure: column.measure })
|
|
479
|
+
: this.valueMode === 'columnPercent'
|
|
480
|
+
? this.value(this.rowRoot, column)
|
|
481
|
+
: this.value(this.rowRoot, { node: this.columnRoot, measure: column.measure });
|
|
482
|
+
if (typeof base !== 'number' || base === 0) return null;
|
|
483
|
+
return Math.round((v / base) * 1000) / 10;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** How a cell at `(rowNode, column)` is formatted for display in the grid. */
|
|
487
|
+
cellText(rowNode, column, nullText = '—') {
|
|
488
|
+
const row = this.cell(rowNode, column.node);
|
|
489
|
+
if (row === null) return '';
|
|
490
|
+
if (this.valueMode === 'value') return this.valueText(column.measure, this.value(rowNode, column), nullText);
|
|
491
|
+
const pct = this.displayValue(rowNode, column);
|
|
492
|
+
return pct == null ? nullText : `${pct.toFixed(1)}%`;
|
|
265
493
|
}
|
|
266
494
|
|
|
267
495
|
// ── Changes ───────────────────────────────────────────────────────────────
|
|
@@ -283,14 +511,135 @@ export class PivotView {
|
|
|
283
511
|
this._changed();
|
|
284
512
|
}
|
|
285
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Expands an axis to `level` (1 = outermost dimension only, 2 = first two dimensions, etc.) by
|
|
516
|
+
* expanding collapsible members above `level` and collapsing those at or below `level`.
|
|
517
|
+
*/
|
|
518
|
+
expandToLevel(axis, level) {
|
|
519
|
+
const root = axis === 'rows' ? this.rowRoot : this.columnRoot;
|
|
520
|
+
this.collapsed[axis].clear();
|
|
521
|
+
const walk = (node) => {
|
|
522
|
+
for (const c of node.children.values()) {
|
|
523
|
+
if (c.path.length >= level && this.collapsible(c)) this.collapsed[axis].add(c.key);
|
|
524
|
+
walk(c);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
walk(root);
|
|
528
|
+
this._changed();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Sets how cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
|
|
532
|
+
setValueMode(mode) {
|
|
533
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(mode) ? mode : 'value';
|
|
534
|
+
this._changed();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Sets the summary / aggregation function for cube cells:
|
|
539
|
+
* `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`.
|
|
540
|
+
*/
|
|
541
|
+
setAggregation(mode) {
|
|
542
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === mode) ? mode : 'sum';
|
|
543
|
+
this._changed();
|
|
544
|
+
}
|
|
545
|
+
|
|
286
546
|
/** Rows become columns and columns rows. */
|
|
287
547
|
swapAxes() {
|
|
288
548
|
[this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
|
|
289
549
|
this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
|
|
550
|
+
this.rowSort = null;
|
|
551
|
+
this.topN = null;
|
|
290
552
|
this._index();
|
|
291
553
|
this._changed();
|
|
292
554
|
}
|
|
293
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Sorts row members within each level by `column` (`{ node?, columnKey?, measure }`) in `direction`
|
|
558
|
+
* ('asc', 'desc', or null to clear).
|
|
559
|
+
*/
|
|
560
|
+
sortRowsBy(column, direction = 'desc') {
|
|
561
|
+
if (!direction || !column) {
|
|
562
|
+
this.rowSort = null;
|
|
563
|
+
} else {
|
|
564
|
+
const columnKey = column.columnKey ?? column.node?.key ?? this.columnRoot.key;
|
|
565
|
+
const measure = column.measure ?? this.measures[0];
|
|
566
|
+
this.rowSort = { columnKey, measure, direction };
|
|
567
|
+
}
|
|
568
|
+
this._changed();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Clears any row sort, returning row members to their data order. */
|
|
572
|
+
clearRowSort() {
|
|
573
|
+
this.rowSort = null;
|
|
574
|
+
this._changed();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Filters sibling row members at each level to the top (`direction = 'desc'`) or bottom
|
|
579
|
+
* (`direction = 'asc'`) `count` members by `measure` / `column`. Pass `count <= 0` or `null` to clear.
|
|
580
|
+
*/
|
|
581
|
+
setTopN(count, { column = null, columnKey = null, measure = null, direction = 'desc' } = {}) {
|
|
582
|
+
if (!count || count <= 0) {
|
|
583
|
+
this.topN = null;
|
|
584
|
+
} else {
|
|
585
|
+
this.topN = {
|
|
586
|
+
count: Math.floor(count),
|
|
587
|
+
columnKey: columnKey ?? column?.columnKey ?? column?.node?.key ?? this.columnRoot.key,
|
|
588
|
+
measure: measure ?? column?.measure ?? this.measures[0],
|
|
589
|
+
direction: direction === 'asc' ? 'asc' : 'desc',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
this._changed();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Clears any Top-N / Bottom-N row filter, showing all row members again. */
|
|
596
|
+
clearTopN() {
|
|
597
|
+
this.topN = null;
|
|
598
|
+
this._changed();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Adds or updates a post-rollup derived formula measure (`{ name, formula, params? }`), evaluated at
|
|
603
|
+
* every detail, subtotal, and grand-total cell via `compileFormula`.
|
|
604
|
+
*/
|
|
605
|
+
addDerivedMeasure({ name, formula, params = {} }) {
|
|
606
|
+
if (!name || !formula) return;
|
|
607
|
+
this.derivedMeasures.set(name, PivotView._compileDerived({ name, formula, params }));
|
|
608
|
+
if (!this.allMeasures.includes(name)) this.allMeasures.push(name);
|
|
609
|
+
if (!this.measures.includes(name)) this.measures.push(name);
|
|
610
|
+
this._changed();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Removes a derived formula measure by name. */
|
|
614
|
+
removeDerivedMeasure(name) {
|
|
615
|
+
if (!this.derivedMeasures.has(name)) return;
|
|
616
|
+
this.derivedMeasures.delete(name);
|
|
617
|
+
this.allMeasures = this.allMeasures.filter(m => m !== name);
|
|
618
|
+
const next = this.measures.filter(m => m !== name);
|
|
619
|
+
this.measures = next.length > 0 ? next : [...this.allMeasures];
|
|
620
|
+
this._changed();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Shows or hides a measure (`allMeasures` holds every available measure; at least one stays visible). */
|
|
624
|
+
toggleMeasure(measure) {
|
|
625
|
+
if (!this.allMeasures.includes(measure)) return;
|
|
626
|
+
if (this.measures.includes(measure)) {
|
|
627
|
+
if (this.measures.length <= 1) return;
|
|
628
|
+
this.measures = this.measures.filter(m => m !== measure);
|
|
629
|
+
} else {
|
|
630
|
+
this.measures = this.allMeasures.filter(m => this.measures.includes(m) || m === measure);
|
|
631
|
+
}
|
|
632
|
+
this._changed();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Sets the visible measures (kept in `allMeasures` order; ignored if none match). */
|
|
636
|
+
setMeasures(measures) {
|
|
637
|
+
const next = this.allMeasures.filter(m => measures.includes(m));
|
|
638
|
+
if (next.length === 0) return;
|
|
639
|
+
this.measures = next;
|
|
640
|
+
this._changed();
|
|
641
|
+
}
|
|
642
|
+
|
|
294
643
|
_changed() {
|
|
295
644
|
this.onChange?.(this);
|
|
296
645
|
for (const listener of this._listeners) listener(this);
|
|
@@ -473,24 +822,85 @@ export class PivotView {
|
|
|
473
822
|
const names = columns.map(seriesName);
|
|
474
823
|
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
475
824
|
[category, rowLabels[r]],
|
|
476
|
-
...columns.map((c, i) => [names[i], this.
|
|
825
|
+
...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
|
|
477
826
|
]));
|
|
478
827
|
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
479
828
|
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
480
829
|
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
481
830
|
const measureOf = (c) => hint(c.measure).measure ?? null;
|
|
831
|
+
const primaryMeasure = this.measures[0] ?? null;
|
|
832
|
+
const totalCol = this.columnRoot.hasCells && primaryMeasure ? { node: this.columnRoot, measure: primaryMeasure } : null;
|
|
833
|
+
const rowTotals = totalCol
|
|
834
|
+
? rowNodes.map((node, r) => ({ label: rowLabels[r], value: this.displayValue(node, totalCol) })).filter(d => d.value != null)
|
|
835
|
+
: null;
|
|
482
836
|
return {
|
|
483
837
|
rows,
|
|
484
838
|
columns: [
|
|
485
839
|
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
486
840
|
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
487
841
|
],
|
|
488
|
-
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
489
|
-
complete: this.unplaced === 0,
|
|
842
|
+
// Members not broken down further cover the whole answer between them, unless rows went unplaced or Top-N filtered.
|
|
843
|
+
complete: this.unplaced === 0 && !this.topN,
|
|
490
844
|
members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
|
|
845
|
+
columnMembers: new Map(columns.map((c, i) => [names[i], c.node])),
|
|
846
|
+
rowTotals,
|
|
847
|
+
primaryMeasure: primaryMeasure ? { name: primaryMeasure, measure: measureOf({ measure: primaryMeasure }) } : null,
|
|
848
|
+
hierarchy: this.hierarchyData(),
|
|
491
849
|
};
|
|
492
850
|
}
|
|
493
851
|
|
|
852
|
+
/**
|
|
853
|
+
* The row-axis member tree shaped for hierarchical charts (`treemap`, `sunburst`, `outline`, `icicle`):
|
|
854
|
+
* `Array<{ label, value?, children? }>`, using the single column/measure showing (or the row totals
|
|
855
|
+
* column when multiple column members are showing) when the measure is additive and all values are
|
|
856
|
+
* non-negative, or null when hierarchical part-of-whole does not fit.
|
|
857
|
+
*/
|
|
858
|
+
hierarchyData() {
|
|
859
|
+
if (this.unplaced > 0 || this.topN || this.rowAxis.length < 1) return null;
|
|
860
|
+
const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
|
|
861
|
+
const col = columns.length === 1
|
|
862
|
+
? columns[0]
|
|
863
|
+
: (columns.length > 1 && this.columnRoot.hasCells && this.measures.length > 0
|
|
864
|
+
? { node: this.columnRoot, measure: this.measures[0] }
|
|
865
|
+
: null);
|
|
866
|
+
if (!col) return null;
|
|
867
|
+
const hint = (this._options.columns ?? []).find(c => c.name === col.measure) ?? {};
|
|
868
|
+
if (hint.measure !== 'additive') return null;
|
|
869
|
+
const allNodes = [];
|
|
870
|
+
const collect = (node) => {
|
|
871
|
+
for (const child of node.children.values()) {
|
|
872
|
+
allNodes.push(child);
|
|
873
|
+
if (!this.isCollapsed('rows', child)) collect(child);
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
collect(this.rowRoot);
|
|
877
|
+
if (allNodes.length === 0) return null;
|
|
878
|
+
const labelMap = new Map(allNodes.map((n, i) => [n, PivotView._labels(allNodes)[i]]));
|
|
879
|
+
let hasDeep = false;
|
|
880
|
+
let negative = false;
|
|
881
|
+
const build = (parent) => {
|
|
882
|
+
const list = [];
|
|
883
|
+
for (const child of parent.children.values()) {
|
|
884
|
+
const label = labelMap.get(child) ?? PivotView.label(child);
|
|
885
|
+
if (child.children.size > 0 && !this.isCollapsed('rows', child)) {
|
|
886
|
+
const children = build(child);
|
|
887
|
+
if (children.length > 0) {
|
|
888
|
+
hasDeep = true;
|
|
889
|
+
list.push({ label, children });
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
const v = this.value(child, col);
|
|
893
|
+
if (typeof v === 'number' && v < 0) negative = true;
|
|
894
|
+
if (typeof v === 'number' && v > 0) list.push({ label, value: v });
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return list;
|
|
898
|
+
};
|
|
899
|
+
const tree = build(this.rowRoot);
|
|
900
|
+
if (negative || tree.length === 0 || (!hasDeep && this.rowAxis.length < 2)) return null;
|
|
901
|
+
return { tree, members: new Map(allNodes.map(n => [labelMap.get(n) ?? PivotView.label(n), n])), measure: col.measure };
|
|
902
|
+
}
|
|
903
|
+
|
|
494
904
|
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
495
905
|
static _labels(nodes) {
|
|
496
906
|
const short = nodes.map(n => PivotView.label(n));
|
|
@@ -501,19 +911,33 @@ export class PivotView {
|
|
|
501
911
|
}
|
|
502
912
|
|
|
503
913
|
/**
|
|
504
|
-
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the
|
|
505
|
-
* finest level drills down (the host's, through onDrill).
|
|
914
|
+
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the axis's
|
|
915
|
+
* finest level drills down (the host's, through onDrill). `axis` defaults to `'rows'`, or if a
|
|
916
|
+
* row label does not match and `series` is given, tries `'columns'`. Says which it did, or null.
|
|
506
917
|
*/
|
|
507
|
-
drillFrom(label) {
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
this.
|
|
512
|
-
|
|
918
|
+
drillFrom(label, series = null) {
|
|
919
|
+
const data = this.chartData();
|
|
920
|
+
const rowNode = data.members.get(label) ?? data.hierarchy?.members.get(label);
|
|
921
|
+
if (rowNode && rowNode.path.length > 0) {
|
|
922
|
+
if (this.isCollapsed('rows', rowNode)) {
|
|
923
|
+
this.toggle('rows', rowNode);
|
|
924
|
+
return 'expanded';
|
|
925
|
+
}
|
|
926
|
+
if (rowNode.path.length === this.rowAxis.length && this.onDrill) {
|
|
927
|
+
this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: rowNode.path });
|
|
928
|
+
return 'drilled';
|
|
929
|
+
}
|
|
513
930
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
931
|
+
const colNode = series ? data.columnMembers.get(series) : data.columnMembers.get(label);
|
|
932
|
+
if (colNode && colNode.path.length > 0) {
|
|
933
|
+
if (this.isCollapsed('columns', colNode)) {
|
|
934
|
+
this.toggle('columns', colNode);
|
|
935
|
+
return 'expanded';
|
|
936
|
+
}
|
|
937
|
+
if (colNode.path.length === this.columnAxis.length && this.onDrill) {
|
|
938
|
+
this.onDrill({ axis: 'columns', dimension: this.columnAxis.at(-1), direction: 'down', member: colNode.path });
|
|
939
|
+
return 'drilled';
|
|
940
|
+
}
|
|
517
941
|
}
|
|
518
942
|
return null;
|
|
519
943
|
}
|
|
@@ -545,12 +969,26 @@ export class PivotView {
|
|
|
545
969
|
}
|
|
546
970
|
}
|
|
547
971
|
items.push(...this._memberFilterItems(dimension, node));
|
|
972
|
+
if (axis === 'columns') items.push(...this._columnSortItems(node));
|
|
548
973
|
items.push({ separator: true },
|
|
549
974
|
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
550
975
|
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
551
976
|
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
552
977
|
}
|
|
553
978
|
|
|
979
|
+
_columnSortItems(node) {
|
|
980
|
+
if (this.rowAxis.length === 0 || !node.hasCells) return [];
|
|
981
|
+
const measure = this.measures[0];
|
|
982
|
+
const active = this.rowSort?.columnKey === node.key && this.rowSort?.measure === measure ? this.rowSort.direction : null;
|
|
983
|
+
const label = PivotView.label(node);
|
|
984
|
+
return [
|
|
985
|
+
{ separator: true },
|
|
986
|
+
{ label: `Sort rows by ${label} descending${active === 'desc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'desc') },
|
|
987
|
+
{ label: `Sort rows by ${label} ascending${active === 'asc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'asc') },
|
|
988
|
+
...(this.rowSort ? [{ label: 'Clear row sort', action: () => this.clearRowSort() }] : []),
|
|
989
|
+
];
|
|
990
|
+
}
|
|
991
|
+
|
|
554
992
|
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
555
993
|
|
|
556
994
|
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
@@ -639,7 +1077,13 @@ export class PivotView {
|
|
|
639
1077
|
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
640
1078
|
*/
|
|
641
1079
|
displayState() {
|
|
642
|
-
|
|
1080
|
+
const state = { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
|
|
1081
|
+
if (this.rowSort) state.rowSort = { ...this.rowSort };
|
|
1082
|
+
if (this.topN) state.topN = { ...this.topN };
|
|
1083
|
+
if (this.allMeasures.length > 1 && this.measures.length < this.allMeasures.length) state.measures = [...this.measures];
|
|
1084
|
+
if (this.valueMode !== 'value') state.valueMode = this.valueMode;
|
|
1085
|
+
if (this.aggregation !== 'sum') state.aggregation = this.aggregation;
|
|
1086
|
+
return state;
|
|
643
1087
|
}
|
|
644
1088
|
|
|
645
1089
|
/**
|
|
@@ -650,6 +1094,16 @@ export class PivotView {
|
|
|
650
1094
|
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
651
1095
|
const lights = state.lights ?? null;
|
|
652
1096
|
this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
|
|
1097
|
+
this.rowSort = state.rowSort && ['asc', 'desc'].includes(state.rowSort.direction) ? { ...state.rowSort } : null;
|
|
1098
|
+
this.topN = state.topN && state.topN.count > 0 && ['asc', 'desc'].includes(state.topN.direction) ? { ...state.topN } : null;
|
|
1099
|
+
if (Array.isArray(state.measures)) {
|
|
1100
|
+
const kept = this.allMeasures.filter(m => state.measures.includes(m));
|
|
1101
|
+
if (kept.length > 0) this.measures = kept;
|
|
1102
|
+
} else {
|
|
1103
|
+
this.measures = [...this.allMeasures];
|
|
1104
|
+
}
|
|
1105
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(state.valueMode) ? state.valueMode : 'value';
|
|
1106
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === state.aggregation) ? state.aggregation : 'sum';
|
|
653
1107
|
this._changed();
|
|
654
1108
|
}
|
|
655
1109
|
|
|
@@ -699,12 +1153,81 @@ export class PivotView {
|
|
|
699
1153
|
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
700
1154
|
}
|
|
701
1155
|
|
|
702
|
-
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
|
|
1156
|
+
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, measures, sort, copy the grid. */
|
|
703
1157
|
gridMenu() {
|
|
704
1158
|
const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
|
|
1159
|
+
const measureItems = this.allMeasures.length > 1 ? [{
|
|
1160
|
+
label: 'Measures',
|
|
1161
|
+
items: this.allMeasures.map(m => ({
|
|
1162
|
+
label: `${m}${this.measures.includes(m) ? ' ✓' : ''}`,
|
|
1163
|
+
disabled: this.measures.length === 1 && this.measures[0] === m,
|
|
1164
|
+
action: () => this.toggleMeasure(m),
|
|
1165
|
+
})),
|
|
1166
|
+
}] : [];
|
|
1167
|
+
const sortItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
1168
|
+
label: 'Sort rows',
|
|
1169
|
+
items: [
|
|
1170
|
+
...this.measures.flatMap(m => [
|
|
1171
|
+
{
|
|
1172
|
+
label: `By ${this.measures.length > 1 ? m + ' total' : 'total'} — highest first${this.rowSort?.columnKey === this.columnRoot.key && this.rowSort?.measure === m && this.rowSort?.direction === 'desc' ? ' ✓' : ''}`,
|
|
1173
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'desc'),
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
label: `By ${this.measures.length > 1 ? m + ' total' : 'total'} — lowest first${this.rowSort?.columnKey === this.columnRoot.key && this.rowSort?.measure === m && this.rowSort?.direction === 'asc' ? ' ✓' : ''}`,
|
|
1177
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'asc'),
|
|
1178
|
+
},
|
|
1179
|
+
]),
|
|
1180
|
+
...(this.rowSort ? [{ separator: true }, { label: 'Default order', action: () => this.clearRowSort() }] : []),
|
|
1181
|
+
],
|
|
1182
|
+
}] : [];
|
|
1183
|
+
const topNChoice = (count, direction, label) => {
|
|
1184
|
+
const m = this.measures[0];
|
|
1185
|
+
const active = this.topN?.count === count && this.topN?.direction === direction && this.topN?.measure === m;
|
|
1186
|
+
return {
|
|
1187
|
+
label: `${label}${active ? ' ✓' : ''}`,
|
|
1188
|
+
action: () => this.setTopN(count, { node: this.columnRoot, measure: m, direction }),
|
|
1189
|
+
};
|
|
1190
|
+
};
|
|
1191
|
+
const topNItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
1192
|
+
label: 'Top / Bottom N',
|
|
1193
|
+
items: [
|
|
1194
|
+
topNChoice(3, 'desc', 'Top 3'),
|
|
1195
|
+
topNChoice(5, 'desc', 'Top 5'),
|
|
1196
|
+
topNChoice(10, 'desc', 'Top 10'),
|
|
1197
|
+
{ separator: true },
|
|
1198
|
+
topNChoice(3, 'asc', 'Bottom 3'),
|
|
1199
|
+
topNChoice(5, 'asc', 'Bottom 5'),
|
|
1200
|
+
...(this.topN ? [{ separator: true }, { label: 'Show all rows', action: () => this.clearTopN() }] : []),
|
|
1201
|
+
],
|
|
1202
|
+
}] : [];
|
|
1203
|
+
const aggregationItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
1204
|
+
label: 'Summary (Count / Avg / Median)',
|
|
1205
|
+
items: PivotView.AGGREGATIONS.map(a => ({
|
|
1206
|
+
label: `${a.label}${this.aggregation === a.key ? ' ✓' : ''}`,
|
|
1207
|
+
action: () => this.setAggregation(a.key),
|
|
1208
|
+
})),
|
|
1209
|
+
}] : [];
|
|
1210
|
+
const valueModeChoice = (label, mode) => ({
|
|
1211
|
+
label: `${label}${this.valueMode === mode ? ' ✓' : ''}`,
|
|
1212
|
+
action: () => this.setValueMode(mode),
|
|
1213
|
+
});
|
|
1214
|
+
const valueModeItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
1215
|
+
label: 'Show values as',
|
|
1216
|
+
items: [
|
|
1217
|
+
valueModeChoice('Values', 'value'),
|
|
1218
|
+
...(this.columnAxis.length > 0 && this.columnRoot.hasCells ? [valueModeChoice('% of row total', 'rowPercent')] : []),
|
|
1219
|
+
...(this.rowAxis.length > 0 && this.rowRoot.hasCells ? [valueModeChoice('% of column total', 'columnPercent')] : []),
|
|
1220
|
+
...(this.rowRoot.hasCells && this.columnRoot.hasCells ? [valueModeChoice('% of grand total', 'totalPercent')] : []),
|
|
1221
|
+
],
|
|
1222
|
+
}] : [];
|
|
705
1223
|
return [
|
|
706
1224
|
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
707
1225
|
{ label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
|
|
1226
|
+
...sortItems,
|
|
1227
|
+
...topNItems,
|
|
1228
|
+
...measureItems,
|
|
1229
|
+
...aggregationItems,
|
|
1230
|
+
...valueModeItems,
|
|
708
1231
|
{
|
|
709
1232
|
label: 'Traffic lights',
|
|
710
1233
|
items: [
|