@peter.prib/renderengine 0.13.0 → 0.15.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 +480 -302
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49217 -37484
- package/dist/renderengine.umd.js +24899 -13093
- package/documentation/REFERENCE.md +68 -1
- package/package.json +13 -3
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +269 -3
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +7 -2
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +142 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +69 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +6 -4
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +25 -3
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +107 -4
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +5 -2
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +6 -3
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +12 -5
- package/src/charting/charts/RadialBarChart.js +6 -3
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +129 -6
- package/src/charting/charts/ScatterChart.js +19 -3
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +125 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
- package/src/charting/charts/StreamGraphChart.js +18 -3
- package/src/charting/charts/SunburstChart.js +28 -8
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +31 -11
- package/src/charting/charts/ViolinChart.js +20 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +310 -13
- package/src/charting/pivot/PivotGridChart.js +322 -34
- package/src/charting/pivot/PivotView.js +626 -21
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2899
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -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);
|
|
@@ -397,6 +746,88 @@ export class PivotView {
|
|
|
397
746
|
return layout;
|
|
398
747
|
}
|
|
399
748
|
|
|
749
|
+
/**
|
|
750
|
+
* The layout keeping only the specified `values` for `dimension` (multi-selection slice & dice).
|
|
751
|
+
* @param {string} dimension
|
|
752
|
+
* @param {Array<any>|any} values
|
|
753
|
+
* @returns {object} layout
|
|
754
|
+
*/
|
|
755
|
+
withMemberKeep(dimension, values) {
|
|
756
|
+
const keep = Array.isArray(values) ? values : [values];
|
|
757
|
+
const layout = this.layout();
|
|
758
|
+
layout.members = [...layout.members.filter(m => m.dimension !== dimension), { dimension, keep }];
|
|
759
|
+
return layout;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The layout excluding the specified `values` for `dimension`.
|
|
764
|
+
* @param {string} dimension
|
|
765
|
+
* @param {Array<any>|any} values
|
|
766
|
+
* @returns {object} layout
|
|
767
|
+
*/
|
|
768
|
+
withMemberRemove(dimension, values) {
|
|
769
|
+
const removeList = Array.isArray(values) ? values : [values];
|
|
770
|
+
const layout = this.layout();
|
|
771
|
+
const current = layout.members.find(m => m.dimension === dimension);
|
|
772
|
+
const others = layout.members.filter(m => m.dimension !== dimension);
|
|
773
|
+
const combined = Array.from(new Set([...(current?.remove ?? []), ...removeList]));
|
|
774
|
+
layout.members = [...others, { dimension, remove: combined }];
|
|
775
|
+
return layout;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Returns unique member values for a given dimension across facts or rows.
|
|
780
|
+
* @param {string} dimension
|
|
781
|
+
* @returns {Array<any>}
|
|
782
|
+
*/
|
|
783
|
+
uniqueMembersOf(dimension) {
|
|
784
|
+
const pool = this.facts ?? this.rows;
|
|
785
|
+
const set = new Set();
|
|
786
|
+
pool.forEach(r => {
|
|
787
|
+
const val = r[dimension];
|
|
788
|
+
if (val != null) set.add(val);
|
|
789
|
+
});
|
|
790
|
+
return Array.from(set);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Opens a multi-member slice & dice filter dialog for `dimension`.
|
|
795
|
+
* @param {string} dimension
|
|
796
|
+
*/
|
|
797
|
+
showMemberFilterDialog(dimension) {
|
|
798
|
+
if (!this.onLayout || !dimension) return;
|
|
799
|
+
const all = this.uniqueMembersOf(dimension);
|
|
800
|
+
if (!all.length) return;
|
|
801
|
+
const currentFilter = this.memberFilterOf(dimension);
|
|
802
|
+
const selected = currentFilter
|
|
803
|
+
? (currentFilter.keep ?? all.filter(m => !currentFilter.remove?.includes(m)))
|
|
804
|
+
: all;
|
|
805
|
+
|
|
806
|
+
if (typeof document !== 'undefined') {
|
|
807
|
+
import('../FormDialog.js').then(({ FormDialog }) => {
|
|
808
|
+
new FormDialog({
|
|
809
|
+
title: `Filter ${dimension}`,
|
|
810
|
+
fields: [
|
|
811
|
+
{
|
|
812
|
+
key: 'members',
|
|
813
|
+
label: 'Members to include',
|
|
814
|
+
type: 'checklist',
|
|
815
|
+
choices: all.map(m => [m, TableChart.cellText(m)]),
|
|
816
|
+
value: selected,
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
onApply: ({ members }) => {
|
|
820
|
+
if (!members || members.length === all.length) {
|
|
821
|
+
this.onLayout(this.allMembers(dimension));
|
|
822
|
+
} else {
|
|
823
|
+
this.onLayout(this.withMemberKeep(dimension, members));
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
}).open();
|
|
827
|
+
}).catch(() => {});
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
400
831
|
/** A member's keep-or-remove items, the host's through onLayout; none for a NULL member (no filter holds one). */
|
|
401
832
|
_memberFilterItems(dimension, node) {
|
|
402
833
|
if (!this.onLayout || !dimension) return [];
|
|
@@ -473,22 +904,83 @@ export class PivotView {
|
|
|
473
904
|
const names = columns.map(seriesName);
|
|
474
905
|
const rows = rowNodes.map((node, r) => Object.fromEntries([
|
|
475
906
|
[category, rowLabels[r]],
|
|
476
|
-
...columns.map((c, i) => [names[i], this.
|
|
907
|
+
...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
|
|
477
908
|
]));
|
|
478
909
|
const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
|
|
479
910
|
const depths = new Set(rowNodes.map(n => n.path.length));
|
|
480
911
|
const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
|
|
481
912
|
const measureOf = (c) => hint(c.measure).measure ?? null;
|
|
913
|
+
const primaryMeasure = this.measures[0] ?? null;
|
|
914
|
+
const totalCol = this.columnRoot.hasCells && primaryMeasure ? { node: this.columnRoot, measure: primaryMeasure } : null;
|
|
915
|
+
const rowTotals = totalCol
|
|
916
|
+
? rowNodes.map((node, r) => ({ label: rowLabels[r], value: this.displayValue(node, totalCol) })).filter(d => d.value != null)
|
|
917
|
+
: null;
|
|
482
918
|
return {
|
|
483
919
|
rows,
|
|
484
920
|
columns: [
|
|
485
921
|
{ name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
|
|
486
922
|
...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
|
|
487
923
|
],
|
|
488
|
-
// Members not broken down further cover the whole answer between them, unless rows went unplaced.
|
|
489
|
-
complete: this.unplaced === 0,
|
|
924
|
+
// Members not broken down further cover the whole answer between them, unless rows went unplaced or Top-N filtered.
|
|
925
|
+
complete: this.unplaced === 0 && !this.topN,
|
|
490
926
|
members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
|
|
927
|
+
columnMembers: new Map(columns.map((c, i) => [names[i], c.node])),
|
|
928
|
+
rowTotals,
|
|
929
|
+
primaryMeasure: primaryMeasure ? { name: primaryMeasure, measure: measureOf({ measure: primaryMeasure }) } : null,
|
|
930
|
+
hierarchy: this.hierarchyData(),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The row-axis member tree shaped for hierarchical charts (`treemap`, `sunburst`, `outline`, `icicle`):
|
|
936
|
+
* `Array<{ label, value?, children? }>`, using the single column/measure showing (or the row totals
|
|
937
|
+
* column when multiple column members are showing) when the measure is additive and all values are
|
|
938
|
+
* non-negative, or null when hierarchical part-of-whole does not fit.
|
|
939
|
+
*/
|
|
940
|
+
hierarchyData() {
|
|
941
|
+
if (this.unplaced > 0 || this.topN || this.rowAxis.length < 1) return null;
|
|
942
|
+
const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
|
|
943
|
+
const col = columns.length === 1
|
|
944
|
+
? columns[0]
|
|
945
|
+
: (columns.length > 1 && this.columnRoot.hasCells && this.measures.length > 0
|
|
946
|
+
? { node: this.columnRoot, measure: this.measures[0] }
|
|
947
|
+
: null);
|
|
948
|
+
if (!col) return null;
|
|
949
|
+
const hint = (this._options.columns ?? []).find(c => c.name === col.measure) ?? {};
|
|
950
|
+
if (hint.measure !== 'additive') return null;
|
|
951
|
+
const allNodes = [];
|
|
952
|
+
const collect = (node) => {
|
|
953
|
+
for (const child of node.children.values()) {
|
|
954
|
+
allNodes.push(child);
|
|
955
|
+
if (!this.isCollapsed('rows', child)) collect(child);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
collect(this.rowRoot);
|
|
959
|
+
if (allNodes.length === 0) return null;
|
|
960
|
+
const labelMap = new Map(allNodes.map((n, i) => [n, PivotView._labels(allNodes)[i]]));
|
|
961
|
+
let hasDeep = false;
|
|
962
|
+
let negative = false;
|
|
963
|
+
const build = (parent) => {
|
|
964
|
+
const list = [];
|
|
965
|
+
for (const child of parent.children.values()) {
|
|
966
|
+
const label = labelMap.get(child) ?? PivotView.label(child);
|
|
967
|
+
if (child.children.size > 0 && !this.isCollapsed('rows', child)) {
|
|
968
|
+
const children = build(child);
|
|
969
|
+
if (children.length > 0) {
|
|
970
|
+
hasDeep = true;
|
|
971
|
+
list.push({ label, children });
|
|
972
|
+
}
|
|
973
|
+
} else {
|
|
974
|
+
const v = this.value(child, col);
|
|
975
|
+
if (typeof v === 'number' && v < 0) negative = true;
|
|
976
|
+
if (typeof v === 'number' && v > 0) list.push({ label, value: v });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return list;
|
|
491
980
|
};
|
|
981
|
+
const tree = build(this.rowRoot);
|
|
982
|
+
if (negative || tree.length === 0 || (!hasDeep && this.rowAxis.length < 2)) return null;
|
|
983
|
+
return { tree, members: new Map(allNodes.map(n => [labelMap.get(n) ?? PivotView.label(n), n])), measure: col.measure };
|
|
492
984
|
}
|
|
493
985
|
|
|
494
986
|
/** Each member's label; its path (` › `) only where two members' labels would read the same. */
|
|
@@ -501,19 +993,33 @@ export class PivotView {
|
|
|
501
993
|
}
|
|
502
994
|
|
|
503
995
|
/**
|
|
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).
|
|
996
|
+
* A click on a charted member (ChartBlox): a collapsed member expands; a member at the axis's
|
|
997
|
+
* finest level drills down (the host's, through onDrill). `axis` defaults to `'rows'`, or if a
|
|
998
|
+
* row label does not match and `series` is given, tries `'columns'`. Says which it did, or null.
|
|
506
999
|
*/
|
|
507
|
-
drillFrom(label) {
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
this.
|
|
512
|
-
|
|
1000
|
+
drillFrom(label, series = null) {
|
|
1001
|
+
const data = this.chartData();
|
|
1002
|
+
const rowNode = data.members.get(label) ?? data.hierarchy?.members.get(label);
|
|
1003
|
+
if (rowNode && rowNode.path.length > 0) {
|
|
1004
|
+
if (this.isCollapsed('rows', rowNode)) {
|
|
1005
|
+
this.toggle('rows', rowNode);
|
|
1006
|
+
return 'expanded';
|
|
1007
|
+
}
|
|
1008
|
+
if (rowNode.path.length === this.rowAxis.length && this.onDrill) {
|
|
1009
|
+
this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: rowNode.path });
|
|
1010
|
+
return 'drilled';
|
|
1011
|
+
}
|
|
513
1012
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1013
|
+
const colNode = series ? data.columnMembers.get(series) : data.columnMembers.get(label);
|
|
1014
|
+
if (colNode && colNode.path.length > 0) {
|
|
1015
|
+
if (this.isCollapsed('columns', colNode)) {
|
|
1016
|
+
this.toggle('columns', colNode);
|
|
1017
|
+
return 'expanded';
|
|
1018
|
+
}
|
|
1019
|
+
if (colNode.path.length === this.columnAxis.length && this.onDrill) {
|
|
1020
|
+
this.onDrill({ axis: 'columns', dimension: this.columnAxis.at(-1), direction: 'down', member: colNode.path });
|
|
1021
|
+
return 'drilled';
|
|
1022
|
+
}
|
|
517
1023
|
}
|
|
518
1024
|
return null;
|
|
519
1025
|
}
|
|
@@ -545,12 +1051,26 @@ export class PivotView {
|
|
|
545
1051
|
}
|
|
546
1052
|
}
|
|
547
1053
|
items.push(...this._memberFilterItems(dimension, node));
|
|
1054
|
+
if (axis === 'columns') items.push(...this._columnSortItems(node));
|
|
548
1055
|
items.push({ separator: true },
|
|
549
1056
|
{ label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
|
|
550
1057
|
{ label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
|
|
551
1058
|
return [...items, ...this._extra('member', { axis, node, dimension })];
|
|
552
1059
|
}
|
|
553
1060
|
|
|
1061
|
+
_columnSortItems(node) {
|
|
1062
|
+
if (this.rowAxis.length === 0 || !node.hasCells) return [];
|
|
1063
|
+
const measure = this.measures[0];
|
|
1064
|
+
const active = this.rowSort?.columnKey === node.key && this.rowSort?.measure === measure ? this.rowSort.direction : null;
|
|
1065
|
+
const label = PivotView.label(node);
|
|
1066
|
+
return [
|
|
1067
|
+
{ separator: true },
|
|
1068
|
+
{ label: `Sort rows by ${label} descending${active === 'desc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'desc') },
|
|
1069
|
+
{ label: `Sort rows by ${label} ascending${active === 'asc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'asc') },
|
|
1070
|
+
...(this.rowSort ? [{ label: 'Clear row sort', action: () => this.clearRowSort() }] : []),
|
|
1071
|
+
];
|
|
1072
|
+
}
|
|
1073
|
+
|
|
554
1074
|
// ── Traffic lights ─────────────────────────────────────────────────────────
|
|
555
1075
|
|
|
556
1076
|
static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
|
|
@@ -639,7 +1159,13 @@ export class PivotView {
|
|
|
639
1159
|
* collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
|
|
640
1160
|
*/
|
|
641
1161
|
displayState() {
|
|
642
|
-
|
|
1162
|
+
const state = { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
|
|
1163
|
+
if (this.rowSort) state.rowSort = { ...this.rowSort };
|
|
1164
|
+
if (this.topN) state.topN = { ...this.topN };
|
|
1165
|
+
if (this.allMeasures.length > 1 && this.measures.length < this.allMeasures.length) state.measures = [...this.measures];
|
|
1166
|
+
if (this.valueMode !== 'value') state.valueMode = this.valueMode;
|
|
1167
|
+
if (this.aggregation !== 'sum') state.aggregation = this.aggregation;
|
|
1168
|
+
return state;
|
|
643
1169
|
}
|
|
644
1170
|
|
|
645
1171
|
/**
|
|
@@ -650,6 +1176,16 @@ export class PivotView {
|
|
|
650
1176
|
this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
|
|
651
1177
|
const lights = state.lights ?? null;
|
|
652
1178
|
this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
|
|
1179
|
+
this.rowSort = state.rowSort && ['asc', 'desc'].includes(state.rowSort.direction) ? { ...state.rowSort } : null;
|
|
1180
|
+
this.topN = state.topN && state.topN.count > 0 && ['asc', 'desc'].includes(state.topN.direction) ? { ...state.topN } : null;
|
|
1181
|
+
if (Array.isArray(state.measures)) {
|
|
1182
|
+
const kept = this.allMeasures.filter(m => state.measures.includes(m));
|
|
1183
|
+
if (kept.length > 0) this.measures = kept;
|
|
1184
|
+
} else {
|
|
1185
|
+
this.measures = [...this.allMeasures];
|
|
1186
|
+
}
|
|
1187
|
+
this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(state.valueMode) ? state.valueMode : 'value';
|
|
1188
|
+
this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === state.aggregation) ? state.aggregation : 'sum';
|
|
653
1189
|
this._changed();
|
|
654
1190
|
}
|
|
655
1191
|
|
|
@@ -699,12 +1235,81 @@ export class PivotView {
|
|
|
699
1235
|
return items.length === 0 ? extra.slice(1) : [...items, ...extra];
|
|
700
1236
|
}
|
|
701
1237
|
|
|
702
|
-
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
|
|
1238
|
+
/** The grid's menu: swap the axes, expand or collapse everything, traffic lights, measures, sort, copy the grid. */
|
|
703
1239
|
gridMenu() {
|
|
704
1240
|
const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
|
|
1241
|
+
const measureItems = this.allMeasures.length > 1 ? [{
|
|
1242
|
+
label: 'Measures',
|
|
1243
|
+
items: this.allMeasures.map(m => ({
|
|
1244
|
+
label: `${m}${this.measures.includes(m) ? ' ✓' : ''}`,
|
|
1245
|
+
disabled: this.measures.length === 1 && this.measures[0] === m,
|
|
1246
|
+
action: () => this.toggleMeasure(m),
|
|
1247
|
+
})),
|
|
1248
|
+
}] : [];
|
|
1249
|
+
const sortItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
1250
|
+
label: 'Sort rows',
|
|
1251
|
+
items: [
|
|
1252
|
+
...this.measures.flatMap(m => [
|
|
1253
|
+
{
|
|
1254
|
+
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' ? ' ✓' : ''}`,
|
|
1255
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'desc'),
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
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' ? ' ✓' : ''}`,
|
|
1259
|
+
action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'asc'),
|
|
1260
|
+
},
|
|
1261
|
+
]),
|
|
1262
|
+
...(this.rowSort ? [{ separator: true }, { label: 'Default order', action: () => this.clearRowSort() }] : []),
|
|
1263
|
+
],
|
|
1264
|
+
}] : [];
|
|
1265
|
+
const topNChoice = (count, direction, label) => {
|
|
1266
|
+
const m = this.measures[0];
|
|
1267
|
+
const active = this.topN?.count === count && this.topN?.direction === direction && this.topN?.measure === m;
|
|
1268
|
+
return {
|
|
1269
|
+
label: `${label}${active ? ' ✓' : ''}`,
|
|
1270
|
+
action: () => this.setTopN(count, { node: this.columnRoot, measure: m, direction }),
|
|
1271
|
+
};
|
|
1272
|
+
};
|
|
1273
|
+
const topNItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
|
|
1274
|
+
label: 'Top / Bottom N',
|
|
1275
|
+
items: [
|
|
1276
|
+
topNChoice(3, 'desc', 'Top 3'),
|
|
1277
|
+
topNChoice(5, 'desc', 'Top 5'),
|
|
1278
|
+
topNChoice(10, 'desc', 'Top 10'),
|
|
1279
|
+
{ separator: true },
|
|
1280
|
+
topNChoice(3, 'asc', 'Bottom 3'),
|
|
1281
|
+
topNChoice(5, 'asc', 'Bottom 5'),
|
|
1282
|
+
...(this.topN ? [{ separator: true }, { label: 'Show all rows', action: () => this.clearTopN() }] : []),
|
|
1283
|
+
],
|
|
1284
|
+
}] : [];
|
|
1285
|
+
const aggregationItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
1286
|
+
label: 'Summary (Count / Avg / Median)',
|
|
1287
|
+
items: PivotView.AGGREGATIONS.map(a => ({
|
|
1288
|
+
label: `${a.label}${this.aggregation === a.key ? ' ✓' : ''}`,
|
|
1289
|
+
action: () => this.setAggregation(a.key),
|
|
1290
|
+
})),
|
|
1291
|
+
}] : [];
|
|
1292
|
+
const valueModeChoice = (label, mode) => ({
|
|
1293
|
+
label: `${label}${this.valueMode === mode ? ' ✓' : ''}`,
|
|
1294
|
+
action: () => this.setValueMode(mode),
|
|
1295
|
+
});
|
|
1296
|
+
const valueModeItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
|
|
1297
|
+
label: 'Show values as',
|
|
1298
|
+
items: [
|
|
1299
|
+
valueModeChoice('Values', 'value'),
|
|
1300
|
+
...(this.columnAxis.length > 0 && this.columnRoot.hasCells ? [valueModeChoice('% of row total', 'rowPercent')] : []),
|
|
1301
|
+
...(this.rowAxis.length > 0 && this.rowRoot.hasCells ? [valueModeChoice('% of column total', 'columnPercent')] : []),
|
|
1302
|
+
...(this.rowRoot.hasCells && this.columnRoot.hasCells ? [valueModeChoice('% of grand total', 'totalPercent')] : []),
|
|
1303
|
+
],
|
|
1304
|
+
}] : [];
|
|
705
1305
|
return [
|
|
706
1306
|
{ label: 'Swap rows and columns', action: () => this.swapAxes() },
|
|
707
1307
|
{ label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
|
|
1308
|
+
...sortItems,
|
|
1309
|
+
...topNItems,
|
|
1310
|
+
...measureItems,
|
|
1311
|
+
...aggregationItems,
|
|
1312
|
+
...valueModeItems,
|
|
708
1313
|
{
|
|
709
1314
|
label: 'Traffic lights',
|
|
710
1315
|
items: [
|