@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.
Files changed (67) hide show
  1. package/README.md +454 -296
  2. package/dist/renderengine-workspace.umd.js +4122 -1167
  3. package/dist/renderengine.umd.js +4103 -1164
  4. package/documentation/REFERENCE.md +96 -6
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Axis.js +47 -5
  8. package/src/charting/Chart3D.js +4 -2
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Hierarchy.js +28 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/ask/AskBar.js +388 -0
  18. package/src/charting/ask/AskView.js +400 -0
  19. package/src/charting/charts/AreaChart.js +20 -3
  20. package/src/charting/charts/BarChart.js +5 -5
  21. package/src/charting/charts/BarChart3D.js +2 -2
  22. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  23. package/src/charting/charts/BarChartBase.js +21 -3
  24. package/src/charting/charts/BoxPlotChart.js +2 -2
  25. package/src/charting/charts/BubbleChart.js +4 -1
  26. package/src/charting/charts/ChordDiagram.js +4 -4
  27. package/src/charting/charts/DensityChart.js +1 -1
  28. package/src/charting/charts/FunnelChart.js +1 -1
  29. package/src/charting/charts/GroupedBarChart.js +7 -9
  30. package/src/charting/charts/HeatmapChart.js +4 -1
  31. package/src/charting/charts/HistogramChart.js +2 -2
  32. package/src/charting/charts/IcicleChart.js +47 -0
  33. package/src/charting/charts/LollipopChart.js +104 -0
  34. package/src/charting/charts/MarimekkoChart.js +75 -0
  35. package/src/charting/charts/OutlineChart.js +10 -8
  36. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  37. package/src/charting/charts/ParetoChart.js +92 -0
  38. package/src/charting/charts/PipeChart.js +5 -2
  39. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  40. package/src/charting/charts/RadarChart.js +5 -4
  41. package/src/charting/charts/RadialBarChart.js +2 -2
  42. package/src/charting/charts/SankeyDiagram.js +4 -4
  43. package/src/charting/charts/ScatterChart.js +6 -3
  44. package/src/charting/charts/StackedBarChart.js +82 -11
  45. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  46. package/src/charting/charts/StreamGraphChart.js +8 -0
  47. package/src/charting/charts/SunburstChart.js +2 -2
  48. package/src/charting/charts/TableChart.js +18 -2
  49. package/src/charting/charts/TreemapChart.js +4 -4
  50. package/src/charting/charts/ViolinChart.js +2 -2
  51. package/src/charting/charts/WaterfallChart.js +2 -2
  52. package/src/charting/charts/WordCloudChart.js +2 -2
  53. package/src/charting/diagrams/ArchimateModel.js +33 -0
  54. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  55. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  56. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  57. package/src/charting/pivot/PivotChart.js +205 -13
  58. package/src/charting/pivot/PivotGridChart.js +1 -1
  59. package/src/charting/pivot/PivotView.js +555 -23
  60. package/src/charting/table/TableView.js +10 -1
  61. package/src/charting/table/TableViewChart.js +1 -1
  62. package/src/charting/templates/Template.js +10 -0
  63. package/src/exportImage.js +45 -18
  64. package/src/index.js +115 -6
  65. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  66. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  67. 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
- this.measures = options.measures ?? this._defaultMeasures(rows, options.columns);
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.values()) {
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
- /** The value at a row and column, from the row the data gave for that pair; null when it gave none. */
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
- return row === null ? null : row[column.measure] ?? null;
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.value(node, c)]),
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 row axis's
502
- * finest level drills down (the host's, through onDrill). Says which it did, or null.
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 node = this.chartData().members.get(label);
506
- if (!node || node.path.length === 0) return null;
507
- if (this.isCollapsed('rows', node)) {
508
- this.toggle('rows', node);
509
- return 'expanded';
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
- if (node.path.length === this.rowAxis.length && this.onDrill) {
512
- this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: node.path });
513
- return 'drilled';
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.atLeast : null, r.below !== undefined ? '< ' + r.below : null,
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 + ' ' + TableChart.cellText(a) + ' / ' + TableChart.cellText(b)).join('; ');
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
- return { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
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: [