@peter.prib/renderengine 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +454 -302
  2. package/dist/renderengine-workspace.umd.js +2203 -113
  3. package/dist/renderengine.umd.js +2203 -113
  4. package/documentation/REFERENCE.md +30 -1
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Chart3D.js +4 -2
  8. package/src/charting/Hierarchy.js +28 -0
  9. package/src/charting/ask/AskBar.js +388 -0
  10. package/src/charting/ask/AskView.js +400 -0
  11. package/src/charting/charts/AreaChart.js +20 -3
  12. package/src/charting/charts/BarChart3D.js +2 -2
  13. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  14. package/src/charting/charts/BoxPlotChart.js +2 -2
  15. package/src/charting/charts/BubbleChart.js +4 -1
  16. package/src/charting/charts/ChordDiagram.js +4 -4
  17. package/src/charting/charts/DensityChart.js +1 -1
  18. package/src/charting/charts/FunnelChart.js +1 -1
  19. package/src/charting/charts/HeatmapChart.js +4 -1
  20. package/src/charting/charts/HistogramChart.js +2 -2
  21. package/src/charting/charts/IcicleChart.js +47 -0
  22. package/src/charting/charts/LollipopChart.js +104 -0
  23. package/src/charting/charts/MarimekkoChart.js +75 -0
  24. package/src/charting/charts/OutlineChart.js +10 -8
  25. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  26. package/src/charting/charts/ParetoChart.js +92 -0
  27. package/src/charting/charts/PipeChart.js +5 -2
  28. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  29. package/src/charting/charts/RadarChart.js +5 -4
  30. package/src/charting/charts/RadialBarChart.js +2 -2
  31. package/src/charting/charts/SankeyDiagram.js +4 -4
  32. package/src/charting/charts/ScatterChart.js +6 -3
  33. package/src/charting/charts/StackedBarChart.js +82 -11
  34. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  35. package/src/charting/charts/StreamGraphChart.js +8 -0
  36. package/src/charting/charts/SunburstChart.js +2 -2
  37. package/src/charting/charts/TreemapChart.js +4 -4
  38. package/src/charting/charts/ViolinChart.js +2 -2
  39. package/src/charting/charts/WaterfallChart.js +2 -2
  40. package/src/charting/charts/WordCloudChart.js +2 -2
  41. package/src/charting/pivot/PivotChart.js +205 -13
  42. package/src/charting/pivot/PivotGridChart.js +1 -1
  43. package/src/charting/pivot/PivotView.js +544 -21
  44. package/src/index.js +98 -2
@@ -6788,12 +6788,15 @@
6788
6788
  },
6789
6789
  {
6790
6790
  key: 'stackedBar', label: 'Stacked Bar', method: 'stackedBarChart', category: '2d',
6791
- coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
6791
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
6792
+ seriesCapable: true,
6792
6793
  axes: [
6793
6794
  { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
6794
- { role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: true },
6795
+ { role: 'values', kind: AXIS_KIND.NUMERIC_LIST, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
6796
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_STACKED, required: false },
6797
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
6795
6798
  ],
6796
- dataShape: "Array<{label, values:number[]}> e.g. [{label:'Jan', values:[10,20]}] — no built-in legend option despite being multi-series (a real gap; areaChart's stacked mode has one, this doesn't)",
6799
+ dataShape: "Array<{label, values:number[]}> or multi-series Array<{name, values:Array<{label,value}>}> — segments stacked from zero, with optional series legend",
6797
6800
  streamShape: 'flat', // a new stacked bar is a whole new {label,values} record appended to the outer array, not growth of an existing record's values
6798
6801
  },
6799
6802
  {
@@ -7034,6 +7037,50 @@
7034
7037
  dataShape: 'Array<{label, x, y, z, size?, category?}> — real orbit camera (drag-rotate/wheel-zoom); a future sphere/ellipsoid variant is this same shape with a second shape-size-channeled role, not a new chart type',
7035
7038
  streamShape: 'flat',
7036
7039
  },
7040
+ {
7041
+ key: 'pareto', label: 'Pareto', method: 'paretoChart', category: '2d',
7042
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.SEMANTIC, camera: CAMERA_MODE.NONE,
7043
+ axes: [
7044
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
7045
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
7046
+ ],
7047
+ dataShape: 'Array<{label, value}> — sorted descending with a right-hand 0-100% cumulative percentage line and 80% cutoff reference line',
7048
+ streamShape: 'flat',
7049
+ },
7050
+ {
7051
+ key: 'lollipop', label: 'Lollipop', method: 'lollipopChart', category: '2d',
7052
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
7053
+ seriesCapable: true,
7054
+ axes: [
7055
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_X, required: true },
7056
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y, required: true },
7057
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: false },
7058
+ ],
7059
+ dataShape: 'Array<{label, value}> or multi-series Array<{name, values:Array<{label,value}>}> — stem-and-dot alternative to bars',
7060
+ streamShape: 'flat',
7061
+ },
7062
+ {
7063
+ key: 'marimekko', label: 'Marimekko', method: 'marimekkoChart', category: '2d',
7064
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: true, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
7065
+ seriesCapable: true,
7066
+ axes: [
7067
+ { role: 'category', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.SHAPE_WIDTH, required: true },
7068
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.POSITION_Y_HEIGHT, required: true },
7069
+ { role: 'series', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.COLOR, required: true },
7070
+ ],
7071
+ dataShape: 'Multi-series Array<{name, values:Array<{label,value}>}> or Array<{label, values:number[]}> — variable-width 100%-stacked mosaic columns',
7072
+ streamShape: 'series',
7073
+ },
7074
+ {
7075
+ key: 'icicle', label: 'Icicle', method: 'icicleChart', category: '2d',
7076
+ coordSystem: COORD_SYSTEM.CARTESIAN, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.NONE,
7077
+ axes: [
7078
+ { role: 'value', kind: AXIS_KIND.NUMERIC, channel: ENCODING_CHANNEL.ANGLE_OR_AREA, required: true },
7079
+ { role: 'group', kind: AXIS_KIND.CATEGORICAL, channel: ENCODING_CHANNEL.POSITION_ORDER, required: false },
7080
+ ],
7081
+ dataShape: 'Array<{label, value?, children?}> — rectangular hierarchical partition chart (top-down bands by depth, width proportional to value)',
7082
+ streamShape: 'flat',
7083
+ },
7037
7084
  {
7038
7085
  key: 'bar3DOrbit', label: 'Bar (3D Orbit)', method: 'barChart3DOrbit', category: '3d',
7039
7086
  coordSystem: COORD_SYSTEM.ORBIT_3D, title: true, legend: false, colorMode: COLOR_MODE.PALETTE, camera: CAMERA_MODE.ORBIT,
@@ -16373,8 +16420,10 @@
16373
16420
  const box = ChartBox.fromRenderer(this.renderer);
16374
16421
  const {
16375
16422
  x = box.x, y = box.y, width = box.width, height = box.height,
16376
- colors, title, xAxisLabel, yAxisLabel, stacked = false, legend: showLegend = false,
16423
+ colors, title, xAxisLabel, yAxisLabel, stacked = false,
16424
+ normalize = false, stackMode, legend: showLegend = false,
16377
16425
  } = options;
16426
+ const isPercent = Boolean(stacked && (normalize || stackMode === 'percent'));
16378
16427
  const plotBox = { x, y, width, height };
16379
16428
  const isMultiline = Array.isArray(data[0]?.values);
16380
16429
  const series = isMultiline ? data : [{ name: 'default', values: data }];
@@ -16383,6 +16432,10 @@
16383
16432
  const palette = new ColorPalette(colors);
16384
16433
  const textAnchor = this.renderer.centerTextAnchor;
16385
16434
 
16435
+ const pointTotals = isPercent
16436
+ ? Array.from({ length: pointCount }, (_, i) => series.reduce((sum, s) => sum + Math.max(0, s.values[i]?.value ?? 0), 0))
16437
+ : null;
16438
+
16386
16439
  // Precompute each series' [base, top] band per point — base is 0
16387
16440
  // unless stacked, in which case it's the running cumulative of prior
16388
16441
  // series (computed once here, not re-derived during render).
@@ -16390,12 +16443,15 @@
16390
16443
  const bands = series.map(s => {
16391
16444
  const band = s.values.map((d, i) => {
16392
16445
  const base = stacked ? running[i] : 0;
16393
- return { base, top: base + d.value };
16446
+ const val = isPercent
16447
+ ? (pointTotals[i] > 0 ? (Math.max(0, d.value) / pointTotals[i]) * 100 : 0)
16448
+ : d.value;
16449
+ return { base, top: base + val, pct: val };
16394
16450
  });
16395
16451
  if (stacked) running = band.map(b => b.top);
16396
16452
  return band;
16397
16453
  });
16398
- const maxValue = Math.max(...bands.flat().map(b => b.top)) || 1;
16454
+ const maxValue = isPercent ? 100 : (Math.max(...bands.flat().map(b => b.top)) || 1);
16399
16455
 
16400
16456
  new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
16401
16457
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
@@ -16410,6 +16466,14 @@
16410
16466
  const { r, g, b: bl } = palette.hexToRgb(color);
16411
16467
  this.renderer.path(areaPath, `rgba(${r},${g},${bl},0.35)`, null, { tooltip: s.name });
16412
16468
  this.renderer.path('M' + topPts.map(p => `${p.px},${p.py}`).join(' L'), 'none', { color, width: 2 });
16469
+ if (typeof this.renderer.circle === 'function') {
16470
+ topPts.forEach((p, i) => {
16471
+ const d = s.values[i];
16472
+ this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
16473
+ tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
16474
+ }), { label: d.label, series: isMultiline ? s.name : null });
16475
+ });
16476
+ }
16413
16477
 
16414
16478
  if (seriesIndex === 0) {
16415
16479
  const step = Axis.categoryLabelStep(s.values.map(d => d.label), width, { fontSize: 10 });
@@ -16574,8 +16638,11 @@
16574
16638
  this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
16575
16639
 
16576
16640
  pts.forEach(p => {
16577
- this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
16578
- { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` });
16641
+ if (typeof this.renderer.circle === 'function') {
16642
+ this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
16643
+ { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
16644
+ { label: p.d.label, series: isMultiline ? s.name : null });
16645
+ }
16579
16646
  });
16580
16647
 
16581
16648
  if (seriesIndex === 0) {
@@ -16594,35 +16661,104 @@
16594
16661
 
16595
16662
  /**
16596
16663
  * StackedBarChart — vertical stacked bar chart.
16664
+ *
16665
+ * Accepts either:
16666
+ * - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (the same
16667
+ * shape as `GroupedBarChart`, `LineChart` and `StackedAreaChart`), or
16668
+ * - Flat per-category stacks `Array<{label: string, values: number[]}>` with optional
16669
+ * `options.seriesLabels`.
16670
+ *
16671
+ * Draws a numeric Y axis (`_drawYAxis`), optional series legend (`legend: true`), and records each
16672
+ * segment with `this._datum()` so `onDatumClick` / `PivotChart` drilling works on every segment.
16597
16673
  */
16598
16674
  class StackedBarChart extends BarChartBase {
16599
16675
  static chartType = 'stackedBar';
16600
16676
 
16677
+ static _normalize(data, seriesLabels = null) {
16678
+ if (!Array.isArray(data) || data.length === 0) return { categories: [], seriesNames: [] };
16679
+ if (Array.isArray(data[0]?.values) && typeof data[0].values[0] === 'object' && data[0].values[0] !== null) {
16680
+ const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
16681
+ const maps = data.map(s => new Map(s.values.map(v => [v.label, v.value])));
16682
+ return {
16683
+ seriesNames: data.map((s, i) => s.name ?? seriesLabels?.[i] ?? `Series ${i + 1}`),
16684
+ categories: labels.map(label => ({ label, values: maps.map(m => m.get(label) ?? 0) })),
16685
+ };
16686
+ }
16687
+ const maxLen = Math.max(0, ...data.map(d => d.values?.length ?? 0));
16688
+ return {
16689
+ seriesNames: Array.from({ length: maxLen }, (_, i) => seriesLabels?.[i] ?? `Series ${i + 1}`),
16690
+ categories: data.map(d => ({ label: d.label, values: (d.values ?? []).map(v => v ?? 0) })),
16691
+ };
16692
+ }
16693
+
16694
+ _setup() {
16695
+ this._normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
16696
+ super._setup();
16697
+ if (this.options.legend && this.options.width === undefined && this._normalized.seriesNames.length > 0) {
16698
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
16699
+ }
16700
+ }
16701
+
16702
+ _legendOptions() {
16703
+ return {
16704
+ items: this._normalized.seriesNames.map((name, i) => ({ label: name, color: this._palette.colorAt(i), dataIndex: i })),
16705
+ itemHeight: 20, markerSize: 10,
16706
+ };
16707
+ }
16708
+
16709
+ _isNormalized() {
16710
+ return Boolean(this.options.normalize || this.options.stackMode === 'percent');
16711
+ }
16712
+
16601
16713
  _computeMaxValue() {
16602
- const data = this.data;
16603
- return Math.max(...data.map(d => d.values.reduce((sum, val) => sum + val, 0)));
16714
+ if (this._isNormalized()) {
16715
+ return this._drawYAxis(100);
16716
+ }
16717
+ const { categories } = this._normalized;
16718
+ const rawMax = Math.max(0, ...categories.map(d => d.values.reduce((sum, val) => sum + Math.max(0, val), 0))) || 1;
16719
+ return this._drawYAxis(rawMax);
16604
16720
  }
16605
16721
 
16606
16722
  _renderBars(maxStackValue) {
16607
- const data = this.data;
16723
+ const { categories, seriesNames } = this._normalized;
16724
+ if (categories.length === 0) return;
16608
16725
  const { x, y, width, height } = this._plotBox;
16609
- const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width);
16610
- const { bandWidth, margin } = this._bandScale(data.length);
16726
+ const normalize = this._isNormalized();
16727
+ const labels = categories.map(d => d.label);
16728
+ const { bandWidth, margin } = this._bandScale(categories.length);
16611
16729
  const textAnchor = this.renderer.centerTextAnchor;
16730
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
16731
+ const room = (bandWidth + margin) - 4;
16732
+ const cut = room >= measure('x'.repeat(6));
16733
+ const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
16612
16734
 
16613
- data.forEach((d, i) => {
16735
+ categories.forEach((d, i) => {
16614
16736
  let currentY = y + height;
16615
16737
  const barX = x + i * (bandWidth + margin);
16738
+ const colTotal = d.values.reduce((sum, val) => sum + Math.max(0, val ?? 0), 0);
16616
16739
  d.values.forEach((value, j) => {
16617
- const barHeight = (value / maxStackValue) * height;
16740
+ if (value == null || value <= 0) return;
16741
+ const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
16742
+ const barHeight = (plotted / maxStackValue) * height;
16618
16743
  currentY -= barHeight;
16619
- this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
16620
- tooltip: `${d.label} - Series ${j + 1}: ${value}`,
16621
- });
16744
+ const series = seriesNames[j] ?? `Series ${j + 1}`;
16745
+ const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
16746
+ this._datum(this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
16747
+ tooltip: `${d.label} - ${series}: ${tipVal}`,
16748
+ }), { label: d.label, series });
16622
16749
  });
16623
- if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
16750
+ if (i % labelStep === 0) {
16751
+ this.renderer.text(cut ? TableChart.fit(String(d.label), room, measure) : d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
16752
+ }
16624
16753
  });
16625
16754
  }
16755
+
16756
+ _afterBars() {
16757
+ const { x, y, width } = this._plotBox;
16758
+ if (this.options.legend && this._normalized.seriesNames.length > 0) {
16759
+ Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
16760
+ }
16761
+ }
16626
16762
  }
16627
16763
 
16628
16764
  // Copyright 2026 Jaroslav Peter Prib, Purveyor of Excellence
@@ -16958,8 +17094,8 @@
16958
17094
  ({ from, to } = referenceLine);
16959
17095
  }
16960
17096
  if (from && to) {
16961
- const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1 });
16962
- line.setAttribute('stroke-dasharray', '4 3');
17097
+ const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1, dashArray: [4, 3] });
17098
+ line?.setAttribute?.('stroke-dasharray', '4 3');
16963
17099
  }
16964
17100
  }
16965
17101
 
@@ -16967,7 +17103,10 @@
16967
17103
  const cx = toX(d.x);
16968
17104
  const cy = toY(d.y);
16969
17105
  const tooltip = d.label ? `${d.label}: (${d.x}, ${d.y})` : `(${d.x}, ${d.y})`;
16970
- this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip });
17106
+ const series = catField ? ChartData.readColorCol(d, catField) : null;
17107
+ this._datum(this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip }), {
17108
+ label: d.label ?? String(d.x), series: series != null ? String(series) : null,
17109
+ });
16971
17110
 
16972
17111
  const xLow = d.errorXMinus != null ? toX(d.x - d.errorXMinus)
16973
17112
  : d.errorX != null ? toX(d.x - d.errorX) : null;
@@ -17070,7 +17209,10 @@
17070
17209
  // 3D effect: draw a line to the "floor"
17071
17210
  this.renderer.path(`M${floorX},${floorY} L${bubbleX},${bubbleY}`, 'none', { color: '#e0e0e0', width: 1 });
17072
17211
  }
17073
- this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` });
17212
+ const series = catField ? ChartData.readColorCol(d, catField) : null;
17213
+ this._datum(this.renderer.circle(bubbleX, bubbleY, radius, color, { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip: `${d.label}: (${d.x}, ${d.y}, ${d.z})` }), {
17214
+ label: d.label ?? String(d.x), series: series != null ? String(series) : null,
17215
+ });
17074
17216
  });
17075
17217
 
17076
17218
  // Category color legend
@@ -17249,9 +17391,9 @@
17249
17391
  const barY = y + height - barHeight;
17250
17392
  // No gap between bars (unlike barChart's categorical gaps) — bins
17251
17393
  // are contiguous numeric ranges, not independent categories.
17252
- this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
17394
+ this._datum(this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
17253
17395
  tooltip: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}: ${bin.count}`,
17254
- });
17396
+ }), { label: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}` });
17255
17397
  if (i % Math.ceil(bins.length / 10 || 1) === 0) {
17256
17398
  this.renderer.text(Axis.formatValue(bin.from), barX, y + height + 16, { textAnchor: 'start', fontSize: '9px' });
17257
17399
  }
@@ -17353,9 +17495,9 @@
17353
17495
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.min)} L${cx + boxWidth / 4},${this._toY(s.min)}`, 'none', { color: '#555', width: 1 });
17354
17496
  this.renderer.path(`M${cx - boxWidth / 4},${this._toY(s.max)} L${cx + boxWidth / 4},${this._toY(s.max)}`, 'none', { color: '#555', width: 1 });
17355
17497
  const boxTop = this._toY(s.q3), boxBottom = this._toY(s.q1);
17356
- this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
17498
+ this._datum(this.renderer.rect(cx - boxWidth / 2, boxTop, boxWidth, Math.max(1, boxBottom - boxTop), boxColor, { color: '#333', width: 1 }, {
17357
17499
  tooltip: `${d.label}: median ${Axis.formatValue(s.median)}, Q1 ${Axis.formatValue(s.q1)}, Q3 ${Axis.formatValue(s.q3)}`,
17358
- });
17500
+ }), { label: d.label, series: d.label });
17359
17501
  this.renderer.path(`M${cx - boxWidth / 2},${this._toY(s.median)} L${cx + boxWidth / 2},${this._toY(s.median)}`, 'none', { color: '#fff', width: 2 });
17360
17502
  s.outliers.forEach(v => {
17361
17503
  this.renderer.circle(cx, this._toY(v), 3, 'none', { color: boxColor, width: 1.5 }, { tooltip: `${d.label} outlier: ${Axis.formatValue(v)}` });
@@ -17417,7 +17559,7 @@
17417
17559
  const topPath = 'M' + topPts.map(p => `${p.px},${p.py}`).join(' L');
17418
17560
  const areaPath = topPath + ` L${x + width},${y + height} L${x},${y + height} Z`;
17419
17561
  const { r, g, b } = ColorPalette.hexToRgb(color);
17420
- this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label });
17562
+ this._datum(this.renderer.path(areaPath, `rgba(${r},${g},${b},0.25)`, null, { tooltip: d.label }), { label: d.label, series: d.label });
17421
17563
  this.renderer.path(topPath, 'none', { color, width: 2 });
17422
17564
  });
17423
17565
 
@@ -17455,9 +17597,9 @@
17455
17597
  const leftPts = curve.map(p => ({ px: cx - (p.density / maxDensity) * halfWidth, py: this._toY(p.x) }));
17456
17598
  const path = 'M' + rightPts.map(p => `${p.px},${p.py}`).join(' L')
17457
17599
  + ' L' + [...leftPts].reverse().map(p => `${p.px},${p.py}`).join(' L') + ' Z';
17458
- this.renderer.path(path, color, { color: '#333', width: 1 }, {
17600
+ this._datum(this.renderer.path(path, color, { color: '#333', width: 1 }, {
17459
17601
  tooltip: `${d.label}: n=${d.values.length}`,
17460
- });
17602
+ }), { label: d.label, series: d.label });
17461
17603
 
17462
17604
  const median = Statistics.quantile([...d.values].sort((a, b) => a - b), 0.5);
17463
17605
  this.renderer.path(`M${cx - halfWidth},${this._toY(median)} L${cx + halfWidth},${this._toY(median)}`, 'none', { color: '#fff', width: 2 });
@@ -17712,6 +17854,34 @@
17712
17854
  return result;
17713
17855
  }
17714
17856
 
17857
+ /**
17858
+ * Recursive rectangular partition (icicle / flame graph) layout over a node tree.
17859
+ * Partitions `width` proportionally to node value at each level and steps down by `bandHeight` per depth.
17860
+ * @returns {Array<{node: object, x: number, y: number, width: number, height: number, depth: number, isLeaf: boolean, topAncestor: object}>}
17861
+ */
17862
+ static icicleLayout(nodes, x, y, width, bandHeight, depth = 0, topAncestors = null) {
17863
+ const items = (nodes ?? [])
17864
+ .map(node => ({ node, value: Hierarchy.value(node) }))
17865
+ .filter(it => it.value > 0);
17866
+ const total = items.reduce((s, it) => s + it.value, 0) || 1;
17867
+
17868
+ const result = [];
17869
+ let cursorX = x;
17870
+ for (const it of items) {
17871
+ const { node } = it;
17872
+ const spanW = (it.value / total) * width;
17873
+ const isLeaf = !node.children?.length;
17874
+ const topAncestor = topAncestors ? topAncestors.get(node) : node;
17875
+ result.push({ node, x: cursorX, y, width: spanW, height: bandHeight, depth, isLeaf, topAncestor });
17876
+ if (!isLeaf) {
17877
+ const childTopAncestors = new Map(node.children.map(c => [c, topAncestor]));
17878
+ result.push(...Hierarchy.icicleLayout(node.children, cursorX, y + bandHeight, spanW, bandHeight, depth + 1, childTopAncestors));
17879
+ }
17880
+ cursorX += spanW;
17881
+ }
17882
+ return result;
17883
+ }
17884
+
17715
17885
  /**
17716
17886
  * Flat, pre-order list of *visible* rows for an outline view. A branch
17717
17887
  * present in `collapsed` contributes its own row but none of its
@@ -17801,9 +17971,9 @@
17801
17971
  const textColor = this._textColorForNode(topAncestor, depth);
17802
17972
 
17803
17973
  if (isLeaf) {
17804
- this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
17974
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
17805
17975
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
17806
- });
17976
+ }), { label: node.label });
17807
17977
  if (rw > 30 && rh > 14) {
17808
17978
  this.renderer.text(node.label, rx + rw / 2, ry + rh / 2, {
17809
17979
  textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
@@ -17812,9 +17982,9 @@
17812
17982
  } else {
17813
17983
  const headerH = Math.min(rh, TreemapChart.HEADER_H);
17814
17984
  this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), 'rgba(0,0,0,0.03)', { color: '#ddd', width: 1 });
17815
- this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
17985
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
17816
17986
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
17817
- });
17987
+ }), { label: node.label });
17818
17988
  if (rw > 30) {
17819
17989
  this.renderer.text(node.label, rx + 4, ry + headerH / 2, {
17820
17990
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: textColor, 'pointer-events': 'none',
@@ -17862,9 +18032,9 @@
17862
18032
  const textColor = this._textColorForNode(topAncestor, depth);
17863
18033
 
17864
18034
  const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
17865
- this.renderer.path(path, color, { color: '#fff', width: 1 }, {
18035
+ this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
17866
18036
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
17867
- });
18037
+ }), { label: node.label });
17868
18038
 
17869
18039
  if (endAngle - startAngle > 0.35) {
17870
18040
  const midAngle = (startAngle + endAngle) / 2;
@@ -17914,15 +18084,16 @@
17914
18084
  const accentColor = this._colorForNode(topAncestor, depth);
17915
18085
 
17916
18086
  const g = this.renderer.group();
17917
- g.appendChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
18087
+ const addChild = (child) => g?.appendChild?.(child);
18088
+ addChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
17918
18089
 
17919
18090
  let labelX = indentX + 16;
17920
18091
  if (hasChildren) {
17921
18092
  const collapsedHere = this.collapsed ? this.collapsed.has(node) : false;
17922
18093
  const badgeX = indentX + 26;
17923
18094
  const badge = this.renderer.circle(badgeX, rowCenterY, 7, '#ffffff', { color: '#888', width: 1.2 });
17924
- g.appendChild(badge);
17925
- g.appendChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
18095
+ addChild(badge);
18096
+ addChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
17926
18097
  textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#555', 'pointer-events': 'none',
17927
18098
  }));
17928
18099
  badge.style.cursor = 'pointer';
@@ -17936,22 +18107,23 @@
17936
18107
  labelX = badgeX + 16;
17937
18108
  }
17938
18109
 
17939
- g.appendChild(this.renderer.text(node.label, labelX, rowCenterY, {
18110
+ const labelEl = this._datum(this.renderer.text(node.label, labelX, rowCenterY, {
17940
18111
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#222',
17941
- }));
18112
+ }), { label: node.label, series: null });
18113
+ addChild(labelEl);
17942
18114
 
17943
18115
  if (hasChildren) {
17944
- g.appendChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
18116
+ addChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
17945
18117
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
17946
18118
  }));
17947
18119
  } else if (Number.isFinite(node.value)) {
17948
- g.appendChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
18120
+ addChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
17949
18121
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
17950
18122
  }));
17951
18123
  } else {
17952
18124
  const extra = Hierarchy.outlineExtraText(node, structuralKeys);
17953
18125
  if (extra) {
17954
- g.appendChild(this.renderer.text(extra, detailX, rowCenterY, {
18126
+ addChild(this.renderer.text(extra, detailX, rowCenterY, {
17955
18127
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
17956
18128
  }));
17957
18129
  }
@@ -17973,6 +18145,308 @@
17973
18145
  OutlineChart.INDENT_W = 18;
17974
18146
  OutlineChart.HIERARCHY_STRUCTURAL_KEYS = new Set(['label', 'value', 'children']);
17975
18147
 
18148
+ /**
18149
+ * IcicleChart — rectangular hierarchical partition chart (top-down flame/icicle layout).
18150
+ * Each depth level forms a horizontal band and each node's width is proportional to its value
18151
+ * within its parent's span.
18152
+ */
18153
+ class IcicleChart extends HierarchyChart {
18154
+ static chartType = 'icicle';
18155
+
18156
+ _render() {
18157
+ const data = this.data ?? [];
18158
+ const options = this.options;
18159
+ const box = ChartBox.fromRenderer(this.renderer);
18160
+ const { x = box.x, y = box.y, width = box.width, height = box.height, title } = options;
18161
+ const plotBox = { x, y, width, height };
18162
+
18163
+ new ChartLabels({ title }).draw(this.renderer, plotBox);
18164
+ if (data.length === 0) return;
18165
+
18166
+ const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
18167
+ const bandHeight = height / maxDepth;
18168
+ const layout = Hierarchy.icicleLayout(data, x, y, width, bandHeight);
18169
+ const textAnchor = this.renderer.centerTextAnchor;
18170
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '10px' }) ?? s.length * 10 * 0.55;
18171
+
18172
+ layout.forEach(entry => {
18173
+ const { node, x: rx, y: ry, width: rw, height: rh, depth, topAncestor } = entry;
18174
+ const color = this._colorForNode(topAncestor, depth);
18175
+ const textColor = this._textColorForNode(topAncestor, depth);
18176
+
18177
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
18178
+ tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
18179
+ }), { label: node.label });
18180
+
18181
+ if (rw > 28 && rh > 14) {
18182
+ const fitted = TableChart.fit(String(node.label), rw - 6, measure);
18183
+ this.renderer.text(fitted, rx + rw / 2, ry + rh / 2, {
18184
+ textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
18185
+ });
18186
+ }
18187
+ });
18188
+ }
18189
+ }
18190
+
18191
+ /**
18192
+ * ParetoChart — descending bar chart combined with a cumulative percentage line (0–100%)
18193
+ * on a right-hand axis and an 80% cutoff reference line.
18194
+ */
18195
+ class ParetoChart extends BarChartBase {
18196
+ static chartType = 'pareto';
18197
+
18198
+ _setup() {
18199
+ super._setup();
18200
+ if (this.options.width === undefined) {
18201
+ this._plotBox.width = Math.max(80, this._plotBox.width - 36);
18202
+ }
18203
+ const items = (this.data ?? []).filter(d => d && typeof d.value === 'number' && d.value >= 0);
18204
+ this._sorted = this.options.sorted === false ? [...items] : [...items].sort((a, b) => b.value - a.value);
18205
+ }
18206
+
18207
+ _computeMaxValue() {
18208
+ const rawMax = Math.max(0, ...this._sorted.map(d => d.value)) || 1;
18209
+ return this._drawYAxis(rawMax, 0);
18210
+ }
18211
+
18212
+ _renderBars(maxValue) {
18213
+ const items = this._sorted;
18214
+ if (items.length === 0) return;
18215
+ const { x, y, width, height } = this._plotBox;
18216
+ const {
18217
+ barColor = '#3b82f6',
18218
+ lineColor = '#dc2626',
18219
+ cutoff = 80,
18220
+ colors,
18221
+ } = this.options;
18222
+ const { bandWidth, margin } = this._bandScale(items.length);
18223
+ const labelStep = Axis.categoryLabelStep(items.map(d => d.label), width);
18224
+ const textAnchor = this.renderer.centerTextAnchor;
18225
+ const total = items.reduce((sum, d) => sum + d.value, 0) || 1;
18226
+
18227
+ // Right-hand percentage axis (0%..100%).
18228
+ const rightX = x + width;
18229
+ this.renderer.path(`M${rightX},${y} L${rightX},${y + height}`, 'none', { color: '#64748b', width: 1 });
18230
+ for (const pct of [0, 20, 40, 60, 80, 100]) {
18231
+ const py = y + height - (pct / 100) * height;
18232
+ this.renderer.path(`M${rightX},${py} L${rightX + 4},${py}`, 'none', { color: '#64748b', width: 1 });
18233
+ this.renderer.text(`${pct}%`, rightX + 8, py + 3, { textAnchor: 'start', fontSize: '10px', fill: '#475569' });
18234
+ }
18235
+
18236
+ // Dashed 80% cutoff reference line.
18237
+ if (cutoff > 0 && cutoff < 100) {
18238
+ const cutY = y + height - (cutoff / 100) * height;
18239
+ this.renderer.path(`M${x},${cutY} L${rightX},${cutY}`, 'none', {
18240
+ color: lineColor, width: 1, dashArray: '4,4',
18241
+ });
18242
+ }
18243
+
18244
+ let running = 0;
18245
+ const linePts = [];
18246
+ items.forEach((d, i) => {
18247
+ running += d.value;
18248
+ const cumPct = (running / total) * 100;
18249
+ const color = colors ? this._palette.colorAt(i) : barColor;
18250
+ const bar = this._barSpan(d.value);
18251
+ const barX = x + i * (bandWidth + margin);
18252
+ const cx = barX + bandWidth / 2;
18253
+ const py = y + height - (cumPct / 100) * height;
18254
+ linePts.push({ cx, py, label: d.label, value: d.value, cumPct });
18255
+
18256
+ this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, {
18257
+ bar: true,
18258
+ dataIndex: i,
18259
+ tooltip: `${d.label}: ${d.value} (${cumPct.toFixed(1)}% cumulative)`,
18260
+ }), { label: d.label });
18261
+
18262
+ if (i % labelStep === 0) {
18263
+ this.renderer.text(d.label, cx, y + height + 20, { textAnchor });
18264
+ }
18265
+ });
18266
+
18267
+ if (linePts.length > 0) {
18268
+ const dPath = 'M' + linePts.map(p => `${p.cx},${p.py}`).join(' L');
18269
+ this.renderer.path(dPath, 'none', { color: lineColor, width: 2 });
18270
+ if (typeof this.renderer.circle === 'function') {
18271
+ linePts.forEach(p => {
18272
+ this._datum(this.renderer.circle(p.cx, p.py, 3.5, lineColor, { color: '#fff', width: 1.5 }, {
18273
+ tooltip: `${p.label}: ${p.cumPct.toFixed(1)}% cumulative`,
18274
+ }), { label: p.label });
18275
+ });
18276
+ }
18277
+ }
18278
+ }
18279
+ }
18280
+
18281
+ /**
18282
+ * LollipopChart — stem-and-dot alternative to a bar chart for high-cardinality or multi-series
18283
+ * categorical comparisons with minimal ink density.
18284
+ *
18285
+ * Accepts either:
18286
+ * - 1D `Array<{label: string, value: number}>`, or
18287
+ * - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>`.
18288
+ */
18289
+ class LollipopChart extends BarChartBase {
18290
+ static chartType = 'lollipop';
18291
+
18292
+ static _normalize(data) {
18293
+ if (!Array.isArray(data) || data.length === 0) return { isMulti: false, series: [], labels: [] };
18294
+ if (Array.isArray(data[0]?.values)) {
18295
+ const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
18296
+ return { isMulti: true, series: data, labels };
18297
+ }
18298
+ return {
18299
+ isMulti: false,
18300
+ series: [{ name: 'default', values: data }],
18301
+ labels: data.map(d => d.label),
18302
+ };
18303
+ }
18304
+
18305
+ _setup() {
18306
+ this._norm = LollipopChart._normalize(this.data);
18307
+ super._setup();
18308
+ const showLegend = this.options.legend ?? this._norm.isMulti;
18309
+ if (showLegend && this._norm.isMulti && this.options.width === undefined) {
18310
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
18311
+ }
18312
+ }
18313
+
18314
+ _legendOptions() {
18315
+ return {
18316
+ items: this._norm.series.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
18317
+ itemHeight: 20, markerSize: 10,
18318
+ };
18319
+ }
18320
+
18321
+ _computeMaxValue() {
18322
+ const allValues = this._norm.series.flatMap(s => s.values.map(v => v.value)).filter(v => typeof v === 'number');
18323
+ const rawMax = Math.max(0, ...allValues) || 1;
18324
+ const rawMin = Math.min(0, ...allValues);
18325
+ return this._drawYAxis(rawMax, rawMin);
18326
+ }
18327
+
18328
+ _renderBars() {
18329
+ const { isMulti, series, labels } = this._norm;
18330
+ if (labels.length === 0) return;
18331
+ const { x, y, width, height } = this._plotBox;
18332
+ const {
18333
+ color: singleColor = '#2563eb',
18334
+ colors,
18335
+ radius = 5,
18336
+ stemWidth = 2,
18337
+ } = this.options;
18338
+ const { bandWidth, margin } = this._bandScale(labels.length);
18339
+ const labelStep = Axis.categoryLabelStep(labels, width);
18340
+ const textAnchor = this.renderer.centerTextAnchor;
18341
+ const zeroY = this._barSpan(0).end;
18342
+ const seriesCount = series.length;
18343
+
18344
+ labels.forEach((label, i) => {
18345
+ const bandX = x + i * (bandWidth + margin);
18346
+ series.forEach((s, sIdx) => {
18347
+ const pt = s.values.find(v => v.label === label);
18348
+ if (!pt || typeof pt.value !== 'number') return;
18349
+ const cx = seriesCount === 1
18350
+ ? bandX + bandWidth / 2
18351
+ : bandX + ((sIdx + 0.5) / seriesCount) * bandWidth;
18352
+ const span = this._barSpan(pt.value);
18353
+ const col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
18354
+ const tip = isMulti ? `${s.name} - ${label}: ${pt.value}` : `${label}: ${pt.value}`;
18355
+
18356
+ this.renderer.path(`M${cx},${zeroY} L${cx},${span.end}`, 'none', { color: col, width: stemWidth });
18357
+ if (typeof this.renderer.circle === 'function') {
18358
+ this._datum(this.renderer.circle(cx, span.end, radius, col, { color: '#fff', width: 1.5 }, {
18359
+ tooltip: tip,
18360
+ }), { label, series: isMulti ? s.name : null });
18361
+ } else {
18362
+ this._datum(this.renderer.rect(cx - radius, span.end - radius, radius * 2, radius * 2, col, null, {
18363
+ tooltip: tip,
18364
+ }), { label, series: isMulti ? s.name : null });
18365
+ }
18366
+ });
18367
+ if (i % labelStep === 0) {
18368
+ this.renderer.text(label, bandX + bandWidth / 2, y + height + 20, { textAnchor });
18369
+ }
18370
+ });
18371
+ }
18372
+
18373
+ _afterBars() {
18374
+ const showLegend = this.options.legend ?? this._norm.isMulti;
18375
+ if (showLegend && this._norm.isMulti) {
18376
+ const { x, y, width } = this._plotBox;
18377
+ Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
18378
+ }
18379
+ }
18380
+ }
18381
+
18382
+ /**
18383
+ * MarimekkoChart — two-dimensional 100%-stacked mosaic chart where column width is proportional to
18384
+ * each category's total across series and segment height is each series' percentage share within
18385
+ * that category.
18386
+ */
18387
+ class MarimekkoChart extends Chart {
18388
+ static chartType = 'marimekko';
18389
+
18390
+ _render() {
18391
+ const normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
18392
+ const { categories, seriesNames } = normalized;
18393
+ const box = ChartBox.fromRenderer(this.renderer);
18394
+ const {
18395
+ x = box.x, y = box.y, width: rawWidth = box.width, height = box.height,
18396
+ colors, title, xAxisLabel, yAxisLabel, legend: showLegend = true,
18397
+ } = this.options;
18398
+ const palette = new ColorPalette(colors);
18399
+ const legendOpts = {
18400
+ items: seriesNames.map((name, i) => ({ label: name, color: palette.colorAt(i), dataIndex: i })),
18401
+ itemHeight: 20, markerSize: 10,
18402
+ };
18403
+ const width = showLegend && this.options.width === undefined && seriesNames.length > 0
18404
+ ? Legend.plotWidthBeside({ x, y, width: rawWidth, height }, this.renderer.width, legendOpts)
18405
+ : rawWidth;
18406
+ const plotBox = { x, y, width, height };
18407
+
18408
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
18409
+ new Axis().drawYRange(this.renderer, plotBox, 0, 100);
18410
+
18411
+ const colTotals = categories.map(c => c.values.reduce((sum, v) => sum + Math.max(0, v ?? 0), 0));
18412
+ const grandTotal = colTotals.reduce((sum, v) => sum + v, 0) || 1;
18413
+ const textAnchor = this.renderer.centerTextAnchor;
18414
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '11px' }) ?? s.length * 11 * 0.55;
18415
+
18416
+ let cursorX = x;
18417
+ categories.forEach((cat, i) => {
18418
+ const colTotal = colTotals[i];
18419
+ if (colTotal <= 0) return;
18420
+ const colW = (colTotal / grandTotal) * width;
18421
+ let cursorY = y + height;
18422
+
18423
+ cat.values.forEach((value, j) => {
18424
+ if (value == null || value <= 0) return;
18425
+ const share = (value / colTotal) * 100;
18426
+ const segH = (share / 100) * height;
18427
+ cursorY -= segH;
18428
+ const series = seriesNames[j] ?? `Series ${j + 1}`;
18429
+ const color = palette.colorAt(j);
18430
+ this._datum(this.renderer.rect(cursorX, cursorY, colW, segH, color, { color: '#fff', width: 1 }, {
18431
+ tooltip: `${cat.label} - ${series}: ${value} (${share.toFixed(1)}%)`,
18432
+ }), { label: cat.label, series });
18433
+ });
18434
+
18435
+ if (colW >= 24) {
18436
+ const labelText = TableChart.fit(String(cat.label), colW - 4, measure);
18437
+ this.renderer.text(labelText, cursorX + colW / 2, y + height + 18, { textAnchor, fontSize: '11px' });
18438
+ }
18439
+ cursorX += colW;
18440
+ });
18441
+
18442
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
18443
+
18444
+ if (showLegend && seriesNames.length > 0) {
18445
+ Legend.beside({ x, y, width }, this.renderer.width, legendOpts).draw(this.renderer);
18446
+ }
18447
+ }
18448
+ }
18449
+
17976
18450
  /**
17977
18451
  * ChartChooser — which catalog chart types fit a set of result rows, best first, each with the rows
17978
18452
  * already shaped as that chart's `dataShape`. A table always fits, so it is always offered, last.
@@ -18706,6 +19180,16 @@
18706
19180
  class PivotView {
18707
19181
  static ROLLUP_COLUMN = '_rollup';
18708
19182
 
19183
+ static AGGREGATIONS = [
19184
+ { key: 'sum', label: 'Sum' },
19185
+ { key: 'count', label: 'Count' },
19186
+ { key: 'avg', label: 'Average' },
19187
+ { key: 'median', label: 'Median' },
19188
+ { key: 'min', label: 'Minimum' },
19189
+ { key: 'max', label: 'Maximum' },
19190
+ { key: 'stddev', label: 'Std Dev' },
19191
+ ];
19192
+
18709
19193
  /**
18710
19194
  * @param {Array<object>} rows result rows: dimension values, measure values, and `_rollup`
18711
19195
  * @param {object} [options]
@@ -18733,6 +19217,7 @@
18733
19217
  * @param {function} [options.copy] writes text to the clipboard (navigator.clipboard by default)
18734
19218
  * @param {function|Intl.NumberFormat|object} [options.format] how numbers show, for every measure or per
18735
19219
  * measure (`TableChart.formatFor`): cells, totals and the traffic lights' cut-offs. A copy keeps the raw numbers.
19220
+ * @param {string[]} [options.visibleMeasures] subset of `measures` currently shown in the grid and chart
18736
19221
  */
18737
19222
  constructor(rows, options = {}) {
18738
19223
  this.onChange = options.onChange ?? null;
@@ -18748,6 +19233,18 @@
18748
19233
  this.lightRules = options.trafficLights ?? null;
18749
19234
  /** Traffic lights showing: null (off), 'rules' (the host's), 'thirds-high' or 'thirds-low' (by thirds, higher or lower is better). */
18750
19235
  this.lights = this.lightRules ? 'rules' : null;
19236
+ /** Optional sort on the row axis: `{ columnKey, measure, direction: 'asc'|'desc' }`, or null. */
19237
+ this.rowSort = null;
19238
+ /** Optional Top-N / Bottom-N filter on row siblings: `{ count, columnKey, measure, direction: 'desc'|'asc' }`, or null. */
19239
+ this.topN = null;
19240
+ /** Post-rollup derived formula measures: `Map<name, { name, formula, params, fn }>`. */
19241
+ this.derivedMeasures = new Map();
19242
+ /** Summary / aggregation mode: `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`. */
19243
+ this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === options.aggregation) ? options.aggregation : 'sum';
19244
+ /** Optional raw fact rows behind the cube, enabling exact cell-level Count, Average, Median, Min, Max, and Std Dev. */
19245
+ this.facts = Array.isArray(options.facts) ? options.facts : null;
19246
+ /** How cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
19247
+ this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(options.valueMode) ? options.valueMode : 'value';
18751
19248
  this._load(rows, options);
18752
19249
  }
18753
19250
 
@@ -18764,6 +19261,10 @@
18764
19261
  _load(rows, options) {
18765
19262
  this._options = options;
18766
19263
  this.rows = rows;
19264
+ if (Array.isArray(options.facts)) this.facts = options.facts;
19265
+ if (options.aggregation && PivotView.AGGREGATIONS.some(a => a.key === options.aggregation)) {
19266
+ this.aggregation = options.aggregation;
19267
+ }
18767
19268
  this.rowAxis = [...(options.rowAxis ?? [])];
18768
19269
  this.columnAxis = [...(options.columnAxis ?? [])];
18769
19270
  this.pageAxis = (options.pageAxis ?? []).map(p => ({ dimension: p.dimension, value: p.value ?? null }));
@@ -18772,9 +19273,42 @@
18772
19273
  const rollup = (options.columns ?? []).find(c => c.kind === 'rollup');
18773
19274
  this.rollupColumn = rollup?.name ?? PivotView.ROLLUP_COLUMN;
18774
19275
  this.dimensions = options.dimensions ?? rollup?.dimensions ?? [...this.rowAxis, ...this.columnAxis];
18775
- this.measures = options.measures ?? this._defaultMeasures(rows, options.columns);
19276
+ if (Array.isArray(options.derivedMeasures)) {
19277
+ this.derivedMeasures.clear();
19278
+ for (const spec of options.derivedMeasures) {
19279
+ if (spec?.name && spec.formula) this.derivedMeasures.set(spec.name, PivotView._compileDerived(spec));
19280
+ }
19281
+ }
19282
+ const baseMeasures = options.measures ?? this._defaultMeasures(rows, options.columns);
19283
+ const derivedNames = [...this.derivedMeasures.keys()].filter(m => !baseMeasures.includes(m));
19284
+ this.allMeasures = [...baseMeasures, ...derivedNames];
19285
+ const keptMeasures = (options.visibleMeasures ?? this.measures ?? this.allMeasures).filter(m => this.allMeasures.includes(m));
19286
+ this.measures = keptMeasures.length > 0 ? keptMeasures : [...this.allMeasures];
18776
19287
  this.collapsed = { rows: new Set(this.collapsed?.rows ?? []), columns: new Set(this.collapsed?.columns ?? []) };
18777
19288
  this._index();
19289
+ if (options.topN && options.topN.count > 0) {
19290
+ this.topN = {
19291
+ count: options.topN.count,
19292
+ columnKey: options.topN.columnKey ?? this.columnRoot.key,
19293
+ measure: options.topN.measure ?? this.measures[0],
19294
+ direction: options.topN.direction === 'asc' ? 'asc' : 'desc',
19295
+ };
19296
+ }
19297
+ }
19298
+
19299
+ static _compileDerived({ name, formula, params = null }) {
19300
+ if (typeof formula === 'function') {
19301
+ return { name, formula, params, fn: (row) => formula(row) };
19302
+ }
19303
+ const builtins = new Set(['abs', 'min', 'max', 'sqrt', 'log', 'exp', 'floor', 'ceil', 'round', 'clamp']);
19304
+ const paramList = Array.isArray(params) && params.length > 0
19305
+ ? params
19306
+ : [...new Set((String(formula).match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).filter(id => !builtins.has(id)))];
19307
+ const compiled = compileFormula(String(formula), paramList);
19308
+ const fn = (row) => (paramList.some(p => typeof row[p] !== 'number')
19309
+ ? null
19310
+ : compiled(...paramList.map(p => row[p])));
19311
+ return { name, formula, params: paramList, fn };
18778
19312
  }
18779
19313
 
18780
19314
  _defaultMeasures(rows, columns) {
@@ -18806,6 +19340,7 @@
18806
19340
 
18807
19341
  _index() {
18808
19342
  this.cells = new Map();
19343
+ this._sampleCache = new Map();
18809
19344
  this.unplaced = 0;
18810
19345
  this.rowRoot = PivotView._node([], null);
18811
19346
  this.columnRoot = PivotView._node([], null);
@@ -18854,12 +19389,35 @@
18854
19389
 
18855
19390
  /**
18856
19391
  * The rows drawn, top to bottom: each member with its own totals, then (unless collapsed) the
18857
- * members below it, indented; the grand total last. `{ node, depth }`.
19392
+ * members below it, indented; the grand total last. `{ node, depth }`. When `rowSort` is set,
19393
+ * siblings at each level are ordered by that column/measure (nulls last).
18858
19394
  */
18859
19395
  visibleRows() {
18860
19396
  const out = [];
19397
+ const sortList = (list, spec) => {
19398
+ const { columnKey, measure, direction } = spec;
19399
+ const sign = direction === 'desc' ? -1 : 1;
19400
+ const colNode = this._findColumnNode(columnKey) ?? this.columnRoot;
19401
+ const col = { node: colNode, measure: this.measures.includes(measure) ? measure : this.measures[0] };
19402
+ return [...list].sort((a, b) => {
19403
+ const va = this.value(a, col), vb = this.value(b, col);
19404
+ if (va == null) return vb == null ? 0 : 1;
19405
+ if (vb == null) return -1;
19406
+ return sign * (va - vb);
19407
+ });
19408
+ };
19409
+ const order = (children) => {
19410
+ let list = [...children.values()];
19411
+ if (this.topN && this.topN.count > 0) {
19412
+ list = sortList(list, this.topN).slice(0, this.topN.count);
19413
+ }
19414
+ if (this.rowSort) {
19415
+ list = sortList(list, this.rowSort);
19416
+ }
19417
+ return list;
19418
+ };
18861
19419
  const walk = (node) => {
18862
- for (const child of node.children.values()) {
19420
+ for (const child of order(node.children)) {
18863
19421
  out.push({ node: child, depth: child.path.length - 1 });
18864
19422
  if (!this.isCollapsed('rows', child)) walk(child);
18865
19423
  }
@@ -18869,6 +19427,19 @@
18869
19427
  return out;
18870
19428
  }
18871
19429
 
19430
+ _findColumnNode(key) {
19431
+ if (!key || key === this.columnRoot.key) return this.columnRoot;
19432
+ const walk = (node) => {
19433
+ for (const child of node.children.values()) {
19434
+ if (child.key === key) return child;
19435
+ const found = walk(child);
19436
+ if (found) return found;
19437
+ }
19438
+ return null;
19439
+ };
19440
+ return walk(this.columnRoot);
19441
+ }
19442
+
18872
19443
  /**
18873
19444
  * The value columns drawn, left to right: each column-axis member's members (unless it is
18874
19445
  * collapsed, or has none), then its own total; the grand total last. One per measure for each.
@@ -18936,10 +19507,140 @@
18936
19507
  return out.map(({ id, ...cell }) => cell);
18937
19508
  }
18938
19509
 
18939
- /** The value at a row and column, from the row the data gave for that pair; null when it gave none. */
19510
+ /** Evaluates `measure` on a single data or fact row (handling derived formulas). */
19511
+ _rowMeasureValue(row, measure) {
19512
+ if (!row) return null;
19513
+ if (this.derivedMeasures.has(measure)) {
19514
+ let scope = row;
19515
+ if (this.derivedMeasures.size > 1) {
19516
+ scope = { ...row };
19517
+ for (const [name, d] of this.derivedMeasures) {
19518
+ const val = d.fn(scope);
19519
+ scope[name] = Number.isFinite(val) ? Math.round(val * 1000) / 1000 : null;
19520
+ if (name === measure) return scope[name];
19521
+ }
19522
+ }
19523
+ const d = this.derivedMeasures.get(measure);
19524
+ const v = d.fn(scope);
19525
+ return Number.isFinite(v) ? Math.round(v * 1000) / 1000 : null;
19526
+ }
19527
+ return row[measure] ?? null;
19528
+ }
19529
+
19530
+ /**
19531
+ * Collects the sample of numeric values behind `(rowNode, colNode, measure)` for statistical
19532
+ * aggregations (`count`, `avg`, `median`, `min`, `max`, `stddev`):
19533
+ * 1) From `this.facts` when raw fact rows are supplied,
19534
+ * 2) Else from `row._values?.[measure]` when pre-attached,
19535
+ * 3) Else from the descendant leaf cells in `this.cells`.
19536
+ */
19537
+ _cellSample(rowNode, colNode, measure) {
19538
+ const cacheKey = `${rowNode.key}|${colNode.key}|${measure}`;
19539
+ if (this._sampleCache?.has(cacheKey)) return this._sampleCache.get(cacheKey);
19540
+ let values = [];
19541
+ const row = this.cell(rowNode, colNode);
19542
+ if (Array.isArray(this.facts) && this.facts.length > 0) {
19543
+ const matching = this.facts.filter(f => rowNode.path.every((v, i) => f[this.rowAxis[i]] === v)
19544
+ && colNode.path.every((v, j) => f[this.columnAxis[j]] === v)
19545
+ && this.pageAxis.every(p => p.value === null || String(f[p.dimension]) === String(p.value))
19546
+ && this.memberFilters.every(m => (m.keep ? m.keep.includes(f[m.dimension]) : !m.remove?.includes(f[m.dimension]))));
19547
+ values = matching.map(f => this._rowMeasureValue(f, measure)).filter(v => typeof v === 'number' && Number.isFinite(v));
19548
+ } else if (Array.isArray(row?._values?.[measure])) {
19549
+ values = row._values[measure].filter(v => typeof v === 'number' && Number.isFinite(v));
19550
+ } else {
19551
+ const leaves = (node) => {
19552
+ if (node.children.size === 0) return [node];
19553
+ const out = [];
19554
+ for (const c of node.children.values()) out.push(...leaves(c));
19555
+ return out;
19556
+ };
19557
+ const rLeaves = leaves(rowNode);
19558
+ const cLeaves = leaves(colNode);
19559
+ for (const r of rLeaves) {
19560
+ for (const c of cLeaves) {
19561
+ const leafRow = this.cell(r, c);
19562
+ const v = this._rowMeasureValue(leafRow, measure);
19563
+ if (typeof v === 'number' && Number.isFinite(v)) values.push(v);
19564
+ }
19565
+ }
19566
+ }
19567
+ this._sampleCache?.set(cacheKey, values);
19568
+ return values;
19569
+ }
19570
+
19571
+ /** Computes `mode` (`'sum'|'count'|'avg'|'median'|'min'|'max'|'stddev'`) over `values`. */
19572
+ static aggregateValues(values, mode = 'sum', sumValue = null, countOverride = null) {
19573
+ if (mode === 'count') {
19574
+ if (typeof countOverride === 'number') return countOverride;
19575
+ return Array.isArray(values) ? values.length : 0;
19576
+ }
19577
+ if (!Array.isArray(values) || values.length === 0) {
19578
+ return mode === 'sum' ? sumValue : null;
19579
+ }
19580
+ const round3 = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : null);
19581
+ switch (mode) {
19582
+ case 'sum':
19583
+ return sumValue !== null ? sumValue : round3(values.reduce((a, b) => a + b, 0));
19584
+ case 'avg': {
19585
+ if (typeof countOverride === 'number' && countOverride > 0 && typeof sumValue === 'number') {
19586
+ return round3(sumValue / countOverride);
19587
+ }
19588
+ return round3(values.reduce((a, b) => a + b, 0) / values.length);
19589
+ }
19590
+ case 'median': {
19591
+ const sorted = [...values].sort((a, b) => a - b);
19592
+ const mid = Math.floor(sorted.length / 2);
19593
+ const med = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
19594
+ return round3(med);
19595
+ }
19596
+ case 'min':
19597
+ return round3(Math.min(...values));
19598
+ case 'max':
19599
+ return round3(Math.max(...values));
19600
+ case 'stddev': {
19601
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
19602
+ const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / values.length;
19603
+ return round3(Math.sqrt(variance));
19604
+ }
19605
+ default:
19606
+ return sumValue;
19607
+ }
19608
+ }
19609
+
19610
+ /** 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. */
18940
19611
  value(rowNode, column) {
18941
19612
  const row = this.cell(rowNode, column.node);
18942
- return row === null ? null : row[column.measure] ?? null;
19613
+ if (row === null) return null;
19614
+ const raw = this._rowMeasureValue(row, column.measure);
19615
+ if (!this.aggregation || this.aggregation === 'sum') return raw;
19616
+ const sample = this._cellSample(rowNode, column.node, column.measure);
19617
+ return PivotView.aggregateValues(sample, this.aggregation, raw, row._count);
19618
+ }
19619
+
19620
+ /**
19621
+ * The value at a row and column in the active `valueMode`: the raw value (`'value'`), or its share
19622
+ * (`0..100`) of the row total (`'rowPercent'`), column total (`'columnPercent'`), or grand total
19623
+ * (`'totalPercent'`). Returns null when the cell or its reference total is missing or zero.
19624
+ */
19625
+ displayValue(rowNode, column) {
19626
+ const v = this.value(rowNode, column);
19627
+ if (this.valueMode === 'value' || typeof v !== 'number') return v;
19628
+ const base = this.valueMode === 'rowPercent'
19629
+ ? this.value(rowNode, { node: this.columnRoot, measure: column.measure })
19630
+ : this.valueMode === 'columnPercent'
19631
+ ? this.value(this.rowRoot, column)
19632
+ : this.value(this.rowRoot, { node: this.columnRoot, measure: column.measure });
19633
+ if (typeof base !== 'number' || base === 0) return null;
19634
+ return Math.round((v / base) * 1000) / 10;
19635
+ }
19636
+
19637
+ /** How a cell at `(rowNode, column)` is formatted for display in the grid. */
19638
+ cellText(rowNode, column, nullText = '—') {
19639
+ const row = this.cell(rowNode, column.node);
19640
+ if (row === null) return '';
19641
+ if (this.valueMode === 'value') return this.valueText(column.measure, this.value(rowNode, column), nullText);
19642
+ const pct = this.displayValue(rowNode, column);
19643
+ return pct == null ? nullText : `${pct.toFixed(1)}%`;
18943
19644
  }
18944
19645
 
18945
19646
  // ── Changes ───────────────────────────────────────────────────────────────
@@ -18961,14 +19662,135 @@
18961
19662
  this._changed();
18962
19663
  }
18963
19664
 
19665
+ /**
19666
+ * Expands an axis to `level` (1 = outermost dimension only, 2 = first two dimensions, etc.) by
19667
+ * expanding collapsible members above `level` and collapsing those at or below `level`.
19668
+ */
19669
+ expandToLevel(axis, level) {
19670
+ const root = axis === 'rows' ? this.rowRoot : this.columnRoot;
19671
+ this.collapsed[axis].clear();
19672
+ const walk = (node) => {
19673
+ for (const c of node.children.values()) {
19674
+ if (c.path.length >= level && this.collapsible(c)) this.collapsed[axis].add(c.key);
19675
+ walk(c);
19676
+ }
19677
+ };
19678
+ walk(root);
19679
+ this._changed();
19680
+ }
19681
+
19682
+ /** Sets how cell values are shown: `'value'`, `'rowPercent'`, `'columnPercent'`, or `'totalPercent'`. */
19683
+ setValueMode(mode) {
19684
+ this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(mode) ? mode : 'value';
19685
+ this._changed();
19686
+ }
19687
+
19688
+ /**
19689
+ * Sets the summary / aggregation function for cube cells:
19690
+ * `'sum'`, `'count'`, `'avg'`, `'median'`, `'min'`, `'max'`, or `'stddev'`.
19691
+ */
19692
+ setAggregation(mode) {
19693
+ this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === mode) ? mode : 'sum';
19694
+ this._changed();
19695
+ }
19696
+
18964
19697
  /** Rows become columns and columns rows. */
18965
19698
  swapAxes() {
18966
19699
  [this.rowAxis, this.columnAxis] = [this.columnAxis, this.rowAxis];
18967
19700
  this.collapsed = { rows: this.collapsed.columns, columns: this.collapsed.rows };
19701
+ this.rowSort = null;
19702
+ this.topN = null;
18968
19703
  this._index();
18969
19704
  this._changed();
18970
19705
  }
18971
19706
 
19707
+ /**
19708
+ * Sorts row members within each level by `column` (`{ node?, columnKey?, measure }`) in `direction`
19709
+ * ('asc', 'desc', or null to clear).
19710
+ */
19711
+ sortRowsBy(column, direction = 'desc') {
19712
+ if (!direction || !column) {
19713
+ this.rowSort = null;
19714
+ } else {
19715
+ const columnKey = column.columnKey ?? column.node?.key ?? this.columnRoot.key;
19716
+ const measure = column.measure ?? this.measures[0];
19717
+ this.rowSort = { columnKey, measure, direction };
19718
+ }
19719
+ this._changed();
19720
+ }
19721
+
19722
+ /** Clears any row sort, returning row members to their data order. */
19723
+ clearRowSort() {
19724
+ this.rowSort = null;
19725
+ this._changed();
19726
+ }
19727
+
19728
+ /**
19729
+ * Filters sibling row members at each level to the top (`direction = 'desc'`) or bottom
19730
+ * (`direction = 'asc'`) `count` members by `measure` / `column`. Pass `count <= 0` or `null` to clear.
19731
+ */
19732
+ setTopN(count, { column = null, columnKey = null, measure = null, direction = 'desc' } = {}) {
19733
+ if (!count || count <= 0) {
19734
+ this.topN = null;
19735
+ } else {
19736
+ this.topN = {
19737
+ count: Math.floor(count),
19738
+ columnKey: columnKey ?? column?.columnKey ?? column?.node?.key ?? this.columnRoot.key,
19739
+ measure: measure ?? column?.measure ?? this.measures[0],
19740
+ direction: direction === 'asc' ? 'asc' : 'desc',
19741
+ };
19742
+ }
19743
+ this._changed();
19744
+ }
19745
+
19746
+ /** Clears any Top-N / Bottom-N row filter, showing all row members again. */
19747
+ clearTopN() {
19748
+ this.topN = null;
19749
+ this._changed();
19750
+ }
19751
+
19752
+ /**
19753
+ * Adds or updates a post-rollup derived formula measure (`{ name, formula, params? }`), evaluated at
19754
+ * every detail, subtotal, and grand-total cell via `compileFormula`.
19755
+ */
19756
+ addDerivedMeasure({ name, formula, params = {} }) {
19757
+ if (!name || !formula) return;
19758
+ this.derivedMeasures.set(name, PivotView._compileDerived({ name, formula, params }));
19759
+ if (!this.allMeasures.includes(name)) this.allMeasures.push(name);
19760
+ if (!this.measures.includes(name)) this.measures.push(name);
19761
+ this._changed();
19762
+ }
19763
+
19764
+ /** Removes a derived formula measure by name. */
19765
+ removeDerivedMeasure(name) {
19766
+ if (!this.derivedMeasures.has(name)) return;
19767
+ this.derivedMeasures.delete(name);
19768
+ this.allMeasures = this.allMeasures.filter(m => m !== name);
19769
+ const next = this.measures.filter(m => m !== name);
19770
+ this.measures = next.length > 0 ? next : [...this.allMeasures];
19771
+ this._changed();
19772
+ }
19773
+
19774
+ /** Shows or hides a measure (`allMeasures` holds every available measure; at least one stays visible). */
19775
+ toggleMeasure(measure) {
19776
+ if (!this.allMeasures.includes(measure)) return;
19777
+ if (this.measures.includes(measure)) {
19778
+ if (this.measures.length <= 1) return;
19779
+ this.measures = this.measures.filter(m => m !== measure);
19780
+ } else {
19781
+ this.measures = this.allMeasures.filter(m => this.measures.includes(m) || m === measure);
19782
+ }
19783
+ this._changed();
19784
+ }
19785
+
19786
+ /** Sets the visible measures (kept in `allMeasures` order; ignored if none match). */
19787
+ setMeasures(measures) {
19788
+ const next = this.allMeasures.filter(m => measures.includes(m));
19789
+ if (next.length === 0) return;
19790
+ this.measures = next;
19791
+ this._changed();
19792
+ }
19793
+
18972
19794
  _changed() {
18973
19795
  this.onChange?.(this);
18974
19796
  for (const listener of this._listeners) listener(this);
@@ -19151,22 +19973,83 @@
19151
19973
  const names = columns.map(seriesName);
19152
19974
  const rows = rowNodes.map((node, r) => Object.fromEntries([
19153
19975
  [category, rowLabels[r]],
19154
- ...columns.map((c, i) => [names[i], this.value(node, c)]),
19976
+ ...columns.map((c, i) => [names[i], this.displayValue(node, c)]),
19155
19977
  ]));
19156
19978
  const hint = (name) => (this._options.columns ?? []).find(c => c.name === name) ?? {};
19157
19979
  const depths = new Set(rowNodes.map(n => n.path.length));
19158
19980
  const innermost = depths.size === 1 ? this.rowAxis[[...depths][0] - 1] : null;
19159
19981
  const measureOf = (c) => hint(c.measure).measure ?? null;
19982
+ const primaryMeasure = this.measures[0] ?? null;
19983
+ const totalCol = this.columnRoot.hasCells && primaryMeasure ? { node: this.columnRoot, measure: primaryMeasure } : null;
19984
+ const rowTotals = totalCol
19985
+ ? rowNodes.map((node, r) => ({ label: rowLabels[r], value: this.displayValue(node, totalCol) })).filter(d => d.value != null)
19986
+ : null;
19160
19987
  return {
19161
19988
  rows,
19162
19989
  columns: [
19163
19990
  { name: category, kind: hint(innermost).kind === 'time' ? 'time' : 'categorical' },
19164
19991
  ...columns.map((c, i) => ({ name: names[i], kind: 'numeric', measure: measureOf(c) })),
19165
19992
  ],
19166
- // Members not broken down further cover the whole answer between them, unless rows went unplaced.
19167
- complete: this.unplaced === 0,
19993
+ // Members not broken down further cover the whole answer between them, unless rows went unplaced or Top-N filtered.
19994
+ complete: this.unplaced === 0 && !this.topN,
19168
19995
  members: new Map(rowNodes.map((node, r) => [rowLabels[r], node])),
19996
+ columnMembers: new Map(columns.map((c, i) => [names[i], c.node])),
19997
+ rowTotals,
19998
+ primaryMeasure: primaryMeasure ? { name: primaryMeasure, measure: measureOf({ measure: primaryMeasure }) } : null,
19999
+ hierarchy: this.hierarchyData(),
20000
+ };
20001
+ }
20002
+
20003
+ /**
20004
+ * The row-axis member tree shaped for hierarchical charts (`treemap`, `sunburst`, `outline`, `icicle`):
20005
+ * `Array<{ label, value?, children? }>`, using the single column/measure showing (or the row totals
20006
+ * column when multiple column members are showing) when the measure is additive and all values are
20007
+ * non-negative, or null when hierarchical part-of-whole does not fit.
20008
+ */
20009
+ hierarchyData() {
20010
+ if (this.unplaced > 0 || this.topN || this.rowAxis.length < 1) return null;
20011
+ const columns = this.visibleColumns().filter(c => !c.total && (this.columnAxis.length === 0 || c.node.path.length > 0));
20012
+ const col = columns.length === 1
20013
+ ? columns[0]
20014
+ : (columns.length > 1 && this.columnRoot.hasCells && this.measures.length > 0
20015
+ ? { node: this.columnRoot, measure: this.measures[0] }
20016
+ : null);
20017
+ if (!col) return null;
20018
+ const hint = (this._options.columns ?? []).find(c => c.name === col.measure) ?? {};
20019
+ if (hint.measure !== 'additive') return null;
20020
+ const allNodes = [];
20021
+ const collect = (node) => {
20022
+ for (const child of node.children.values()) {
20023
+ allNodes.push(child);
20024
+ if (!this.isCollapsed('rows', child)) collect(child);
20025
+ }
20026
+ };
20027
+ collect(this.rowRoot);
20028
+ if (allNodes.length === 0) return null;
20029
+ const labelMap = new Map(allNodes.map((n, i) => [n, PivotView._labels(allNodes)[i]]));
20030
+ let hasDeep = false;
20031
+ let negative = false;
20032
+ const build = (parent) => {
20033
+ const list = [];
20034
+ for (const child of parent.children.values()) {
20035
+ const label = labelMap.get(child) ?? PivotView.label(child);
20036
+ if (child.children.size > 0 && !this.isCollapsed('rows', child)) {
20037
+ const children = build(child);
20038
+ if (children.length > 0) {
20039
+ hasDeep = true;
20040
+ list.push({ label, children });
20041
+ }
20042
+ } else {
20043
+ const v = this.value(child, col);
20044
+ if (typeof v === 'number' && v < 0) negative = true;
20045
+ if (typeof v === 'number' && v > 0) list.push({ label, value: v });
20046
+ }
20047
+ }
20048
+ return list;
19169
20049
  };
20050
+ const tree = build(this.rowRoot);
20051
+ if (negative || tree.length === 0 || (!hasDeep && this.rowAxis.length < 2)) return null;
20052
+ return { tree, members: new Map(allNodes.map(n => [labelMap.get(n) ?? PivotView.label(n), n])), measure: col.measure };
19170
20053
  }
19171
20054
 
19172
20055
  /** Each member's label; its path (` › `) only where two members' labels would read the same. */
@@ -19179,19 +20062,33 @@
19179
20062
  }
19180
20063
 
19181
20064
  /**
19182
- * A click on a charted member (ChartBlox): a collapsed member expands; a member at the row axis's
19183
- * finest level drills down (the host's, through onDrill). Says which it did, or null.
20065
+ * A click on a charted member (ChartBlox): a collapsed member expands; a member at the axis's
20066
+ * finest level drills down (the host's, through onDrill). `axis` defaults to `'rows'`, or if a
20067
+ * row label does not match and `series` is given, tries `'columns'`. Says which it did, or null.
19184
20068
  */
19185
- drillFrom(label) {
19186
- const node = this.chartData().members.get(label);
19187
- if (!node || node.path.length === 0) return null;
19188
- if (this.isCollapsed('rows', node)) {
19189
- this.toggle('rows', node);
19190
- return 'expanded';
20069
+ drillFrom(label, series = null) {
20070
+ const data = this.chartData();
20071
+ const rowNode = data.members.get(label) ?? data.hierarchy?.members.get(label);
20072
+ if (rowNode && rowNode.path.length > 0) {
20073
+ if (this.isCollapsed('rows', rowNode)) {
20074
+ this.toggle('rows', rowNode);
20075
+ return 'expanded';
20076
+ }
20077
+ if (rowNode.path.length === this.rowAxis.length && this.onDrill) {
20078
+ this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: rowNode.path });
20079
+ return 'drilled';
20080
+ }
19191
20081
  }
19192
- if (node.path.length === this.rowAxis.length && this.onDrill) {
19193
- this.onDrill({ axis: 'rows', dimension: this.rowAxis.at(-1), direction: 'down', member: node.path });
19194
- return 'drilled';
20082
+ const colNode = series ? data.columnMembers.get(series) : data.columnMembers.get(label);
20083
+ if (colNode && colNode.path.length > 0) {
20084
+ if (this.isCollapsed('columns', colNode)) {
20085
+ this.toggle('columns', colNode);
20086
+ return 'expanded';
20087
+ }
20088
+ if (colNode.path.length === this.columnAxis.length && this.onDrill) {
20089
+ this.onDrill({ axis: 'columns', dimension: this.columnAxis.at(-1), direction: 'down', member: colNode.path });
20090
+ return 'drilled';
20091
+ }
19195
20092
  }
19196
20093
  return null;
19197
20094
  }
@@ -19223,12 +20120,26 @@
19223
20120
  }
19224
20121
  }
19225
20122
  items.push(...this._memberFilterItems(dimension, node));
20123
+ if (axis === 'columns') items.push(...this._columnSortItems(node));
19226
20124
  items.push({ separator: true },
19227
20125
  { label: `Expand all ${axis}`, action: () => this.collapseAll(axis, false) },
19228
20126
  { label: `Collapse all ${axis}`, action: () => this.collapseAll(axis, true) });
19229
20127
  return [...items, ...this._extra('member', { axis, node, dimension })];
19230
20128
  }
19231
20129
 
20130
+ _columnSortItems(node) {
20131
+ if (this.rowAxis.length === 0 || !node.hasCells) return [];
20132
+ const measure = this.measures[0];
20133
+ const active = this.rowSort?.columnKey === node.key && this.rowSort?.measure === measure ? this.rowSort.direction : null;
20134
+ const label = PivotView.label(node);
20135
+ return [
20136
+ { separator: true },
20137
+ { label: `Sort rows by ${label} descending${active === 'desc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'desc') },
20138
+ { label: `Sort rows by ${label} ascending${active === 'asc' ? ' ✓' : ''}`, action: () => this.sortRowsBy({ node, measure }, 'asc') },
20139
+ ...(this.rowSort ? [{ label: 'Clear row sort', action: () => this.clearRowSort() }] : []),
20140
+ ];
20141
+ }
20142
+
19232
20143
  // ── Traffic lights ─────────────────────────────────────────────────────────
19233
20144
 
19234
20145
  static LIGHTS = { low: '#f6d3cf', mid: '#fbe7b0', high: '#d3ebd4' };
@@ -19317,7 +20228,13 @@
19317
20228
  * collapsed on each axis, and the traffic lights. The layout itself is `layout()`.
19318
20229
  */
19319
20230
  displayState() {
19320
- return { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
20231
+ const state = { collapsed: { rows: [...this.collapsed.rows], columns: [...this.collapsed.columns] }, lights: this.lights };
20232
+ if (this.rowSort) state.rowSort = { ...this.rowSort };
20233
+ if (this.topN) state.topN = { ...this.topN };
20234
+ if (this.allMeasures.length > 1 && this.measures.length < this.allMeasures.length) state.measures = [...this.measures];
20235
+ if (this.valueMode !== 'value') state.valueMode = this.valueMode;
20236
+ if (this.aggregation !== 'sum') state.aggregation = this.aggregation;
20237
+ return state;
19321
20238
  }
19322
20239
 
19323
20240
  /**
@@ -19328,6 +20245,16 @@
19328
20245
  this.collapsed = { rows: new Set(state.collapsed?.rows ?? []), columns: new Set(state.collapsed?.columns ?? []) };
19329
20246
  const lights = state.lights ?? null;
19330
20247
  this.lights = ['thirds-high', 'thirds-low'].includes(lights) || (lights === 'rules' && this.lightRules) ? lights : null;
20248
+ this.rowSort = state.rowSort && ['asc', 'desc'].includes(state.rowSort.direction) ? { ...state.rowSort } : null;
20249
+ this.topN = state.topN && state.topN.count > 0 && ['asc', 'desc'].includes(state.topN.direction) ? { ...state.topN } : null;
20250
+ if (Array.isArray(state.measures)) {
20251
+ const kept = this.allMeasures.filter(m => state.measures.includes(m));
20252
+ if (kept.length > 0) this.measures = kept;
20253
+ } else {
20254
+ this.measures = [...this.allMeasures];
20255
+ }
20256
+ this.valueMode = ['rowPercent', 'columnPercent', 'totalPercent'].includes(state.valueMode) ? state.valueMode : 'value';
20257
+ this.aggregation = PivotView.AGGREGATIONS.some(a => a.key === state.aggregation) ? state.aggregation : 'sum';
19331
20258
  this._changed();
19332
20259
  }
19333
20260
 
@@ -19377,12 +20304,81 @@
19377
20304
  return items.length === 0 ? extra.slice(1) : [...items, ...extra];
19378
20305
  }
19379
20306
 
19380
- /** The grid's menu: swap the axes, expand or collapse everything, traffic lights, copy the grid. */
20307
+ /** The grid's menu: swap the axes, expand or collapse everything, traffic lights, measures, sort, copy the grid. */
19381
20308
  gridMenu() {
19382
20309
  const light = (label, mode) => ({ label: label + (this.lights === mode ? ' ✓' : ''), action: () => this.setLights(mode) });
20310
+ const measureItems = this.allMeasures.length > 1 ? [{
20311
+ label: 'Measures',
20312
+ items: this.allMeasures.map(m => ({
20313
+ label: `${m}${this.measures.includes(m) ? ' ✓' : ''}`,
20314
+ disabled: this.measures.length === 1 && this.measures[0] === m,
20315
+ action: () => this.toggleMeasure(m),
20316
+ })),
20317
+ }] : [];
20318
+ const sortItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
20319
+ label: 'Sort rows',
20320
+ items: [
20321
+ ...this.measures.flatMap(m => [
20322
+ {
20323
+ 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' ? ' ✓' : ''}`,
20324
+ action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'desc'),
20325
+ },
20326
+ {
20327
+ 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' ? ' ✓' : ''}`,
20328
+ action: () => this.sortRowsBy({ node: this.columnRoot, measure: m }, 'asc'),
20329
+ },
20330
+ ]),
20331
+ ...(this.rowSort ? [{ separator: true }, { label: 'Default order', action: () => this.clearRowSort() }] : []),
20332
+ ],
20333
+ }] : [];
20334
+ const topNChoice = (count, direction, label) => {
20335
+ const m = this.measures[0];
20336
+ const active = this.topN?.count === count && this.topN?.direction === direction && this.topN?.measure === m;
20337
+ return {
20338
+ label: `${label}${active ? ' ✓' : ''}`,
20339
+ action: () => this.setTopN(count, { node: this.columnRoot, measure: m, direction }),
20340
+ };
20341
+ };
20342
+ const topNItems = this.rowAxis.length > 0 && this.rowRoot.children.size > 1 && this.columnRoot.hasCells ? [{
20343
+ label: 'Top / Bottom N',
20344
+ items: [
20345
+ topNChoice(3, 'desc', 'Top 3'),
20346
+ topNChoice(5, 'desc', 'Top 5'),
20347
+ topNChoice(10, 'desc', 'Top 10'),
20348
+ { separator: true },
20349
+ topNChoice(3, 'asc', 'Bottom 3'),
20350
+ topNChoice(5, 'asc', 'Bottom 5'),
20351
+ ...(this.topN ? [{ separator: true }, { label: 'Show all rows', action: () => this.clearTopN() }] : []),
20352
+ ],
20353
+ }] : [];
20354
+ const aggregationItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
20355
+ label: 'Summary (Count / Avg / Median)',
20356
+ items: PivotView.AGGREGATIONS.map(a => ({
20357
+ label: `${a.label}${this.aggregation === a.key ? ' ✓' : ''}`,
20358
+ action: () => this.setAggregation(a.key),
20359
+ })),
20360
+ }] : [];
20361
+ const valueModeChoice = (label, mode) => ({
20362
+ label: `${label}${this.valueMode === mode ? ' ✓' : ''}`,
20363
+ action: () => this.setValueMode(mode),
20364
+ });
20365
+ const valueModeItems = (this.rowRoot.hasCells || this.columnRoot.hasCells) ? [{
20366
+ label: 'Show values as',
20367
+ items: [
20368
+ valueModeChoice('Values', 'value'),
20369
+ ...(this.columnAxis.length > 0 && this.columnRoot.hasCells ? [valueModeChoice('% of row total', 'rowPercent')] : []),
20370
+ ...(this.rowAxis.length > 0 && this.rowRoot.hasCells ? [valueModeChoice('% of column total', 'columnPercent')] : []),
20371
+ ...(this.rowRoot.hasCells && this.columnRoot.hasCells ? [valueModeChoice('% of grand total', 'totalPercent')] : []),
20372
+ ],
20373
+ }] : [];
19383
20374
  return [
19384
20375
  { label: 'Swap rows and columns', action: () => this.swapAxes() },
19385
20376
  { label: 'Expand all', action: () => { this.collapsed.rows.clear(); this.collapsed.columns.clear(); this._changed(); } },
20377
+ ...sortItems,
20378
+ ...topNItems,
20379
+ ...measureItems,
20380
+ ...aggregationItems,
20381
+ ...valueModeItems,
19386
20382
  {
19387
20383
  label: 'Traffic lights',
19388
20384
  items: [
@@ -19524,7 +20520,7 @@
19524
20520
  const lit = view.cellFill(node, c, cuts);
19525
20521
  if (lit) this.renderer.rect(x0 + i * colW + 1, top + 1, colW - 2, ROW_H - 2, lit, null);
19526
20522
  const row = view.cell(node, c.node);
19527
- const text = row === null ? '' : view.valueText(c.measure, row[c.measure], nullText);
20523
+ const text = view.cellText ? view.cellText(node, c, nullText) : (row === null ? '' : view.valueText(c.measure, row[c.measure], nullText));
19528
20524
  const shown = this._cell(text, end, x0 + i * colW, top, colW, { fill: row?.[c.measure] == null ? '#999' : '#222', ...weight });
19529
20525
  // A cell with a value opens its menu (drill-through) where the host gives one.
19530
20526
  if (row !== null && view.cellHasMenu()) {
@@ -19627,8 +20623,182 @@
19627
20623
 
19628
20624
  /** The charts that fit what the grid shows, best first. The grid itself is the table. */
19629
20625
  choices() {
19630
- const { rows, columns, complete } = this.view.chartData();
19631
- return this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
20626
+ const chartData = this.view.chartData();
20627
+ const { rows, columns, complete } = chartData;
20628
+ const base = this.chooser.choose(rows, columns, { complete }).filter(c => c.key !== 'table');
20629
+ const seen = new Set(base.map(c => c.key));
20630
+ const extra = this._cubeChoices(chartData).filter(c => !seen.has(c.key) && this.chooser.registry.get(c.key));
20631
+ return [...base, ...extra];
20632
+ }
20633
+
20634
+ /**
20635
+ * Additional chart types suited to a cube's hierarchy, series, and matrix structure beyond the
20636
+ * general flat-table defaults in `ChartChooser`.
20637
+ */
20638
+ _cubeChoices({ rows, columns, complete, hierarchy, rowTotals = null, primaryMeasure = null }) {
20639
+ if (!rows || rows.length === 0) return [];
20640
+ const categories = columns.filter(c => c.kind !== 'numeric');
20641
+ const numbers = columns.filter(c => c.kind === 'numeric');
20642
+ if (categories.length !== 1 || numbers.length === 0) return [];
20643
+ const category = categories[0];
20644
+ const add = (key, reason, data, options) => ({
20645
+ key,
20646
+ label: this.chooser.registry.get(key)?.label ?? key,
20647
+ reason,
20648
+ data,
20649
+ ...(options ? { options } : {}),
20650
+ });
20651
+ const out = [];
20652
+
20653
+ if (numbers.length === 1) {
20654
+ const number = numbers[0];
20655
+ const points = rows.filter(r => r[number.name] != null).map(r => ({
20656
+ label: String(r[category.name]),
20657
+ value: r[number.name],
20658
+ }));
20659
+ if (points.length < 2) return out;
20660
+ this._addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, false);
20661
+ return out;
20662
+ }
20663
+
20664
+ // Multi-series (2+ numeric series from column-axis members or multiple measures).
20665
+ const gaps = rows.some(r => numbers.some(n => r[n.name] == null));
20666
+ if (gaps) return out;
20667
+
20668
+ const names = numbers.map(n => n.name).join(', ');
20669
+ const seriesData = numbers.map(n => ({
20670
+ name: n.name,
20671
+ values: rows.map(r => ({ label: String(r[category.name]), value: r[n.name] })),
20672
+ }));
20673
+ const allNonNeg = rows.every(r => numbers.every(n => r[n.name] >= 0));
20674
+ const additiveNonNeg = allNonNeg && numbers.every(n => n.measure !== 'semi-additive' && n.measure !== 'non-additive');
20675
+
20676
+ if (additiveNonNeg) {
20677
+ out.push(add('stackedBar', `${names} stacked for each ${category.name}`, seriesData));
20678
+ out.push(add('marimekko', `${names} share and total across ${category.name}, as a Marimekko mosaic`, seriesData));
20679
+ }
20680
+ out.push(add('lollipop', `${names} for each ${category.name}, as stems and dots`, seriesData));
20681
+ if (allNonNeg && rows.length >= 2) {
20682
+ out.push(add('area', `${names} across ${category.name}, filled`, seriesData));
20683
+ }
20684
+ if (additiveNonNeg && rows.length >= 2) {
20685
+ out.push(add('stackedArea', `${names} stacked across ${category.name}`, seriesData));
20686
+ out.push(add('streamGraph', `${names} flowing across ${category.name}`, seriesData));
20687
+ }
20688
+ if (allNonNeg && rows.length >= 2) {
20689
+ out.push(add('pipe', `${names} tapering across ${category.name}`, seriesData.map(s => ({
20690
+ name: s.name,
20691
+ values: s.values.map(v => ({ label: v.label, value: v.value, metric: v.value })),
20692
+ }))));
20693
+ }
20694
+ const heatData = rows.flatMap(r => numbers.map(n => ({
20695
+ x: String(r[category.name]),
20696
+ y: n.name,
20697
+ value: r[n.name],
20698
+ })));
20699
+ out.push(add('heatmap', `${names} for each ${category.name}, as a colour grid`, heatData));
20700
+ if (allNonNeg && rows.length >= 3 && rows.length <= 12) {
20701
+ out.push(add('radar', `${names} across ${category.name} axes`, seriesData.map(s => ({
20702
+ name: s.name,
20703
+ values: s.values.map(v => ({ axis: v.label, value: v.value })),
20704
+ }))));
20705
+ }
20706
+ if (rows.length >= 2) {
20707
+ out.push(add('parallelCoordinates', `${names} across parallel axes for each ${category.name}`,
20708
+ rows.map(r => ({
20709
+ label: String(r[category.name]),
20710
+ category: String(r[category.name]),
20711
+ ...Object.fromEntries(numbers.map(n => [n.name, r[n.name]])),
20712
+ })),
20713
+ { dimensions: numbers.map(n => n.name) }));
20714
+ if (allNonNeg) {
20715
+ out.push(add('populationPyramid', `${numbers[0].name} vs ${numbers[1].name} for each ${category.name}`,
20716
+ rows.map(r => ({ label: String(r[category.name]), left: r[numbers[0].name], right: r[numbers[1].name] })),
20717
+ { leftLabel: numbers[0].name, rightLabel: numbers[1].name, showValues: true }));
20718
+ const flowLinks = rows.flatMap(r => numbers
20719
+ .filter(n => r[n.name] > 0)
20720
+ .map(n => ({ source: String(r[category.name]), target: n.name, value: r[n.name] })));
20721
+ if (flowLinks.length > 0) {
20722
+ out.push(add('sankey', `flow from ${category.name} to ${names}`, { links: flowLinks }));
20723
+ out.push(add('chord', `chords between ${category.name} and ${names}`, { links: flowLinks }));
20724
+ }
20725
+ }
20726
+ if (rows.length >= 3) {
20727
+ const distData = numbers.map(n => ({ label: n.name, values: rows.map(r => r[n.name]) }));
20728
+ out.push(add('boxPlot', `distribution of ${category.name} values for each of ${names}`, distData));
20729
+ out.push(add('density', `density curve of ${category.name} values for each of ${names}`, distData));
20730
+ out.push(add('violin', `violin distribution of ${category.name} values for each of ${names}`, distData));
20731
+ }
20732
+ out.push(add('scatter', `${numbers[0].name} against ${numbers[1].name} for each ${category.name}`,
20733
+ rows.map(r => ({ x: r[numbers[0].name], y: r[numbers[1].name], label: String(r[category.name]) }))));
20734
+ if (numbers.length >= 3 || (rowTotals && rowTotals.length === rows.length)) {
20735
+ out.push(add('bubble', `${numbers[0].name} and ${numbers[1].name} sized by ${numbers[2]?.name ?? (primaryMeasure?.name || 'Total')}`,
20736
+ rows.map((r, idx) => ({
20737
+ x: r[numbers[0].name],
20738
+ y: r[numbers[1].name],
20739
+ z: Math.max(1, numbers[2] ? r[numbers[2].name] : rowTotals[idx].value),
20740
+ label: String(r[category.name]),
20741
+ }))));
20742
+ }
20743
+ }
20744
+ if (allNonNeg) {
20745
+ out.push(add('bar3DOrbit', `${names} for each ${category.name}, in 3D`,
20746
+ rows.flatMap(r => numbers.map(n => ({ x: String(r[category.name]), z: n.name, value: r[n.name] })))));
20747
+ }
20748
+ if (additiveNonNeg) {
20749
+ out.push(add('stackedBar3DOrbit', `${names} stacked for each ${category.name}, in 3D`,
20750
+ rows.map(r => ({ x: String(r[category.name]), z: category.name, values: numbers.map(n => r[n.name]) })),
20751
+ { seriesLabels: numbers.map(n => n.name) }));
20752
+ }
20753
+ if (rowTotals && rowTotals.length >= 2 && primaryMeasure) {
20754
+ this._addSingleSeriesChoices(out, add, category, primaryMeasure, rowTotals, complete, hierarchy, true);
20755
+ }
20756
+ return out;
20757
+ }
20758
+
20759
+ _addSingleSeriesChoices(out, add, category, number, points, complete, hierarchy, includeBase = false) {
20760
+ const nonNeg = points.every(d => d.value >= 0);
20761
+ if (includeBase) {
20762
+ out.push(add('bar', `total ${number.name} for each ${category.name}`, points));
20763
+ if (nonNeg && complete && number.measure === 'additive' && points.length <= 8) {
20764
+ out.push(add('pie', `share of total ${number.name} across ${category.name}`, points));
20765
+ out.push(add('donut', `share of total ${number.name} across ${category.name}, as a ring`, points));
20766
+ }
20767
+ }
20768
+ if (hierarchy) {
20769
+ out.push(add('treemap', `${number.name} across the ${category.name} hierarchy`, hierarchy.tree));
20770
+ out.push(add('sunburst', `${number.name} across the ${category.name} hierarchy, in rings`, hierarchy.tree));
20771
+ out.push(add('icicle', `${number.name} across the ${category.name} hierarchy, as an icicle partition`, hierarchy.tree));
20772
+ out.push(add('outline', `${number.name} across the ${category.name} hierarchy, as an outline`, hierarchy.tree));
20773
+ }
20774
+ if (number.measure === 'additive') {
20775
+ out.push(add('waterfall', `how each ${category.name} adds to ${number.name}`, points));
20776
+ if (nonNeg && complete) {
20777
+ out.push(add('pareto', `cumulative share of ${number.name} across ${category.name}`, points));
20778
+ }
20779
+ }
20780
+ if (!includeBase) {
20781
+ out.push(add('lollipop', `${number.name} for each ${category.name}, as stems and dots`, points));
20782
+ if (nonNeg && points.length >= 2) {
20783
+ out.push(add('pipe', `${number.name} tapering across ${category.name}`,
20784
+ points.map(d => ({ label: d.label, value: d.value, metric: d.value }))));
20785
+ }
20786
+ }
20787
+ if (nonNeg && points.length <= 12) {
20788
+ out.push(add('funnel', `${number.name} across ${category.name} stages`, points));
20789
+ out.push(add('radialBar', `${number.name} for each ${category.name}, around a circle`, points));
20790
+ if (!includeBase && points.length >= 3) {
20791
+ out.push(add('radar', `${number.name} across ${category.name} axes`, points.map(d => ({ axis: d.label, value: d.value }))));
20792
+ }
20793
+ out.push(add('wordCloud', `${category.name} sized by ${number.name}`, points.map(d => ({ text: d.label, value: d.value }))));
20794
+ out.push(add('bar3D', `${number.name} for each ${category.name}, in 3D`, points));
20795
+ }
20796
+ if (points.length >= 3) {
20797
+ const sample = points.map(d => d.value);
20798
+ out.push(add('histogram', `frequency distribution of ${number.name} across ${category.name}`, sample));
20799
+ out.push(add('qq', `normal Q-Q plot of ${number.name} across ${category.name}`, sample));
20800
+ out.push(add('lag', `lag-1 autocorrelation of ${number.name} across ${category.name}`, sample));
20801
+ }
19632
20802
  }
19633
20803
 
19634
20804
  /** The chart to draw: the one asked for while it still fits, else the best; null when none fits. */
@@ -19637,20 +20807,826 @@
19637
20807
  return choices.find(c => c.key === this.type) ?? choices[0] ?? null;
19638
20808
  }
19639
20809
 
19640
- /** The chart's menu: "Chart as" each chart that fits, the one showing ticked. */
19641
- menu(redraw) {
20810
+ /** Sets the preferred chart type key (e.g. `'stackedBar'`, `'line'`, `'bar'`, `'pareto'`) and redraws if attached. */
20811
+ setType(type) {
20812
+ this.type = type;
20813
+ this.redraw?.();
20814
+ return this.chosen();
20815
+ }
20816
+
20817
+ /** The chart's menu: "Chart as" each chart that fits (plus quick top-level items), the one showing ticked. */
20818
+ menu(redraw = () => this.redraw?.()) {
19642
20819
  const current = this.chosen()?.key;
19643
- return [{
19644
- label: 'Chart as',
19645
- items: this.choices().map(c => ({
19646
- label: `${c.label}${c.key === current ? ' ✓' : ''}`,
19647
- action: () => { this.type = c.key; redraw(); },
19648
- })),
19649
- }];
20820
+ const all = this.choices();
20821
+ const toItem = (c) => ({
20822
+ label: `${c.label}${c.key === current ? ' ✓' : ''}`,
20823
+ action: () => { this.type = c.key; redraw(); },
20824
+ });
20825
+ const quickKeys = new Set([
20826
+ 'groupedBar', 'stackedBar', 'bar', 'line', 'area', 'stackedArea',
20827
+ 'pareto', 'waterfall', 'marimekko', 'lollipop', 'heatmap', 'radar',
20828
+ 'treemap', 'sunburst', 'icicle', 'sankey', 'pie', 'donut',
20829
+ ]);
20830
+ const quickItems = all.filter(c => quickKeys.has(c.key)).map(toItem);
20831
+ return [
20832
+ {
20833
+ label: 'Chart as',
20834
+ items: all.map(toItem),
20835
+ },
20836
+ ...(quickItems.length > 0 ? [{ separator: true }, ...quickItems] : []),
20837
+ ];
20838
+ }
20839
+
20840
+ /** A click on a charted value: drill the grid at its member (or its series' column member). */
20841
+ click(datum) { return this.view.drillFrom(datum.label, datum.series ?? null); }
20842
+ }
20843
+
20844
+ /**
20845
+ * AskView — the state and next-word prediction model for an interactive "Ask" bar
20846
+ * (question input, scrollable predictive typeahead list with semantic sense/reference/relation
20847
+ * badges, keyboard navigation, and configurable next-word prediction), kept apart from DOM
20848
+ * rendering so all of it can be unit-tested under Node.js without a browser.
20849
+ *
20850
+ * A host can supply its own `suggest(text, view)` callback (for example, semanticengine's
20851
+ * `(text) => engine.suggest(text, { sampler: (dim) => engine.members(dim) })`), or pass
20852
+ * declarative vocabulary hints (`measures`, `dimensions`, `members`, `vocabulary`,
20853
+ * `sampleQuestions`) to use `AskView.nextWordPredictor(...)`.
20854
+ */
20855
+ class AskView {
20856
+ /** Default choices for how many predicted next-word items are visible at once in the scrollable list (`0` = Off). */
20857
+ static DEFAULT_PREDICT_CHOICES = [0, 3, 5, 8, 10];
20858
+
20859
+ /**
20860
+ * @param {object} [options]
20861
+ * @param {string} [options.value=''] initial question text
20862
+ * @param {function} [options.suggest] `(text, view) => Array<string|{display, completion?, sense?, reference?, relation?, kind?}>`, or a promise of one
20863
+ * @param {string[]} [options.measures] measure names for built-in next-word prediction
20864
+ * @param {string[]} [options.dimensions] dimension names for built-in next-word prediction
20865
+ * @param {function|object} [options.members] `(dimension) => Array<string|number>` or `{ dim: [...] }` for value prediction after `=`
20866
+ * @param {Array<string|{display, completion?, sense?, reference?, relation?, kind?}>} [options.vocabulary] custom vocabulary entries
20867
+ * @param {string[]} [options.sampleQuestions] sample questions used both as clickable pills and to learn next-word transitions
20868
+ * @param {boolean} [options.predict=true] whether next-word prediction is initially enabled
20869
+ * @param {number} [options.visibleCount=5] how many items are visible at once in the scrollable suggestion viewport
20870
+ * @param {number[]} [options.predictChoices=[0, 3, 5, 8, 10]] configurable choices for the predict-next-word selector (`0` = Off)
20871
+ * @param {number} [options.maxSuggestions=50] maximum number of suggestions kept in the scrollable list
20872
+ * @param {function} [options.onAsk] `(question, view) => *` called when the user submits the question
20873
+ * @param {function} [options.onComplete] `(item, view) => void` called when a suggestion is accepted
20874
+ * @param {function} [options.onChange] `(view) => void` called whenever view state changes
20875
+ */
20876
+ constructor(options = {}) {
20877
+ this.value = options.value ?? '';
20878
+ this.samples = [...(options.sampleQuestions ?? options.samples ?? [])];
20879
+ this.predictChoices = Array.isArray(options.predictChoices) && options.predictChoices.length > 0
20880
+ ? [...options.predictChoices]
20881
+ : [...AskView.DEFAULT_PREDICT_CHOICES];
20882
+ const initialVisible = Number.isFinite(options.visibleCount) && options.visibleCount > 0 ? Math.floor(options.visibleCount) : 5;
20883
+ this.visibleCount = initialVisible;
20884
+ this.predict = options.predict !== false && options.visibleCount !== 0;
20885
+ if (this.predict && !this.predictChoices.includes(this.visibleCount)) {
20886
+ this.predictChoices = [...new Set([...this.predictChoices, this.visibleCount])].sort((a, b) => a - b);
20887
+ }
20888
+ this.maxSuggestions = Number.isFinite(options.maxSuggestions) && options.maxSuggestions > 0
20889
+ ? Math.floor(options.maxSuggestions)
20890
+ : 50;
20891
+ this.suggestFn = typeof options.suggest === 'function'
20892
+ ? options.suggest
20893
+ : AskView.nextWordPredictor({
20894
+ measures: options.measures,
20895
+ dimensions: options.dimensions,
20896
+ members: options.members,
20897
+ vocabulary: options.vocabulary,
20898
+ sampleQuestions: this.samples,
20899
+ });
20900
+ this.onAsk = options.onAsk ?? null;
20901
+ this.onComplete = options.onComplete ?? null;
20902
+ this.onChange = options.onChange ?? null;
20903
+ this.items = [];
20904
+ this.selectedIndex = -1;
20905
+ this.open = false;
20906
+ this.history = [];
20907
+ this._ticket = 0;
20908
+ }
20909
+
20910
+ /**
20911
+ * Current predict-next-word setting as a single number (`0` when Off, else `visibleCount`),
20912
+ * matching `predictChoices`.
20913
+ */
20914
+ get predictCount() {
20915
+ return this.predict ? this.visibleCount : 0;
20916
+ }
20917
+
20918
+ /**
20919
+ * Enables or disables next-word prediction, or sets how many items show at once in the
20920
+ * scrollable list (`0` or `false` turns prediction off; a positive integer enables it with
20921
+ * that viewport size).
20922
+ */
20923
+ setPredictCount(countOrEnabled) {
20924
+ if (countOrEnabled === false || countOrEnabled === 0 || countOrEnabled === '0' || countOrEnabled === 'off') {
20925
+ this.predict = false;
20926
+ this.items = [];
20927
+ this.selectedIndex = -1;
20928
+ this.open = false;
20929
+ this._changed();
20930
+ return;
20931
+ }
20932
+ if (countOrEnabled === true) {
20933
+ this.predict = true;
20934
+ if (this.visibleCount <= 0) this.visibleCount = 5;
20935
+ } else {
20936
+ const n = Number(countOrEnabled);
20937
+ if (Number.isFinite(n) && n > 0) {
20938
+ this.predict = true;
20939
+ this.visibleCount = Math.floor(n);
20940
+ }
20941
+ }
20942
+ this.refresh();
20943
+ }
20944
+
20945
+ /** Updates the input text and refreshes next-word predictions if enabled. */
20946
+ setInput(text, { suggest = true } = {}) {
20947
+ this.value = String(text ?? '');
20948
+ if (suggest && this.predict) {
20949
+ return this.refresh();
20950
+ } else {
20951
+ if (!suggest) {
20952
+ this.open = false;
20953
+ this.selectedIndex = -1;
20954
+ }
20955
+ this._changed();
20956
+ }
20957
+ }
20958
+
20959
+ /**
20960
+ * Swaps the source of suggestions (a host whose vocabulary changed — another model or
20961
+ * ontology) and refreshes the list.
20962
+ */
20963
+ setSuggest(suggest) {
20964
+ this.suggestFn = typeof suggest === 'function' ? suggest : null;
20965
+ this.refresh();
20966
+ }
20967
+
20968
+ /**
20969
+ * Recomputes suggestions for the current `value` and opens the list if any match. Nothing is
20970
+ * selected until the user moves into the list, so Enter asks the question as typed.
20971
+ *
20972
+ * `suggest` may answer with a promise (an engine still loading, a server's vocabulary): the
20973
+ * list keeps what it shows until it settles, and an answer for text since changed is dropped.
20974
+ * Returns the items, or a promise of them.
20975
+ */
20976
+ refresh() {
20977
+ const ticket = ++this._ticket;
20978
+ if (!this.predict || !this.suggestFn) {
20979
+ this._apply([]);
20980
+ return this.items;
20981
+ }
20982
+ let raw;
20983
+ try {
20984
+ raw = this.suggestFn(this.value, this);
20985
+ } catch {
20986
+ this._apply([]);
20987
+ return this.items;
20988
+ }
20989
+ if (raw && typeof raw.then === 'function') {
20990
+ const asked = this.value;
20991
+ return Promise.resolve(raw).then(
20992
+ (items) => (ticket === this._ticket && asked === this.value ? this._apply(items) : this.items),
20993
+ () => (ticket === this._ticket ? this._apply([]) : this.items),
20994
+ );
20995
+ }
20996
+ return this._apply(raw);
19650
20997
  }
19651
20998
 
19652
- /** A click on a charted value: drill the grid at its member. */
19653
- click(datum) { return this.view.drillFrom(datum.label); }
20999
+ /** Shows `raw` suggestions (anything not a list shows none). */
21000
+ _apply(raw) {
21001
+ this.items = Array.isArray(raw)
21002
+ ? raw.slice(0, this.maxSuggestions).map((item) => AskView.normalizeItem(item, this.value))
21003
+ : [];
21004
+ this.selectedIndex = -1;
21005
+ this.open = this.items.length > 0;
21006
+ this._changed();
21007
+ return this.items;
21008
+ }
21009
+
21010
+ /**
21011
+ * Moves the active highlight up (`-1`) or down (`+1`) in the scrollable list, wrapping around.
21012
+ * From no selection, down goes to the first item and up to the last.
21013
+ */
21014
+ move(delta) {
21015
+ if (!this.open || this.items.length === 0) {
21016
+ this.refresh();
21017
+ return this.selectedIndex;
21018
+ }
21019
+ const len = this.items.length;
21020
+ this.selectedIndex = this.selectedIndex < 0
21021
+ ? (delta > 0 ? 0 : len - 1)
21022
+ : (this.selectedIndex + delta + len) % len;
21023
+ this._changed();
21024
+ return this.selectedIndex;
21025
+ }
21026
+
21027
+ /**
21028
+ * Applies the suggestion at `index` (defaults to `selectedIndex`, or `0`), replacing `value`
21029
+ * with `item.completion` and immediately predicting the next word.
21030
+ */
21031
+ accept(index = this.selectedIndex >= 0 ? this.selectedIndex : 0) {
21032
+ if (index < 0 || index >= this.items.length) return null;
21033
+ const item = this.items[index];
21034
+ this.value = item.completion;
21035
+ this.onComplete?.(item, this);
21036
+ if (this.predict) {
21037
+ this.refresh();
21038
+ } else {
21039
+ this.close();
21040
+ }
21041
+ return item;
21042
+ }
21043
+
21044
+ /** Closes the suggestion popup without changing `value`. */
21045
+ close() {
21046
+ if (!this.open && this.selectedIndex === -1) return;
21047
+ this.open = false;
21048
+ this.selectedIndex = -1;
21049
+ this._changed();
21050
+ }
21051
+
21052
+ /** Submits the current question (`value`), closes the suggestion list, and invokes `onAsk`. */
21053
+ submit(question = this.value) {
21054
+ this.value = String(question ?? '');
21055
+ this.open = false;
21056
+ this.selectedIndex = -1;
21057
+ const trimmed = this.value.trim();
21058
+ if (trimmed && this.history.at(-1) !== trimmed) {
21059
+ this.history.push(trimmed);
21060
+ }
21061
+ this._changed();
21062
+ return this.onAsk?.(this.value, this);
21063
+ }
21064
+
21065
+ _changed() {
21066
+ this.onChange?.(this);
21067
+ }
21068
+
21069
+ /** Normalizes a string or suggestion object into `{ display, completion, sense, reference, relation, kind }`. */
21070
+ static normalizeItem(item, currentText = '') {
21071
+ if (typeof item === 'string') {
21072
+ return {
21073
+ display: item,
21074
+ completion: AskView._completeWord(currentText, item),
21075
+ sense: null,
21076
+ reference: null,
21077
+ relation: null,
21078
+ kind: 'suggestion',
21079
+ };
21080
+ }
21081
+ const display = String(item.display ?? item.word ?? item.label ?? item.completion ?? '');
21082
+ const completion = item.completion !== undefined
21083
+ ? String(item.completion)
21084
+ : AskView._completeWord(currentText, display);
21085
+ return {
21086
+ display,
21087
+ completion,
21088
+ sense: item.sense ?? null,
21089
+ reference: item.reference ?? null,
21090
+ relation: item.relation ?? null,
21091
+ kind: item.kind ?? 'suggestion',
21092
+ };
21093
+ }
21094
+
21095
+ /** Replaces the trailing partial token of `currentText` with `word` (followed by a space). */
21096
+ static _completeWord(currentText, word) {
21097
+ const text = String(currentText ?? '');
21098
+ if (word.includes(' ') && word.toLowerCase().startsWith(text.trim().toLowerCase()) && text.trim().length > 0) {
21099
+ return word;
21100
+ }
21101
+ const match = text.match(/^(.*?)(\S*)$/);
21102
+ const prefix = match ? match[1] : '';
21103
+ const cleanWord = String(word).trim();
21104
+ return `${prefix}${cleanWord} `;
21105
+ }
21106
+
21107
+ /**
21108
+ * Builds a context-aware next-word predictor from declarative cube/table metadata and/or
21109
+ * sample questions. Predicts metrics, dimensions, clauses (`by`, `and`, `where`, `top`, `vs`),
21110
+ * sampled dimension members after `=`, and learned next-word bigrams from `sampleQuestions`.
21111
+ */
21112
+ static nextWordPredictor({
21113
+ measures = [],
21114
+ dimensions = [],
21115
+ members = null,
21116
+ vocabulary = [],
21117
+ sampleQuestions = [],
21118
+ } = {}) {
21119
+ const catalog = [];
21120
+ const addEntry = (display, kind, sense = null, reference = null, relation = null) => {
21121
+ if (!display) return;
21122
+ if (catalog.some(c => c.display.toLowerCase() === String(display).toLowerCase() && c.kind === kind)) return;
21123
+ catalog.push({ display: String(display), kind, sense, reference, relation });
21124
+ };
21125
+
21126
+ for (const m of measures) addEntry(m, 'metric', `Measure ${m}`, m, 'canonical');
21127
+ for (const d of dimensions) addEntry(d, 'dimension', `Dimension ${d}`, d, 'canonical');
21128
+ for (const v of vocabulary) {
21129
+ if (typeof v === 'string') addEntry(v, 'term');
21130
+ else addEntry(v.display ?? v.word ?? v.label, v.kind ?? 'term', v.sense ?? null, v.reference ?? null, v.relation ?? null);
21131
+ }
21132
+
21133
+ const keywords = [
21134
+ { display: 'by', kind: 'keyword', sense: 'Group or slice by dimension' },
21135
+ { display: 'and', kind: 'keyword', sense: 'Add another measure or dimension' },
21136
+ { display: 'where', kind: 'keyword', sense: 'Filter by dimension member' },
21137
+ { display: 'top', kind: 'keyword', sense: 'Rank top N members' },
21138
+ { display: '=', kind: 'operator', sense: 'Equals member value' },
21139
+ { display: 'in', kind: 'keyword', sense: 'Match any of several members' },
21140
+ { display: 'vs', kind: 'keyword', sense: 'Compare against another measure or period' },
21141
+ { display: 'descending', kind: 'keyword', sense: 'Sort highest first' },
21142
+ { display: 'ascending', kind: 'keyword', sense: 'Sort lowest first' },
21143
+ ];
21144
+ for (const kw of keywords) addEntry(kw.display, kw.kind, kw.sense);
21145
+
21146
+ // Learn next-word transitions (bigrams) from sampleQuestions.
21147
+ const bigrams = new Map();
21148
+ for (const q of sampleQuestions) {
21149
+ const tokens = String(q).trim().split(/\s+/).filter(Boolean);
21150
+ for (let i = 0; i < tokens.length - 1; i++) {
21151
+ const prev = tokens[i].replace(/,+$/, '').toLowerCase();
21152
+ const next = tokens[i + 1].replace(/,+$/, '');
21153
+ if (!bigrams.has(prev)) bigrams.set(prev, new Set());
21154
+ bigrams.get(prev).add(next);
21155
+ addEntry(tokens[i].replace(/,+$/, ''), 'term');
21156
+ }
21157
+ if (tokens.length > 0) addEntry(tokens.at(-1).replace(/,+$/, ''), 'term');
21158
+ }
21159
+
21160
+ const getMembers = (dim) => {
21161
+ if (!dim || !members) return [];
21162
+ if (typeof members === 'function') return members(dim) ?? [];
21163
+ const foundKey = Object.keys(members).find(k => k.toLowerCase() === dim.toLowerCase());
21164
+ return foundKey ? members[foundKey] ?? [] : [];
21165
+ };
21166
+
21167
+ return (text) => {
21168
+ const raw = String(text ?? '');
21169
+ const match = raw.match(/^(.*?)(\S*)$/);
21170
+ const before = match ? match[1] : '';
21171
+ const partial = (match ? match[2] : '').toLowerCase();
21172
+ const priorTokens = before.trim().split(/\s+/).filter(Boolean);
21173
+ const lastToken = (priorTokens.at(-1) ?? '').replace(/,+$/, '').toLowerCase();
21174
+ const prevToken = (priorTokens.at(-2) ?? '').replace(/,+$/, '').toLowerCase();
21175
+
21176
+ const results = [];
21177
+ const push = (entry, bonus = 0) => {
21178
+ const word = String(entry.display);
21179
+ if (partial && !word.toLowerCase().startsWith(partial)) return;
21180
+ if (results.some(r => r.display.toLowerCase() === word.toLowerCase())) return;
21181
+ results.push({
21182
+ display: word,
21183
+ completion: AskView._completeWord(raw, word),
21184
+ sense: entry.sense ?? null,
21185
+ reference: entry.reference ?? null,
21186
+ relation: entry.relation ?? null,
21187
+ kind: entry.kind ?? 'suggestion',
21188
+ _score: bonus + (word.toLowerCase() === partial ? 0 : 10),
21189
+ });
21190
+ };
21191
+
21192
+ // 1. After `<dimension> =`, suggest sampled member values of that dimension.
21193
+ if (lastToken === '=' && prevToken) {
21194
+ const dimName = dimensions.find(d => d.toLowerCase() === prevToken) ?? prevToken;
21195
+ for (const val of getMembers(dimName)) {
21196
+ push({
21197
+ display: String(val),
21198
+ kind: 'value',
21199
+ sense: `Member of ${dimName}`,
21200
+ reference: `${dimName} = ${val}`,
21201
+ }, 100);
21202
+ }
21203
+ }
21204
+
21205
+ // 2. Context-sensitive grammar transitions.
21206
+ if (!lastToken) {
21207
+ for (const c of catalog.filter(e => e.kind === 'metric')) push(c, 80);
21208
+ for (const c of catalog.filter(e => e.display === 'top')) push(c, 70);
21209
+ for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 60);
21210
+ } else if (lastToken === 'by' || lastToken === 'where') {
21211
+ for (const c of catalog.filter(e => e.kind === 'dimension')) push(c, 90);
21212
+ } else if (lastToken === 'and') {
21213
+ const inByClause = priorTokens.some(t => t.toLowerCase() === 'by');
21214
+ for (const c of catalog.filter(e => e.kind === (inByClause ? 'dimension' : 'metric'))) push(c, 90);
21215
+ for (const c of catalog.filter(e => e.kind === (inByClause ? 'metric' : 'dimension'))) push(c, 70);
21216
+ } else if (dimensions.some(d => d.toLowerCase() === lastToken)) {
21217
+ const inWhere = priorTokens.some(t => t.toLowerCase() === 'where');
21218
+ if (inWhere) {
21219
+ for (const c of catalog.filter(e => e.display === '=')) push(c, 95);
21220
+ } else {
21221
+ for (const c of catalog.filter(e => ['and', 'where', 'descending', 'ascending'].includes(e.display))) push(c, 85);
21222
+ }
21223
+ } else if (measures.some(m => m.toLowerCase() === lastToken)) {
21224
+ for (const c of catalog.filter(e => ['by', 'and', 'where', 'vs'].includes(e.display))) push(c, 90);
21225
+ }
21226
+
21227
+ // 3. Learned bigram transitions from sample questions.
21228
+ if (lastToken && bigrams.has(lastToken)) {
21229
+ for (const nextWord of bigrams.get(lastToken)) {
21230
+ const existing = catalog.find(c => c.display.toLowerCase() === nextWord.toLowerCase());
21231
+ push(existing ?? { display: nextWord, kind: 'next word', sense: `Follows "${lastToken}"` }, 88);
21232
+ }
21233
+ }
21234
+
21235
+ // 4. All remaining catalog entries matching the partial prefix.
21236
+ for (const c of catalog) push(c, 20);
21237
+
21238
+ return results
21239
+ .sort((a, b) => b._score - a._score)
21240
+ .map(({ _score, ...rest }) => rest);
21241
+ };
21242
+ }
21243
+ }
21244
+
21245
+ /**
21246
+ * AskBar — a self-contained browser UI component for asking questions with preemptive
21247
+ * next-word prediction in a scrollable list (generalizing the Ask bar from
21248
+ * `semanticengine/examples/browser/index.html` into a reusable RenderEngine capability).
21249
+ *
21250
+ * Follows the same OO encapsulation pattern as `ArchimateDiagramEditor`:
21251
+ * - Owns DOM creation, CSS injection, event wiring, and keyboard/scroll management.
21252
+ * - Separates pure state and prediction logic into `AskView` (`this.view`) so all logic is
21253
+ * DOM-free testable under Node.js.
21254
+ * - Includes a configurable **Predict next word** selector allowing the user (or host) to
21255
+ * choose how many predicted items are visible at once in the scrollable list (e.g. `Off`,
21256
+ * `3`, `5`, `8`, `10`), while scrolling (`overflow-y: auto`) or pressing `↑`/`↓` browses
21257
+ * through the full list of predictions.
21258
+ */
21259
+ class AskBar {
21260
+ /** Approximate height in pixels of one suggestion row in the scrollable list. */
21261
+ static ITEM_HEIGHT = 44;
21262
+
21263
+ /** Numbers each bar's list, so several bars on one page each name their own (ARIA ids). */
21264
+ static _count = 0;
21265
+
21266
+ /**
21267
+ * @param {HTMLElement|string} container host element or its DOM id
21268
+ * @param {object} [options] all `AskView` options plus UI options:
21269
+ * @param {Document} [options.document] DOM document (defaults to `globalThis.document`)
21270
+ * @param {string} [options.placeholder='Ask a question…'] input placeholder
21271
+ * @param {string} [options.buttonLabel='Run'] submit button label
21272
+ * @param {string} [options.ariaLabel='Question'] input aria-label
21273
+ * @param {boolean} [options.showPredictControl=true] whether to show the configurable "Predict next word" selector
21274
+ * @param {string} [options.predictLabel='Predict next word:'] label for the predict selector
21275
+ * @param {boolean} [options.autoRun=false] whether to run `onAsk` immediately upon mounting
21276
+ * @param {object} [options.resultEngine] optional `RenderEngine` instance whose `resultView()` displays `{ rows, columns, complete }` returned by `onAsk`
21277
+ */
21278
+ constructor(container, options = {}) {
21279
+ this.options = options;
21280
+ this.doc = options.document ?? container?.ownerDocument ?? globalThis.document ?? null;
21281
+ this.container = typeof container === 'string'
21282
+ ? this.doc?.getElementById?.(container) ?? null
21283
+ : container ?? null;
21284
+ this.resultEngine = options.resultEngine ?? null;
21285
+
21286
+ const userOnAsk = options.onAsk;
21287
+ this.listId = `re-ask-list-${++AskBar._count}`;
21288
+ this._blurTimer = null;
21289
+ // An answer (`{ rows, columns, complete }`), or a promise of one, is shown by resultEngine.
21290
+ const show = (res) => {
21291
+ if (res && typeof res === 'object' && Array.isArray(res.rows) && this.resultEngine?.resultView) {
21292
+ this.resultEngine.resultView(res.rows, {
21293
+ columns: res.columns,
21294
+ complete: res.complete,
21295
+ pageSize: options.pageSize ?? 10,
21296
+ });
21297
+ }
21298
+ return res;
21299
+ };
21300
+ this.view = options.view instanceof AskView ? options.view : new AskView({
21301
+ ...options,
21302
+ onAsk: (question, view) => {
21303
+ const res = userOnAsk?.(question, view, this);
21304
+ return res && typeof res.then === 'function' ? Promise.resolve(res).then(show) : show(res);
21305
+ },
21306
+ onChange: (view) => {
21307
+ this._syncFromView();
21308
+ options.onChange?.(view, this);
21309
+ },
21310
+ });
21311
+
21312
+ this.root = null;
21313
+ this.inputEl = null;
21314
+ this.suggestEl = null;
21315
+ this.predictSelectEl = null;
21316
+ this.samplesEl = null;
21317
+
21318
+ if (this.container && this.doc) {
21319
+ this.render();
21320
+ if (options.autoRun && this.view.value) {
21321
+ this.view.submit(this.view.value);
21322
+ }
21323
+ }
21324
+ }
21325
+
21326
+ /** Injects the component's scoped stylesheet once per document, build-free and theme-aware. */
21327
+ static _injectStyles(doc) {
21328
+ if (!doc?.head || doc.getElementById?.('re-ask-bar-styles')) return;
21329
+ const style = doc.createElement('style');
21330
+ style.id = 're-ask-bar-styles';
21331
+ style.textContent = `
21332
+ .re-ask-bar { display: flex; flex-direction: column; gap: 6px; font: 14px/1.45 system-ui, sans-serif; color: var(--ws-fg, var(--fg, #1d1d1f)); }
21333
+ .re-ask-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 0; }
21334
+ .re-ask-input-wrapper { position: relative; flex: 1 1 260px; min-width: 180px; }
21335
+ .re-ask-input { width: 100%; box-sizing: border-box; padding: 7px 10px; font: inherit; color: inherit; background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 6px; }
21336
+ .re-ask-input:focus { outline: 2px solid var(--ws-accent, var(--accent, #2b5fd9)); outline-offset: -1px; }
21337
+ .re-ask-submit { padding: 7px 14px; font: inherit; font-weight: 500; color: #fff; background: var(--ws-accent, var(--accent, #2b5fd9)); border: 0; border-radius: 6px; cursor: pointer; }
21338
+ .re-ask-submit:hover { filter: brightness(1.06); }
21339
+ .re-ask-predict-ctrl { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ws-muted, var(--muted, #666)); }
21340
+ .re-ask-predict-select { padding: 5px 7px; font: inherit; color: var(--ws-fg, var(--fg, #1d1d1f)); background: var(--ws-panel-bg, var(--bg, #fff)); border: 1px solid var(--ws-border, var(--line, #ccc)); border-radius: 5px; }
21341
+ .re-ask-suggest-box {
21342
+ position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 250;
21343
+ background: var(--ws-panel-bg, var(--bg, #fff));
21344
+ border: 1px solid var(--ws-border, var(--line, #ccc));
21345
+ border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.18);
21346
+ overflow-y: auto; display: flex; flex-direction: column;
21347
+ }
21348
+ .re-ask-suggest-box[hidden] { display: none !important; }
21349
+ .re-ask-suggest-item {
21350
+ display: flex; align-items: center; justify-content: space-between;
21351
+ padding: 7px 10px; cursor: pointer; font-size: 13px; gap: 8px; user-select: none;
21352
+ box-sizing: border-box; min-height: 36px;
21353
+ }
21354
+ .re-ask-suggest-item:hover, .re-ask-suggest-item.active {
21355
+ background: var(--ws-accent, var(--accent, #2b5fd9)); color: #fff;
21356
+ }
21357
+ .re-ask-suggest-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
21358
+ .re-ask-suggest-text { font-family: ui-monospace, monospace; font-size: 13px; }
21359
+ .re-ask-suggest-sense { font-size: 11px; color: var(--ws-muted, var(--muted, #6b6b70)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
21360
+ .re-ask-suggest-item:hover .re-ask-suggest-sense,
21361
+ .re-ask-suggest-item.active .re-ask-suggest-sense { color: rgba(255, 255, 255, 0.85); }
21362
+ .re-ask-suggest-badges { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
21363
+ .re-ask-suggest-badge {
21364
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
21365
+ padding: 2px 6px; border-radius: 4px;
21366
+ background: var(--ws-border, var(--line, #e0e0e4));
21367
+ color: var(--ws-muted, var(--muted, #555)); flex-shrink: 0;
21368
+ }
21369
+ .re-ask-suggest-item:hover .re-ask-suggest-badge,
21370
+ .re-ask-suggest-item.active .re-ask-suggest-badge {
21371
+ background: rgba(255, 255, 255, 0.25); color: #fff;
21372
+ }
21373
+ .re-ask-samples { font-size: 12px; color: var(--ws-muted, var(--muted, #666)); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
21374
+ .re-ask-sample-pill {
21375
+ font-family: ui-monospace, monospace; font-size: 11.5px;
21376
+ padding: 2px 6px; border-radius: 4px; cursor: pointer;
21377
+ background: rgba(127,127,127,0.12); border: 1px solid var(--ws-border, var(--line, #ddd));
21378
+ color: var(--ws-fg, var(--fg, #222));
21379
+ }
21380
+ .re-ask-sample-pill:hover { border-color: var(--ws-accent, var(--accent, #2b5fd9)); }
21381
+ `;
21382
+ doc.head.appendChild(style);
21383
+ }
21384
+
21385
+ /** Builds the AskBar DOM inside `this.container`. */
21386
+ render() {
21387
+ if (!this.container || !this.doc) return this;
21388
+ AskBar._injectStyles(this.doc);
21389
+
21390
+ const root = this.doc.createElement('div');
21391
+ root.className = 're-ask-bar';
21392
+
21393
+ const form = this.doc.createElement('form');
21394
+ form.className = 're-ask-form';
21395
+
21396
+ const wrapper = this.doc.createElement('div');
21397
+ wrapper.className = 're-ask-input-wrapper';
21398
+
21399
+ const input = this.doc.createElement('input');
21400
+ input.className = 're-ask-input';
21401
+ input.type = 'text';
21402
+ input.value = this.view.value;
21403
+ input.placeholder = this.options.placeholder ?? 'Ask a question…';
21404
+ input.setAttribute('aria-label', this.options.ariaLabel ?? 'Question');
21405
+ input.setAttribute('autocomplete', 'off');
21406
+ input.setAttribute('spellcheck', 'false');
21407
+ // A combobox over its list of completions (WAI-ARIA 1.2), so a screen reader follows ↑/↓.
21408
+ input.setAttribute('role', 'combobox');
21409
+ input.setAttribute('aria-autocomplete', 'list');
21410
+ input.setAttribute('aria-controls', this.listId);
21411
+ input.setAttribute('aria-expanded', 'false');
21412
+
21413
+ const suggestBox = this.doc.createElement('div');
21414
+ suggestBox.className = 're-ask-suggest-box';
21415
+ suggestBox.id = this.listId;
21416
+ suggestBox.hidden = true;
21417
+ suggestBox.setAttribute('role', 'listbox');
21418
+ suggestBox.setAttribute('aria-label', 'Suggested completions');
21419
+
21420
+ wrapper.appendChild(input);
21421
+ wrapper.appendChild(suggestBox);
21422
+ form.appendChild(wrapper);
21423
+
21424
+ const submitBtn = this.doc.createElement('button');
21425
+ submitBtn.type = 'submit';
21426
+ submitBtn.className = 're-ask-submit';
21427
+ submitBtn.textContent = this.options.buttonLabel ?? 'Run';
21428
+ form.appendChild(submitBtn);
21429
+
21430
+ if (this.options.showPredictControl !== false) {
21431
+ const ctrlLabel = this.doc.createElement('label');
21432
+ ctrlLabel.className = 're-ask-predict-ctrl';
21433
+ ctrlLabel.textContent = this.options.predictLabel ?? 'Predict next word:';
21434
+
21435
+ const select = this.doc.createElement('select');
21436
+ select.className = 're-ask-predict-select';
21437
+ select.setAttribute('aria-label', 'Predict next word in scrollable list');
21438
+ for (const choice of this.view.predictChoices) {
21439
+ const n = Number(choice);
21440
+ const text = n <= 0 ? 'Off' : `${n} visible (scrollable)`;
21441
+ const opt = this.doc.createElement('option');
21442
+ opt.value = String(n);
21443
+ opt.textContent = text;
21444
+ if (n === this.view.predictCount) opt.selected = true;
21445
+ select.appendChild(opt);
21446
+ }
21447
+ select.addEventListener('change', () => {
21448
+ this.view.setPredictCount(Number(select.value));
21449
+ });
21450
+ ctrlLabel.appendChild(select);
21451
+ form.appendChild(ctrlLabel);
21452
+ this.predictSelectEl = select;
21453
+ }
21454
+
21455
+ root.appendChild(form);
21456
+
21457
+ if (this.view.samples.length > 0) {
21458
+ const samplesRow = this.doc.createElement('div');
21459
+ samplesRow.className = 're-ask-samples';
21460
+ const prefix = this.doc.createElement('span');
21461
+ prefix.textContent = 'Try:';
21462
+ samplesRow.appendChild(prefix);
21463
+ for (const q of this.view.samples) {
21464
+ const pill = this.doc.createElement('code');
21465
+ pill.className = 're-ask-sample-pill';
21466
+ pill.textContent = q;
21467
+ pill.addEventListener('click', () => {
21468
+ this.view.submit(q);
21469
+ });
21470
+ samplesRow.appendChild(pill);
21471
+ }
21472
+ root.appendChild(samplesRow);
21473
+ this.samplesEl = samplesRow;
21474
+ }
21475
+
21476
+ this.root = root;
21477
+ this.inputEl = input;
21478
+ this.suggestEl = suggestBox;
21479
+ this._wireEvents(form, input);
21480
+ this.container.replaceChildren ? this.container.replaceChildren(root) : (this.container.innerHTML = '', this.container.appendChild(root));
21481
+ this._syncFromView();
21482
+ return this;
21483
+ }
21484
+
21485
+ _wireEvents(form, input) {
21486
+ input.addEventListener('input', () => {
21487
+ this.view.setInput(input.value, { suggest: true });
21488
+ });
21489
+ input.addEventListener('focus', () => {
21490
+ if (this.view.predict) this.view.refresh();
21491
+ });
21492
+ input.addEventListener('blur', () => {
21493
+ this._blurTimer = setTimeout(() => { this._blurTimer = null; this.view.close(); }, 150);
21494
+ });
21495
+ input.addEventListener('keydown', (e) => {
21496
+ if (!this.view.open || this.view.items.length === 0) {
21497
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
21498
+ this.view.refresh();
21499
+ }
21500
+ return;
21501
+ }
21502
+ if (e.key === 'ArrowDown') {
21503
+ e.preventDefault();
21504
+ this.view.move(1);
21505
+ } else if (e.key === 'ArrowUp') {
21506
+ e.preventDefault();
21507
+ this.view.move(-1);
21508
+ } else if (e.key === 'Tab') {
21509
+ e.preventDefault();
21510
+ this.view.accept(this.view.selectedIndex >= 0 ? this.view.selectedIndex : 0);
21511
+ this.inputEl?.focus?.();
21512
+ } else if (e.key === 'Enter' && this.view.selectedIndex >= 0) {
21513
+ e.preventDefault();
21514
+ this.view.accept(this.view.selectedIndex);
21515
+ this.inputEl?.focus?.();
21516
+ } else if (e.key === 'Escape') {
21517
+ this.view.close();
21518
+ }
21519
+ });
21520
+ form.addEventListener('submit', (e) => {
21521
+ e.preventDefault();
21522
+ this.view.submit(input.value);
21523
+ });
21524
+ }
21525
+
21526
+ /** Swaps where suggestions come from (`AskView.setSuggest`): another model or ontology. */
21527
+ setSuggest(suggest) {
21528
+ this.view.setSuggest(suggest);
21529
+ return this;
21530
+ }
21531
+
21532
+ /** Sets the question shown, without suggesting. */
21533
+ setValue(text) {
21534
+ this.view.setInput(text, { suggest: false });
21535
+ return this;
21536
+ }
21537
+
21538
+ /** Puts the cursor in the question. */
21539
+ focus() {
21540
+ this.inputEl?.focus?.();
21541
+ return this;
21542
+ }
21543
+
21544
+ /** Takes the bar out of its container; a pending close is cancelled. */
21545
+ destroy() {
21546
+ if (this._blurTimer !== null) clearTimeout(this._blurTimer);
21547
+ this._blurTimer = null;
21548
+ this.root?.remove?.();
21549
+ this.root = this.inputEl = this.suggestEl = this.predictSelectEl = this.samplesEl = null;
21550
+ }
21551
+
21552
+ _syncFromView() {
21553
+ if (!this.inputEl || !this.suggestEl) return;
21554
+ if (this.inputEl.value !== this.view.value) {
21555
+ this.inputEl.value = this.view.value;
21556
+ }
21557
+ if (this.predictSelectEl) {
21558
+ const val = String(this.view.predictCount);
21559
+ if (this.predictSelectEl.value !== val) this.predictSelectEl.value = val;
21560
+ }
21561
+
21562
+ const active = this.view.selectedIndex;
21563
+ if (active >= 0 && this.view.open) this.inputEl.setAttribute('aria-activedescendant', `${this.listId}-${active}`);
21564
+ else this.inputEl.removeAttribute?.('aria-activedescendant');
21565
+
21566
+ if (!this.view.open || !this.view.predict || this.view.items.length === 0) {
21567
+ this.inputEl.setAttribute('aria-expanded', 'false');
21568
+ this.suggestEl.hidden = true;
21569
+ if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
21570
+ return;
21571
+ }
21572
+
21573
+ this.suggestEl.style.maxHeight = `${Math.max(1, this.view.visibleCount) * AskBar.ITEM_HEIGHT}px`;
21574
+ if (this.suggestEl.replaceChildren) this.suggestEl.replaceChildren();
21575
+
21576
+ this.view.items.forEach((item, idx) => {
21577
+ const row = this.doc.createElement('div');
21578
+ row.className = `re-ask-suggest-item${idx === this.view.selectedIndex ? ' active' : ''}`;
21579
+ row.dataset.index = String(idx);
21580
+ row.id = `${this.listId}-${idx}`;
21581
+ row.setAttribute('role', 'option');
21582
+ row.setAttribute('aria-selected', String(idx === this.view.selectedIndex));
21583
+
21584
+ const mainCol = this.doc.createElement('div');
21585
+ mainCol.className = 're-ask-suggest-main';
21586
+
21587
+ const textSpan = this.doc.createElement('span');
21588
+ textSpan.className = 're-ask-suggest-text';
21589
+ textSpan.textContent = item.display;
21590
+ mainCol.appendChild(textSpan);
21591
+
21592
+ if (item.sense) {
21593
+ const senseSpan = this.doc.createElement('span');
21594
+ senseSpan.className = 're-ask-suggest-sense';
21595
+ senseSpan.textContent = `${item.sense}${item.reference ? ` → ${item.reference}` : ''}`;
21596
+ mainCol.appendChild(senseSpan);
21597
+ }
21598
+
21599
+ const badges = this.doc.createElement('div');
21600
+ badges.className = 're-ask-suggest-badges';
21601
+
21602
+ if (item.relation && item.relation !== 'canonical' && item.relation !== 'function') {
21603
+ const relBadge = this.doc.createElement('span');
21604
+ relBadge.className = 're-ask-suggest-badge';
21605
+ relBadge.textContent = item.relation;
21606
+ badges.appendChild(relBadge);
21607
+ }
21608
+ if (item.kind) {
21609
+ const kindBadge = this.doc.createElement('span');
21610
+ kindBadge.className = 're-ask-suggest-badge';
21611
+ kindBadge.textContent = item.kind;
21612
+ badges.appendChild(kindBadge);
21613
+ }
21614
+
21615
+ row.appendChild(mainCol);
21616
+ row.appendChild(badges);
21617
+ row.addEventListener('mousedown', (e) => {
21618
+ e.preventDefault();
21619
+ this.view.accept(idx);
21620
+ this.inputEl?.focus?.();
21621
+ });
21622
+ this.suggestEl.appendChild(row);
21623
+ if (idx === this.view.selectedIndex && typeof row.scrollIntoView === 'function') {
21624
+ row.scrollIntoView({ block: 'nearest' });
21625
+ }
21626
+ });
21627
+ this.suggestEl.hidden = false;
21628
+ this.inputEl.setAttribute('aria-expanded', 'true');
21629
+ }
19654
21630
  }
19655
21631
 
19656
21632
  /**
@@ -19672,7 +21648,7 @@
19672
21648
  } = options;
19673
21649
  const isMulti = Array.isArray(data[0]?.values);
19674
21650
  const series = isMulti ? data : [{ name: 'default', values: data }];
19675
- const axes = series[0].values.map(v => v.axis);
21651
+ const axes = series[0].values.map(v => v.axis ?? v.label);
19676
21652
  const n = axes.length;
19677
21653
  const palette = new ColorPalette(colors);
19678
21654
  const axisMax = axes.map((_, ai) => Math.max(...series.map(s => s.values[ai]?.value ?? 0)) || 1);
@@ -19710,9 +21686,10 @@
19710
21686
  const { r, g, b } = ColorPalette.hexToRgb(color);
19711
21687
  this.renderer.path(path, `rgba(${r},${g},${b},0.25)`, { color, width: 2 }, { tooltip: s.name });
19712
21688
  pts.forEach((p, i) => {
19713
- this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
19714
- tooltip: `${s.name} — ${s.values[i].axis}: ${s.values[i].value}`,
19715
- });
21689
+ const axisLabel = s.values[i].axis ?? s.values[i].label;
21690
+ this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
21691
+ tooltip: `${s.name} — ${axisLabel}: ${s.values[i].value}`,
21692
+ }), { label: axisLabel, series: isMulti ? s.name : null });
19716
21693
  });
19717
21694
  });
19718
21695
 
@@ -19761,7 +21738,7 @@
19761
21738
  const bottomRight = bottomLeft + bottomWidth;
19762
21739
 
19763
21740
  const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
19764
- this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
21741
+ this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
19765
21742
  this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
19766
21743
  textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
19767
21744
  });
@@ -19825,9 +21802,9 @@
19825
21802
  const top = toY(Math.max(band.from, band.to));
19826
21803
  const bottom = toY(Math.min(band.from, band.to));
19827
21804
  const color = band.isTotal ? totalColor : (band.to >= band.from ? upColor : downColor);
19828
- this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
21805
+ this._datum(this.renderer.rect(barX, top, barWidth, Math.max(1, bottom - top), color, null, {
19829
21806
  tooltip: `${d.label}: ${band.isTotal ? band.to : (d.value >= 0 ? '+' : '') + d.value} (total: ${Axis.formatValue(band.to)})`,
19830
- });
21807
+ }), { label: d.label });
19831
21808
  if (i % labelStep === 0) this.renderer.text(d.label, barX + barWidth / 2, y + height + 20, { textAnchor, fontSize: '10px' });
19832
21809
  });
19833
21810
  }
@@ -20956,12 +22933,12 @@
20956
22933
  const barBaseY = y + height;
20957
22934
  const color = palette.colorAt(i);
20958
22935
 
20959
- this.renderer.rect(
22936
+ this._datum(this.renderer.rect(
20960
22937
  barX, barBaseY - barHeight, barWidth, barHeight,
20961
22938
  color,
20962
22939
  { color: '#333', width: 1 },
20963
22940
  { tooltip: `${d.label}: ${d.value}` },
20964
- );
22941
+ ), { label: d.label });
20965
22942
 
20966
22943
  // Top face
20967
22944
  const topPath = `M${barX},${barBaseY - barHeight} L${barX + barWidth},${barBaseY - barHeight} L${barX + barWidth + depth},${barBaseY - barHeight - depth} L${barX + depth},${barBaseY - barHeight - depth} Z`;
@@ -21045,8 +23022,9 @@
21045
23022
 
21046
23023
  /**
21047
23024
  * Draw one 3D box through the orbit camera. Returns average corner depth.
23025
+ * When `datum` is supplied (`{ label, series? }`), each face is recorded via `_datum()`.
21048
23026
  */
21049
- drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen) {
23027
+ drawBox(renderer, x0, x1, y0, y1, z0, z1, color, toScreen, datum = null) {
21050
23028
  const camera = this.camera;
21051
23029
  const faceXSign = Math.sin(camera.theta) >= 0 ? 1 : -1;
21052
23030
  const faceZSign = Math.cos(camera.theta) >= 0 ? 1 : -1;
@@ -21060,7 +23038,8 @@
21060
23038
 
21061
23039
  const drawFace = (pts, shade) => {
21062
23040
  const d = `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y} L${pts[2].x},${pts[2].y} L${pts[3].x},${pts[3].y} Z`;
21063
- renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
23041
+ const el = renderer.path(d, ColorPalette.lighten(color, shade), { color: '#333', width: 1 });
23042
+ if (datum) this._datum(el, datum);
21064
23043
  };
21065
23044
  drawFace(top, 0.15);
21066
23045
  drawFace(xSide, faceXSign > 0 ? -0.05 : -0.2);
@@ -21180,7 +23159,9 @@
21180
23159
  }).sort((a, b) => b.depth - a.depth);
21181
23160
 
21182
23161
  for (const bar of bars) {
21183
- this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen);
23162
+ this.drawBox(this.renderer, bar.x0, bar.x1, 0, bar.y1, bar.z0, bar.z1, bar.color, toScreen, {
23163
+ label: bar.d.x, series: bar.d.z, x: bar.d.x, z: bar.d.z,
23164
+ });
21184
23165
  }
21185
23166
 
21186
23167
  new Legend({
@@ -21231,13 +23212,16 @@
21231
23212
  const segHeight = bar.d.values[j] / stackMax;
21232
23213
  const y0 = yCursor, y1 = yCursor + segHeight;
21233
23214
  yCursor = y1;
21234
- this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen);
23215
+ const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
23216
+ this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
23217
+ label: bar.d.x, series, x: bar.d.x, z: bar.d.z,
23218
+ });
21235
23219
  }
21236
23220
  }
21237
23221
 
21238
23222
  const maxSegments = Math.max(...data.map(d => d.values.length));
21239
23223
  new Legend({
21240
- items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
23224
+ items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
21241
23225
  x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
21242
23226
  }).draw(this.renderer);
21243
23227
  }
@@ -21748,6 +23732,14 @@
21748
23732
  const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
21749
23733
  const { r, g, b: bl } = palette.hexToRgb(color);
21750
23734
  this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
23735
+ if (typeof this.renderer.circle === 'function') {
23736
+ topPts.forEach((p, i) => {
23737
+ const d = s.values[i];
23738
+ this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
23739
+ tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
23740
+ }), { label: d.label, series: isMultiline ? s.name : null });
23741
+ });
23742
+ }
21751
23743
  });
21752
23744
 
21753
23745
  // X-axis labels.
@@ -21826,9 +23818,9 @@
21826
23818
  const rOuter = innerRadius + barLen;
21827
23819
  const color = d.color ?? palette.colorAt(i);
21828
23820
  const path = ShapePaths.donutSlicePath(cx, cy, rOuter, innerRadius, a0, a1);
21829
- this.renderer.path(path, color, { color: '#fff', width: 1 }, {
23821
+ this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
21830
23822
  tooltip: `${d.label}: ${d.value}`,
21831
- });
23823
+ }), { label: d.label });
21832
23824
 
21833
23825
  // Category label around the outer track.
21834
23826
  const midAngle = (a0 + a1) / 2;
@@ -21934,15 +23926,15 @@
21934
23926
 
21935
23927
  // Left bar grows from center to the left.
21936
23928
  if (leftW > 0) {
21937
- this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
23929
+ this._datum(this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
21938
23930
  tooltip: `${d.label} — ${leftLabel}: ${d.left}`,
21939
- });
23931
+ }), { label: d.label, series: leftLabel });
21940
23932
  }
21941
23933
  // Right bar grows from center to the right.
21942
23934
  if (rightW > 0) {
21943
- this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
23935
+ this._datum(this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
21944
23936
  tooltip: `${d.label} — ${rightLabel}: ${d.right}`,
21945
- });
23937
+ }), { label: d.label, series: rightLabel });
21946
23938
  }
21947
23939
 
21948
23940
  // Row label centered on the axis.
@@ -22082,18 +24074,18 @@
22082
24074
  const linkH = l.value * scale;
22083
24075
  const path = this._linkPath(n.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
22084
24076
  const { r, g, b: bl } = palette.hexToRgb(n.color);
22085
- this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
24077
+ this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
22086
24078
  tooltip: `${n.label} → ${t.label}: ${l.value}`,
22087
- });
24079
+ }), { label: n.label, series: t.label });
22088
24080
  outY += linkH;
22089
24081
  });
22090
24082
  });
22091
24083
 
22092
24084
  // Draw nodes.
22093
24085
  nodes.forEach(n => {
22094
- this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
24086
+ this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
22095
24087
  tooltip: `${n.label}: ${n.value}`,
22096
- });
24088
+ }), { label: n.label });
22097
24089
  this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
22098
24090
  textAnchor: 'middle', fontSize: '10px', fill: '#333',
22099
24091
  });
@@ -22233,19 +24225,19 @@
22233
24225
  const c2x = cx + (innerRadius * 0.2) * Math.cos(t.mid);
22234
24226
  const c2y = cy + (innerRadius * 0.2) * Math.sin(t.mid);
22235
24227
  const width = Math.max(1, (l.value / maxLink) * maxStroke);
22236
- this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
24228
+ this._datum(this.renderer.path(`M${sx},${sy} C${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, 'none', {
22237
24229
  color: s.color, width,
22238
24230
  }, {
22239
24231
  tooltip: `${s.label} ↔ ${t.label}: ${l.value}`,
22240
- });
24232
+ }), { label: s.label, series: t.label });
22241
24233
  });
22242
24234
 
22243
24235
  // Draw node arcs.
22244
24236
  nodes.forEach(n => {
22245
24237
  const path = this._arcPath(cx, cy, radius, innerRadius, n.start, n.end);
22246
- this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
24238
+ this._datum(this.renderer.path(path, n.color, { color: '#fff', width: 1 }, {
22247
24239
  tooltip: `${n.label}: ${n.value}`,
22248
- });
24240
+ }), { label: n.label });
22249
24241
 
22250
24242
  // Label outside the circle.
22251
24243
  const lx = cx + (radius + 16) * Math.cos(n.mid);
@@ -22335,10 +24327,10 @@
22335
24327
  if (!pos) return; // Could not place; skip.
22336
24328
  placed.push({ x: pos.x, y: pos.y, w: box.w, h: box.h });
22337
24329
  const color = palette.colorAt(i);
22338
- this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
24330
+ this._datum(this.renderer.text(d.text, pos.x, pos.y + fontSize * 0.35, {
22339
24331
  textAnchor, fontSize: `${fontSize}px`, fill: color, fontFamily,
22340
24332
  tooltip: `${d.text}: ${d.value}`,
22341
- });
24333
+ }), { label: d.text });
22342
24334
  });
22343
24335
  }
22344
24336
 
@@ -22454,11 +24446,11 @@
22454
24446
  pathData += `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`;
22455
24447
  });
22456
24448
  const label = row.label || (hasCategories ? row.category : null);
22457
- this.renderer.path(pathData, 'none', {
24449
+ this._datum(this.renderer.path(pathData, 'none', {
22458
24450
  color, width: lineWidth,
22459
24451
  opacity: lineOpacity,
22460
24452
  tooltip: label ? `${label}: ${dimensions.map(d => `${d}=${this._fmt(row[d])}`).join(', ')}` : undefined,
22461
- });
24453
+ }), { label: label ?? String(rowIndex) });
22462
24454
  });
22463
24455
 
22464
24456
  if (legendVisible && hasCategories) {
@@ -22690,9 +24682,12 @@
22690
24682
  const cellX = x + xi * cellW;
22691
24683
  const cellY = y + yi * cellH;
22692
24684
  const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
22693
- this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
24685
+ const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
22694
24686
  tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
22695
24687
  });
24688
+ if (v != null) {
24689
+ this._datum(cellRect, { label: yc, series: xc, x: xc, y: yc });
24690
+ }
22696
24691
  if (showValues && v != null) {
22697
24692
  this.renderer.text(Axis.formatValue(v), cellX + cellW / 2, cellY + cellH / 2, {
22698
24693
  textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: ColorPalette.isDark(color) ? '#fff' : '#333',
@@ -56114,6 +58109,39 @@ ${theirs}` : theirs;
56114
58109
  const back = this.renderer.text('◂ Table', 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle' });
56115
58110
  back?.addEventListener?.('click', () => this._drawResultTable(view, options));
56116
58111
  if (back?.style) back.style.cursor = 'pointer';
58112
+ const choices = view.chartChoices();
58113
+ if (choices.length > 1) {
58114
+ const pick = this.renderer.text(`${choice.label} ▾`, 68, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
58115
+ const show = options.showMenu ?? ((x, y, items) => PopupMenu.show(x, y, items));
58116
+ pick?.addEventListener?.('click', (e) => show(e.clientX, e.clientY, choices.map(c => ({
58117
+ label: `${c.label}${c.key === choice.key ? ' ✓' : ''}`,
58118
+ action: () => this._drawResultChart(view, c, options),
58119
+ }))));
58120
+ if (pick?.style) pick.style.cursor = 'pointer';
58121
+ }
58122
+ }
58123
+
58124
+ /**
58125
+ * Mounts an interactive `AskBar` (question input, configurable next-word prediction in a
58126
+ * scrollable list, semantic sense/reference/relation badges, and sample query pills) into
58127
+ * `container` (or links `resultEngine: this` when called as `engine.askBar(container, options)`).
58128
+ * @param {HTMLElement|string|object} containerOrOptions host container element/id, or options when mounting into `this.container`
58129
+ * @param {object} [options] `AskBar` / `AskView` options (`suggest`, `measures`, `dimensions`, `members`, `sampleQuestions`, `visibleCount`, `predictChoices`, `onAsk`, etc.)
58130
+ * @returns {AskBar}
58131
+ */
58132
+ askBar(containerOrOptions, options = {}) {
58133
+ const isPlainOptions = containerOrOptions
58134
+ && typeof containerOrOptions === 'object'
58135
+ && !containerOrOptions.nodeType
58136
+ && typeof containerOrOptions.appendChild !== 'function';
58137
+ const container = isPlainOptions ? this.container : containerOrOptions;
58138
+ const opts = isPlainOptions ? containerOrOptions : options;
58139
+ return new AskBar(container, { resultEngine: this, ...opts });
58140
+ }
58141
+
58142
+ /** Static helper: `RenderEngine.askBar(container, options)` constructs and mounts an `AskBar`. */
58143
+ static askBar(container, options = {}) {
58144
+ return new AskBar(container, options);
56117
58145
  }
56118
58146
 
56119
58147
  /**
@@ -56164,7 +58192,8 @@ ${theirs}` : theirs;
56164
58192
  pivotChart(view, options = {}) {
56165
58193
  if (!this.renderer) return null;
56166
58194
  const chart = new PivotChart(view, options);
56167
- chart.detach = view.listen(() => this._drawPivotChart(chart, options));
58195
+ chart.redraw = () => this._drawPivotChart(chart, options);
58196
+ chart.detach = view.listen(chart.redraw);
56168
58197
  this._drawPivotChart(chart, options);
56169
58198
  return chart;
56170
58199
  }
@@ -56175,15 +58204,19 @@ ${theirs}` : theirs;
56175
58204
  if (choice === null) {
56176
58205
  this.renderer.clear();
56177
58206
  this.renderer.text('No chart fits what the grid shows', 8, 14, { fill: '#666', fontSize: '12px', verticalAlign: 'middle' });
58207
+ options.onDraw?.(null, chart);
56178
58208
  return;
56179
58209
  }
56180
58210
  // No title unless one is given: the grid beside it says what is charted, and the link below
56181
58211
  // names the chart (with the chooser's reason as its tooltip).
56182
58212
  this.renderChartType(choice.key, choice.data, {
58213
+ ...choice.options,
56183
58214
  title: options.title,
56184
58215
  legend: true, // a series per column member: which colour is which must be said
56185
58216
  onDatumClick: (datum) => chart.click(datum),
58217
+ onNodeClick: (node) => chart.click({ label: node.label }),
56186
58218
  });
58219
+ options.onDraw?.(choice, chart);
56187
58220
  // Which chart, and the menu to change it, drawn over the chart as resultView's "◂ Table" is.
56188
58221
  if (!this.renderer.supportsSceneGraph) return;
56189
58222
  const pick = this.renderer.text(`${choice.label} ▾`, 8, 14, { fill: '#2b5fd9', fontSize: '12px', verticalAlign: 'middle', tooltip: choice.reason });
@@ -56201,6 +58234,57 @@ ${theirs}` : theirs;
56201
58234
  this.fitToContainer();
56202
58235
  }
56203
58236
 
58237
+ /**
58238
+ * Renders an icicle chart — rectangular hierarchical partition chart (top-down bands by depth,
58239
+ * width proportional to value within each parent's span).
58240
+ * @param {Array<{label: string, value?: number, children?: Array}>} data
58241
+ * @param {object} [options]
58242
+ */
58243
+ icicleChart(data, options = {}) {
58244
+ if (!this.renderer) return;
58245
+ this._chart3D = false;
58246
+ this._chartState = { type: 'icicle', data, options };
58247
+ this._renderChart(new IcicleChart(this.renderer, data, options));
58248
+ }
58249
+
58250
+ /**
58251
+ * Renders a Pareto chart — categories sorted descending with a cumulative percentage line (0–100%)
58252
+ * on a right-hand axis and an 80% cutoff reference line.
58253
+ * @param {Array<{label: string, value: number}>} data
58254
+ * @param {object} [options]
58255
+ */
58256
+ paretoChart(data, options = {}) {
58257
+ if (!this.renderer) return;
58258
+ this._chart3D = false;
58259
+ this._chartState = { type: 'pareto', data, options };
58260
+ this._renderChart(new ParetoChart(this.renderer, data, options));
58261
+ }
58262
+
58263
+ /**
58264
+ * Renders a lollipop chart — stem-and-dot alternative to bars for 1D or multi-series comparisons.
58265
+ * @param {Array<{label: string, value: number}>|Array<{name: string, values: Array<{label: string, value: number}>}>} data
58266
+ * @param {object} [options]
58267
+ */
58268
+ lollipopChart(data, options = {}) {
58269
+ if (!this.renderer) return;
58270
+ this._chart3D = false;
58271
+ this._chartState = { type: 'lollipop', data, options };
58272
+ this._renderChart(new LollipopChart(this.renderer, data, options));
58273
+ }
58274
+
58275
+ /**
58276
+ * Renders a Marimekko (mosaic) chart — variable-width 100%-stacked columns where column width is
58277
+ * proportional to each category's total and segment height is each series' share within that category.
58278
+ * @param {Array<{name: string, values: Array<{label: string, value: number}>}>|Array<{label: string, values: number[]}>} data
58279
+ * @param {object} [options]
58280
+ */
58281
+ marimekkoChart(data, options = {}) {
58282
+ if (!this.renderer) return;
58283
+ this._chart3D = false;
58284
+ this._chartState = { type: 'marimekko', data, options };
58285
+ this._renderChart(new MarimekkoChart(this.renderer, data, options));
58286
+ }
58287
+
56204
58288
  /**
56205
58289
  * Renders a waterfall chart — running-total bars, each floating from the
56206
58290
  * previous cumulative value to the new one (a "delta" bar), except
@@ -61007,6 +63091,8 @@ th { color: #555; font-weight: 500; }
61007
63091
  exports.ArchimateView = ArchimateView;
61008
63092
  exports.Arrangement = Arrangement;
61009
63093
  exports.ArrowMarker = ArrowMarker;
63094
+ exports.AskBar = AskBar;
63095
+ exports.AskView = AskView;
61010
63096
  exports.AssociationKind = AssociationKind;
61011
63097
  exports.AttributedLevel = AttributedLevel;
61012
63098
  exports.AzureDevOpsApi = AzureDevOpsApi;
@@ -61145,6 +63231,7 @@ th { color: #555; font-weight: 500; }
61145
63231
  exports.ICON_DEFS = ICON_DEFS;
61146
63232
  exports.ITElementType = ITElementType;
61147
63233
  exports.ITIconNotation = ITIconNotation;
63234
+ exports.IcicleChart = IcicleChart;
61148
63235
  exports.IconSet = IconSet;
61149
63236
  exports.ImageSelector = ImageSelector;
61150
63237
  exports.ItemGroup = ItemGroup;
@@ -61162,6 +63249,7 @@ th { color: #555; font-weight: 500; }
61162
63249
  exports.LocalJsonStore = LocalJsonStore;
61163
63250
  exports.LockCommand = LockCommand;
61164
63251
  exports.LogicalLevel = LogicalLevel;
63252
+ exports.LollipopChart = LollipopChart;
61165
63253
  exports.LowerCommand = LowerCommand;
61166
63254
  exports.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS;
61167
63255
  exports.MIDNIGHT_THEME = MIDNIGHT_THEME;
@@ -61169,6 +63257,7 @@ th { color: #555; font-weight: 500; }
61169
63257
  exports.MIN_ROW_SPAN = MIN_ROW_SPAN;
61170
63258
  exports.MODEL_ELEMENT_MIME = MODEL_ELEMENT_MIME;
61171
63259
  exports.ManualInputTemplate = ManualInputTemplate;
63260
+ exports.MarimekkoChart = MarimekkoChart;
61172
63261
  exports.Mat4 = Mat4;
61173
63262
  exports.MeasuredCategory = MeasuredCategory;
61174
63263
  exports.MenuBar = MenuBar;
@@ -61198,6 +63287,7 @@ th { color: #555; font-weight: 500; }
61198
63287
  exports.PackageTemplate = PackageTemplate;
61199
63288
  exports.Palette = Palette;
61200
63289
  exports.ParallelogramTemplate = ParallelogramTemplate;
63290
+ exports.ParetoChart = ParetoChart;
61201
63291
  exports.Part = Part;
61202
63292
  exports.PartFactory = PartFactory;
61203
63293
  exports.PartNode = PartNode;