@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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. 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
- 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];
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.values()) {
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
- /** 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. */
262
460
  value(rowNode, column) {
263
461
  const row = this.cell(rowNode, column.node);
264
- 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)}%`;
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.value(node, c)]),
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 row axis's
505
- * finest level drills down (the host's, through onDrill). Says which it did, or null.
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 node = this.chartData().members.get(label);
509
- if (!node || node.path.length === 0) return null;
510
- if (this.isCollapsed('rows', node)) {
511
- this.toggle('rows', node);
512
- return 'expanded';
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
- if (node.path.length === this.rowAxis.length && this.onDrill) {
515
- this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: node.path });
516
- return 'drilled';
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
- return { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
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: [