@peter.prib/renderengine 0.12.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 -296
- package/dist/renderengine-workspace.umd.js +4122 -1167
- package/dist/renderengine.umd.js +4103 -1164
- package/documentation/REFERENCE.md +96 -6
- package/package.json +8 -3
- package/src/chartCatalog.js +50 -3
- package/src/charting/Axis.js +47 -5
- package/src/charting/Chart3D.js +4 -2
- package/src/charting/DiagramEditor.js +239 -34
- package/src/charting/Hierarchy.js +28 -0
- package/src/charting/ModelFileFormat.js +47 -0
- package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
- package/src/charting/archimate/deleteFromModel.js +57 -0
- package/src/charting/archimate/elementMenuItems.js +3 -1
- package/src/charting/archimate/heatMaps.js +169 -0
- package/src/charting/archimate/index.js +1 -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/BarChart.js +5 -5
- package/src/charting/charts/BarChart3D.js +2 -2
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +21 -3
- 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/GroupedBarChart.js +7 -9
- 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/TableChart.js +18 -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/diagrams/ArchimateModel.js +33 -0
- package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
- package/src/charting/diagrams/EdgeGeometry.js +16 -0
- package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
- package/src/charting/pivot/PivotChart.js +205 -13
- package/src/charting/pivot/PivotGridChart.js +1 -1
- package/src/charting/pivot/PivotView.js +555 -23
- package/src/charting/table/TableView.js +10 -1
- package/src/charting/table/TableViewChart.js +1 -1
- package/src/charting/templates/Template.js +10 -0
- package/src/exportImage.js +45 -18
- package/src/index.js +115 -6
- package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
- package/src/workspace/parts/DiagramEditorPart.js +2 -0
- package/src/workspace/parts/ModelHosted.js +15 -4
|
@@ -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]
|
|
@@ -53,6 +64,9 @@ export class PivotView {
|
|
|
53
64
|
* @param {Array<{measure?, below?, atLeast?, fill, label?}>} [options.trafficLights] the host's rules:
|
|
54
65
|
* the first whose measure (any, when absent) and range match colours a detail cell
|
|
55
66
|
* @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
|
|
67
|
+
* @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
|
|
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
|
|
56
70
|
*/
|
|
57
71
|
constructor(rows, options = {}) {
|
|
58
72
|
this.onChange = options.onChange ?? null;
|
|
@@ -63,10 +77,23 @@ export class PivotView {
|
|
|
63
77
|
this.members = options.members ?? null;
|
|
64
78
|
this.menus = options.menus ?? {};
|
|
65
79
|
this.copy = options.copy ?? PivotView.clipboard;
|
|
80
|
+
this.format = options.format ?? null;
|
|
66
81
|
this._listeners = new Set();
|
|
67
82
|
this.lightRules = options.trafficLights ?? null;
|
|
68
83
|
/** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
|
|
69
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';
|
|
70
97
|
this._load(rows, options);
|
|
71
98
|
}
|
|
72
99
|
|
|
@@ -83,6 +110,10 @@ export class PivotView {
|
|
|
83
110
|
_load(rows, options) {
|
|
84
111
|
this._options = options;
|
|
85
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
|
+
}
|
|
86
117
|
this.rowAxis = [...(options.rowAxis ?? [])];
|
|
87
118
|
this.columnAxis = [...(options.columnAxis ?? [])];
|
|
88
119
|
this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
|
|
@@ -91,9 +122,42 @@ export class PivotView {
|
|
|
91
122
|
const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
|
|
92
123
|
this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
|
|
93
124
|
this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
|
|
94
|
-
|
|
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];
|
|
95
136
|
this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
|
|
96
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 };
|
|
97
161
|
}
|
|
98
162
|
|
|
99
163
|
_defaultMeasures(rows, columns) {
|
|
@@ -125,6 +189,7 @@ export class PivotView {
|
|
|
125
189
|
|
|
126
190
|
_index() {
|
|
127
191
|
this.cells = new Map();
|
|
192
|
+
this._sampleCache = new Map();
|
|
128
193
|
this.unplaced = 0;
|
|
129
194
|
this.rowRoot = PivotView._node([], null);
|
|
130
195
|
this.columnRoot = PivotView._node([], null);
|
|
@@ -173,12 +238,35 @@ export class PivotView {
|
|
|
173
238
|
|
|
174
239
|
/**
|
|
175
240
|
* The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
|
|
176
|
-
* 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).
|
|
177
243
|
*/
|
|
178
244
|
visibleRows() {
|
|
179
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
|
+
};
|
|
180
268
|
const walk = (node) => {
|
|
181
|
-
for (const child of node.children
|
|
269
|
+
for (const child of order(node.children)) {
|
|
182
270
|
out.push({ node: child, depth: child.path.length - 1 });
|
|
183
271
|
if (!this.isCollapsed('rows', child)) walk(child);
|
|
184
272
|
}
|
|
@@ -188,6 +276,19 @@ export class PivotView {
|
|
|
188
276
|
return out;
|
|
189
277
|
}
|
|
190
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
|
+
|
|
191
292
|
/**
|
|
192
293
|
* The value columns drawn, left to right: each column-axis member's members (unless it is
|
|
193
294
|
* collapsed, or has none), then its own total; the grand total last. One per measure for each.
|
|
@@ -255,10 +356,140 @@ export class PivotView {
|
|
|
255
356
|
return out.map(({ id, ...cell }) => cell);
|
|
256
357
|
}
|
|
257
358
|
|
|
258
|
-
/**
|
|
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. */
|
|
259
460
|
value(rowNode, column) {
|
|
260
461
|
const row = this.cell(rowNode, column.node);
|
|
261
|
-
|
|
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)}%`;
|
|
262
493
|
}
|
|
263
494
|
|
|
264
495
|
// ── Changes ───────────────────────────────────────────────────────────────
|
|
@@ -280,14 +511,135 @@ export class PivotView {
|
|
|
280
511
|
this._changed();
|
|
281
512
|
}
|
|
282
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
|
+
|
|
283
546
|
/** Rows become columns and columns rows. */
|
|
284
547
|
swapAxes() {
|
|
285
548
|
[this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
|
|
286
549
|
this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
|
|
550
|
+
this.rowSort = null;
|
|
551
|
+
this.topN = null;
|
|
287
552
|
this._index();
|
|
288
553
|
this._changed();
|
|
289
554
|
}
|
|
290
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
|
+
|
|
291
643
|
_changed() {
|
|
292
644
|
this.onChange?.(this);
|
|
293
645
|
for (const listener of this._listeners) listener(this);
|
|
@@ -470,24 +822,85 @@ export class PivotView {
|
|
|
470
822
|
const names = columns.map(seriesName);
|
|
471
823
|
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
472
824
|
[category, rowLabels[r]],
|
|
473
|
-
...columns.map((c, i) => [names[i], this.
|
|
825
|
+
...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
|
|
474
826
|
]));
|
|
475
827
|
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
476
828
|
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
477
829
|
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
478
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;
|
|
479
836
|
return {
|
|
480
837
|
rows,
|
|
481
838
|
columns: [
|
|
482
839
|
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
483
840
|
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
484
841
|
],
|
|
485
|
-
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
486
|
-
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,
|
|
487
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(),
|
|
488
849
|
};
|
|
489
850
|
}
|
|
490
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
|
+
|
|
491
904
|
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
492
905
|
static _labels(nodes) {
|
|
493
906
|
const short = nodes.map(n => PivotView.label(n));
|
|
@@ -498,19 +911,33 @@ export class PivotView {
|
|
|
498
911
|
}
|
|
499
912
|
|
|
500
913
|
/**
|
|
501
|
-
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the
|
|
502
|
-
* 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.
|
|
503
917
|
*/
|
|
504
|
-
drillFrom(label) {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
if (
|
|
508
|
-
this.
|
|
509
|
-
|
|
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
|
+
}
|
|
510
930
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
+
}
|
|
514
941
|
}
|
|
515
942
|
return null;
|
|
516
943
|
}
|
|
@@ -542,12 +969,26 @@ export class PivotView {
|
|
|
542
969
|
}
|
|
543
970
|
}
|
|
544
971
|
items.push(...this._memberFilterItems(dimension, node));
|
|
972
|
+
if (axis === 'columns') items.push(...this._columnSortItems(node));
|
|
545
973
|
items.push({ separator: true },
|
|
546
974
|
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
547
975
|
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
548
976
|
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
549
977
|
}
|
|
550
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
|
+
|
|
551
992
|
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
552
993
|
|
|
553
994
|
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
@@ -608,18 +1049,24 @@ export class PivotView {
|
|
|
608
1049
|
return PivotView.LIGHTS[good];
|
|
609
1050
|
}
|
|
610
1051
|
|
|
1052
|
+
/** A measure's value as the grid shows it, in the measure's `format` when there is one. */
|
|
1053
|
+
valueText(measure, value, nullText = '—') {
|
|
1054
|
+
return TableChart.cellText(value, nullText, TableChart.formatFor(this.format, measure));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
611
1057
|
/** The traffic lights in words, for a line under the grid; null when off. */
|
|
612
1058
|
lightsLegend() {
|
|
613
1059
|
if (!this.lights) return null;
|
|
614
1060
|
const tail = ' (detail cells; totals not coloured)';
|
|
615
1061
|
if (this.lights === 'rules') {
|
|
616
1062
|
const said = this.lightRules.map(r => r.label ?? [
|
|
617
|
-
r.measure, r.atLeast !== undefined ? '≥ ' + r.
|
|
1063
|
+
r.measure, r.atLeast !== undefined ? '≥ ' + this.valueText(r.measure, r.atLeast) : null,
|
|
1064
|
+
r.below !== undefined ? '< ' + this.valueText(r.measure, r.below) : null,
|
|
618
1065
|
].filter(Boolean).join(' '));
|
|
619
1066
|
return 'Traffic lights: ' + said.join('; ') + tail;
|
|
620
1067
|
}
|
|
621
1068
|
const better = this.lights === 'thirds-high' ? 'higher' : 'lower';
|
|
622
|
-
const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' +
|
|
1069
|
+
const cuts = [...this.thirds()].map(([m, [a, b]]) => m + ' ' + this.valueText(m, a) + ' / ' + this.valueText(m, b)).join('; ');
|
|
623
1070
|
return 'Traffic lights by thirds, ' + better + ' is better — cut at ' + (cuts || 'no values') + tail;
|
|
624
1071
|
}
|
|
625
1072
|
|
|
@@ -630,7 +1077,13 @@ export class PivotView {
|
|
|
630
1077
|
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
631
1078
|
*/
|
|
632
1079
|
displayState() {
|
|
633
|
-
|
|
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;
|
|
634
1087
|
}
|
|
635
1088
|
|
|
636
1089
|
/**
|
|
@@ -641,6 +1094,16 @@ export class PivotView {
|
|
|
641
1094
|
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
642
1095
|
const lights = state.lights ?? null;
|
|
643
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';
|
|
644
1107
|
this._changed();
|
|
645
1108
|
}
|
|
646
1109
|
|
|
@@ -690,12 +1153,81 @@ export class PivotView {
|
|
|
690
1153
|
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
691
1154
|
}
|
|
692
1155
|
|
|
693
|
-
/** 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. */
|
|
694
1157
|
gridMenu() {
|
|
695
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
|
+
}] : [];
|
|
696
1223
|
return [
|
|
697
1224
|
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
698
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,
|
|
699
1231
|
{
|
|
700
1232
|
label: 'Traffic lights',
|
|
701
1233
|
items: [
|