@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
@@ -0,0 +1,104 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Legend } from '../Legend.js';
3
+ import { BarChartBase } from './BarChartBase.js';
4
+
5
+ /**
6
+ * LollipopChart — stem-and-dot alternative to a bar chart for high-cardinality or multi-series
7
+ * categorical comparisons with minimal ink density.
8
+ *
9
+ * Accepts either:
10
+ * - 1D `Array<{label: string, value: number}>`, or
11
+ * - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>`.
12
+ */
13
+ export class LollipopChart extends BarChartBase {
14
+ static chartType = 'lollipop';
15
+
16
+ static _normalize(data) {
17
+ if (!Array.isArray(data) || data.length === 0) return { isMulti: false, series: [], labels: [] };
18
+ if (Array.isArray(data[0]?.values)) {
19
+ const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
20
+ return { isMulti: true, series: data, labels };
21
+ }
22
+ return {
23
+ isMulti: false,
24
+ series: [{ name: 'default', values: data }],
25
+ labels: data.map(d => d.label),
26
+ };
27
+ }
28
+
29
+ _setup() {
30
+ this._norm = LollipopChart._normalize(this.data);
31
+ super._setup();
32
+ const showLegend = this.options.legend ?? this._norm.isMulti;
33
+ if (showLegend && this._norm.isMulti && this.options.width === undefined) {
34
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
35
+ }
36
+ }
37
+
38
+ _legendOptions() {
39
+ return {
40
+ items: this._norm.series.map((s, i) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: i })),
41
+ itemHeight: 20, markerSize: 10,
42
+ };
43
+ }
44
+
45
+ _computeMaxValue() {
46
+ const allValues = this._norm.series.flatMap(s => s.values.map(v => v.value)).filter(v => typeof v === 'number');
47
+ const rawMax = Math.max(0, ...allValues) || 1;
48
+ const rawMin = Math.min(0, ...allValues);
49
+ return this._drawYAxis(rawMax, rawMin);
50
+ }
51
+
52
+ _renderBars() {
53
+ const { isMulti, series, labels } = this._norm;
54
+ if (labels.length === 0) return;
55
+ const { x, y, width, height } = this._plotBox;
56
+ const {
57
+ color: singleColor = '#2563eb',
58
+ colors,
59
+ radius = 5,
60
+ stemWidth = 2,
61
+ } = this.options;
62
+ const { bandWidth, margin } = this._bandScale(labels.length);
63
+ const labelStep = Axis.categoryLabelStep(labels, width);
64
+ const textAnchor = this.renderer.centerTextAnchor;
65
+ const zeroY = this._barSpan(0).end;
66
+ const seriesCount = series.length;
67
+
68
+ labels.forEach((label, i) => {
69
+ const bandX = x + i * (bandWidth + margin);
70
+ series.forEach((s, sIdx) => {
71
+ const pt = s.values.find(v => v.label === label);
72
+ if (!pt || typeof pt.value !== 'number') return;
73
+ const cx = seriesCount === 1
74
+ ? bandX + bandWidth / 2
75
+ : bandX + ((sIdx + 0.5) / seriesCount) * bandWidth;
76
+ const span = this._barSpan(pt.value);
77
+ const col = isMulti || colors ? this._palette.colorAt(sIdx) : singleColor;
78
+ const tip = isMulti ? `${s.name} - ${label}: ${pt.value}` : `${label}: ${pt.value}`;
79
+
80
+ this.renderer.path(`M${cx},${zeroY} L${cx},${span.end}`, 'none', { color: col, width: stemWidth });
81
+ if (typeof this.renderer.circle === 'function') {
82
+ this._datum(this.renderer.circle(cx, span.end, radius, col, { color: '#fff', width: 1.5 }, {
83
+ tooltip: tip,
84
+ }), { label, series: isMulti ? s.name : null });
85
+ } else {
86
+ this._datum(this.renderer.rect(cx - radius, span.end - radius, radius * 2, radius * 2, col, null, {
87
+ tooltip: tip,
88
+ }), { label, series: isMulti ? s.name : null });
89
+ }
90
+ });
91
+ if (i % labelStep === 0) {
92
+ this.renderer.text(label, bandX + bandWidth / 2, y + height + 20, { textAnchor });
93
+ }
94
+ });
95
+ }
96
+
97
+ _afterBars() {
98
+ const showLegend = this.options.legend ?? this._norm.isMulti;
99
+ if (showLegend && this._norm.isMulti) {
100
+ const { x, y, width } = this._plotBox;
101
+ Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,75 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Chart } from '../Chart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+ import { ColorPalette } from '../ColorPalette.js';
5
+ import { Legend } from '../Legend.js';
6
+ import { StackedBarChart } from './StackedBarChart.js';
7
+ import { TableChart } from './TableChart.js';
8
+
9
+ /**
10
+ * MarimekkoChart — two-dimensional 100%-stacked mosaic chart where column width is proportional to
11
+ * each category's total across series and segment height is each series' percentage share within
12
+ * that category.
13
+ */
14
+ export class MarimekkoChart extends Chart {
15
+ static chartType = 'marimekko';
16
+
17
+ _render() {
18
+ const normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
19
+ const { categories, seriesNames } = normalized;
20
+ const box = ChartBox.fromRenderer(this.renderer);
21
+ const {
22
+ x = box.x, y = box.y, width: rawWidth = box.width, height = box.height,
23
+ colors, title, xAxisLabel, yAxisLabel, legend: showLegend = true,
24
+ } = this.options;
25
+ const palette = new ColorPalette(colors);
26
+ const legendOpts = {
27
+ items: seriesNames.map((name, i) => ({ label: name, color: palette.colorAt(i), dataIndex: i })),
28
+ itemHeight: 20, markerSize: 10,
29
+ };
30
+ const width = showLegend && this.options.width === undefined && seriesNames.length > 0
31
+ ? Legend.plotWidthBeside({ x, y, width: rawWidth, height }, this.renderer.width, legendOpts)
32
+ : rawWidth;
33
+ const plotBox = { x, y, width, height };
34
+
35
+ new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
36
+ new Axis().drawYRange(this.renderer, plotBox, 0, 100);
37
+
38
+ const colTotals = categories.map(c => c.values.reduce((sum, v) => sum + Math.max(0, v ?? 0), 0));
39
+ const grandTotal = colTotals.reduce((sum, v) => sum + v, 0) || 1;
40
+ const textAnchor = this.renderer.centerTextAnchor;
41
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '11px' }) ?? s.length * 11 * 0.55;
42
+
43
+ let cursorX = x;
44
+ categories.forEach((cat, i) => {
45
+ const colTotal = colTotals[i];
46
+ if (colTotal <= 0) return;
47
+ const colW = (colTotal / grandTotal) * width;
48
+ let cursorY = y + height;
49
+
50
+ cat.values.forEach((value, j) => {
51
+ if (value == null || value <= 0) return;
52
+ const share = (value / colTotal) * 100;
53
+ const segH = (share / 100) * height;
54
+ cursorY -= segH;
55
+ const series = seriesNames[j] ?? `Series ${j + 1}`;
56
+ const color = palette.colorAt(j);
57
+ this._datum(this.renderer.rect(cursorX, cursorY, colW, segH, color, { color: '#fff', width: 1 }, {
58
+ tooltip: `${cat.label} - ${series}: ${value} (${share.toFixed(1)}%)`,
59
+ }), { label: cat.label, series });
60
+ });
61
+
62
+ if (colW >= 24) {
63
+ const labelText = TableChart.fit(String(cat.label), colW - 4, measure);
64
+ this.renderer.text(labelText, cursorX + colW / 2, y + height + 18, { textAnchor, fontSize: '11px' });
65
+ }
66
+ cursorX += colW;
67
+ });
68
+
69
+ this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
70
+
71
+ if (showLegend && seriesNames.length > 0) {
72
+ Legend.beside({ x, y, width }, this.renderer.width, legendOpts).draw(this.renderer);
73
+ }
74
+ }
75
+ }
@@ -35,15 +35,16 @@ export class OutlineChart extends HierarchyChart {
35
35
  const accentColor = this._colorForNode(topAncestor, depth);
36
36
 
37
37
  const g = this.renderer.group();
38
- g.appendChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
38
+ const addChild = (child) => g?.appendChild?.(child);
39
+ addChild(this.renderer.rect(indentX - 2, rowCenterY - 5, 10, 10, accentColor, null));
39
40
 
40
41
  let labelX = indentX + 16;
41
42
  if (hasChildren) {
42
43
  const collapsedHere = this.collapsed ? this.collapsed.has(node) : false;
43
44
  const badgeX = indentX + 26;
44
45
  const badge = this.renderer.circle(badgeX, rowCenterY, 7, '#ffffff', { color: '#888', width: 1.2 });
45
- g.appendChild(badge);
46
- g.appendChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
46
+ addChild(badge);
47
+ addChild(this.renderer.text(collapsedHere ? '+' : '−', badgeX, rowCenterY, {
47
48
  textAnchor: 'middle', verticalAlign: 'middle', fontSize: '10px', fill: '#555', 'pointer-events': 'none',
48
49
  }));
49
50
  badge.style.cursor = 'pointer';
@@ -57,22 +58,23 @@ export class OutlineChart extends HierarchyChart {
57
58
  labelX = badgeX + 16;
58
59
  }
59
60
 
60
- g.appendChild(this.renderer.text(node.label, labelX, rowCenterY, {
61
+ const labelEl = this._datum(this.renderer.text(node.label, labelX, rowCenterY, {
61
62
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '12px', fill: '#222',
62
- }));
63
+ }), { label: node.label, series: null });
64
+ addChild(labelEl);
63
65
 
64
66
  if (hasChildren) {
65
- g.appendChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
67
+ addChild(this.renderer.text(this._formatNodeValue(node), countX, rowCenterY, {
66
68
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
67
69
  }));
68
70
  } else if (Number.isFinite(node.value)) {
69
- g.appendChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
71
+ addChild(this.renderer.text(Axis.formatValue(node.value), countX, rowCenterY, {
70
72
  textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
71
73
  }));
72
74
  } else {
73
75
  const extra = Hierarchy.outlineExtraText(node, structuralKeys);
74
76
  if (extra) {
75
- g.appendChild(this.renderer.text(extra, detailX, rowCenterY, {
77
+ addChild(this.renderer.text(extra, detailX, rowCenterY, {
76
78
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '11px', fill: '#888', 'pointer-events': 'none',
77
79
  }));
78
80
  }
@@ -85,11 +85,11 @@ export class ParallelCoordinatesChart extends Chart {
85
85
  pathData += `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`;
86
86
  });
87
87
  const label = row.label || (hasCategories ? row.category : null);
88
- this.renderer.path(pathData, 'none', {
88
+ this._datum(this.renderer.path(pathData, 'none', {
89
89
  color, width: lineWidth,
90
90
  opacity: lineOpacity,
91
91
  tooltip: label ? `${label}: ${dimensions.map(d => `${d}=${this._fmt(row[d])}`).join(', ')}` : undefined,
92
- });
92
+ }), { label: label ?? String(rowIndex) });
93
93
  });
94
94
 
95
95
  if (legendVisible && hasCategories) {
@@ -0,0 +1,92 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { BarChartBase } from './BarChartBase.js';
3
+
4
+ /**
5
+ * ParetoChart — descending bar chart combined with a cumulative percentage line (0–100%)
6
+ * on a right-hand axis and an 80% cutoff reference line.
7
+ */
8
+ export class ParetoChart extends BarChartBase {
9
+ static chartType = 'pareto';
10
+
11
+ _setup() {
12
+ super._setup();
13
+ if (this.options.width === undefined) {
14
+ this._plotBox.width = Math.max(80, this._plotBox.width - 36);
15
+ }
16
+ const items = (this.data ?? []).filter(d => d && typeof d.value === 'number' && d.value >= 0);
17
+ this._sorted = this.options.sorted === false ? [...items] : [...items].sort((a, b) => b.value - a.value);
18
+ }
19
+
20
+ _computeMaxValue() {
21
+ const rawMax = Math.max(0, ...this._sorted.map(d => d.value)) || 1;
22
+ return this._drawYAxis(rawMax, 0);
23
+ }
24
+
25
+ _renderBars(maxValue) {
26
+ const items = this._sorted;
27
+ if (items.length === 0) return;
28
+ const { x, y, width, height } = this._plotBox;
29
+ const {
30
+ barColor = '#3b82f6',
31
+ lineColor = '#dc2626',
32
+ cutoff = 80,
33
+ colors,
34
+ } = this.options;
35
+ const { bandWidth, margin } = this._bandScale(items.length);
36
+ const labelStep = Axis.categoryLabelStep(items.map(d => d.label), width);
37
+ const textAnchor = this.renderer.centerTextAnchor;
38
+ const total = items.reduce((sum, d) => sum + d.value, 0) || 1;
39
+
40
+ // Right-hand percentage axis (0%..100%).
41
+ const rightX = x + width;
42
+ this.renderer.path(`M${rightX},${y} L${rightX},${y + height}`, 'none', { color: '#64748b', width: 1 });
43
+ for (const pct of [0, 20, 40, 60, 80, 100]) {
44
+ const py = y + height - (pct / 100) * height;
45
+ this.renderer.path(`M${rightX},${py} L${rightX + 4},${py}`, 'none', { color: '#64748b', width: 1 });
46
+ this.renderer.text(`${pct}%`, rightX + 8, py + 3, { textAnchor: 'start', fontSize: '10px', fill: '#475569' });
47
+ }
48
+
49
+ // Dashed 80% cutoff reference line.
50
+ if (cutoff > 0 && cutoff < 100) {
51
+ const cutY = y + height - (cutoff / 100) * height;
52
+ this.renderer.path(`M${x},${cutY} L${rightX},${cutY}`, 'none', {
53
+ color: lineColor, width: 1, dashArray: '4,4',
54
+ });
55
+ }
56
+
57
+ let running = 0;
58
+ const linePts = [];
59
+ items.forEach((d, i) => {
60
+ running += d.value;
61
+ const cumPct = (running / total) * 100;
62
+ const color = colors ? this._palette.colorAt(i) : barColor;
63
+ const bar = this._barSpan(d.value);
64
+ const barX = x + i * (bandWidth + margin);
65
+ const cx = barX + bandWidth / 2;
66
+ const py = y + height - (cumPct / 100) * height;
67
+ linePts.push({ cx, py, label: d.label, value: d.value, cumPct });
68
+
69
+ this._datum(this.renderer.rect(barX, bar.top, bandWidth, bar.height, color, null, {
70
+ bar: true,
71
+ dataIndex: i,
72
+ tooltip: `${d.label}: ${d.value} (${cumPct.toFixed(1)}% cumulative)`,
73
+ }), { label: d.label });
74
+
75
+ if (i % labelStep === 0) {
76
+ this.renderer.text(d.label, cx, y + height + 20, { textAnchor });
77
+ }
78
+ });
79
+
80
+ if (linePts.length > 0) {
81
+ const dPath = 'M' + linePts.map(p => `${p.cx},${p.py}`).join(' L');
82
+ this.renderer.path(dPath, 'none', { color: lineColor, width: 2 });
83
+ if (typeof this.renderer.circle === 'function') {
84
+ linePts.forEach(p => {
85
+ this._datum(this.renderer.circle(p.cx, p.py, 3.5, lineColor, { color: '#fff', width: 1.5 }, {
86
+ tooltip: `${p.label}: ${p.cumPct.toFixed(1)}% cumulative`,
87
+ }), { label: p.label });
88
+ });
89
+ }
90
+ }
91
+ }
92
+ }
@@ -67,8 +67,11 @@ export class PipeChart extends Chart {
67
67
  this.renderer.path(centerline, 'none', { color: palette.lighten(color, -0.25), width: 1 });
68
68
 
69
69
  pts.forEach(p => {
70
- this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
71
- { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` });
70
+ if (typeof this.renderer.circle === 'function') {
71
+ this._datum(this.renderer.circle(p.x, p.y, 2, '#fff', { color, width: 1.5 },
72
+ { tooltip: `${isMultiline ? s.name + ': ' : ''}${p.d.label}: ${p.d.value} (${metricCol}: ${p.d[metricCol] ?? 0})` }),
73
+ { label: p.d.label, series: isMultiline ? s.name : null });
74
+ }
72
75
  });
73
76
 
74
77
  if (seriesIndex === 0) {
@@ -69,15 +69,15 @@ export class PopulationPyramidChart extends Chart {
69
69
 
70
70
  // Left bar grows from center to the left.
71
71
  if (leftW > 0) {
72
- this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
72
+ this._datum(this.renderer.rect(centerX - leftW, rowY, leftW, bh, leftColor, null, {
73
73
  tooltip: `${d.label} — ${leftLabel}: ${d.left}`,
74
- });
74
+ }), { label: d.label, series: leftLabel });
75
75
  }
76
76
  // Right bar grows from center to the right.
77
77
  if (rightW > 0) {
78
- this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
78
+ this._datum(this.renderer.rect(centerX, rowY, rightW, bh, rightColor, null, {
79
79
  tooltip: `${d.label} — ${rightLabel}: ${d.right}`,
80
- });
80
+ }), { label: d.label, series: rightLabel });
81
81
  }
82
82
 
83
83
  // Row label centered on the axis.
@@ -21,7 +21,7 @@ export class RadarChart extends Chart {
21
21
  } = options;
22
22
  const isMulti = Array.isArray(data[0]?.values);
23
23
  const series = isMulti ? data : [{ name: 'default', values: data }];
24
- const axes = series[0].values.map(v => v.axis);
24
+ const axes = series[0].values.map(v => v.axis ?? v.label);
25
25
  const n = axes.length;
26
26
  const palette = new ColorPalette(colors);
27
27
  const axisMax = axes.map((_, ai) => Math.max(...series.map(s => s.values[ai]?.value ?? 0)) || 1);
@@ -59,9 +59,10 @@ export class RadarChart extends Chart {
59
59
  const { r, g, b } = ColorPalette.hexToRgb(color);
60
60
  this.renderer.path(path, `rgba(${r},${g},${b},0.25)`, { color, width: 2 }, { tooltip: s.name });
61
61
  pts.forEach((p, i) => {
62
- this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
63
- tooltip: `${s.name} — ${s.values[i].axis}: ${s.values[i].value}`,
64
- });
62
+ const axisLabel = s.values[i].axis ?? s.values[i].label;
63
+ this._datum(this.renderer.circle(p.px, p.py, 3, color, { color: '#fff', width: 1 }, {
64
+ tooltip: `${s.name} — ${axisLabel}: ${s.values[i].value}`,
65
+ }), { label: axisLabel, series: isMulti ? s.name : null });
65
66
  });
66
67
  });
67
68
 
@@ -56,9 +56,9 @@ export class RadialBarChart extends Chart {
56
56
  const rOuter = innerRadius + barLen;
57
57
  const color = d.color ?? palette.colorAt(i);
58
58
  const path = ShapePaths.donutSlicePath(cx, cy, rOuter, innerRadius, a0, a1);
59
- this.renderer.path(path, color, { color: '#fff', width: 1 }, {
59
+ this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
60
60
  tooltip: `${d.label}: ${d.value}`,
61
- });
61
+ }), { label: d.label });
62
62
 
63
63
  // Category label around the outer track.
64
64
  const midAngle = (a0 + a1) / 2;
@@ -113,18 +113,18 @@ export class SankeyDiagram extends Chart {
113
113
  const linkH = l.value * scale;
114
114
  const path = this._linkPath(n.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
115
115
  const { r, g, b: bl } = palette.hexToRgb(n.color);
116
- this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
116
+ this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
117
117
  tooltip: `${n.label} → ${t.label}: ${l.value}`,
118
- });
118
+ }), { label: n.label, series: t.label });
119
119
  outY += linkH;
120
120
  });
121
121
  });
122
122
 
123
123
  // Draw nodes.
124
124
  nodes.forEach(n => {
125
- this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
125
+ this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
126
126
  tooltip: `${n.label}: ${n.value}`,
127
- });
127
+ }), { label: n.label });
128
128
  this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
129
129
  textAnchor: 'middle', fontSize: '10px', fill: '#333',
130
130
  });
@@ -68,8 +68,8 @@ export class ScatterChart extends Chart {
68
68
  ({ from, to } = referenceLine);
69
69
  }
70
70
  if (from && to) {
71
- const line = this.renderer.path(`M${toX(from.x)},${toY(from.y)} L${toX(to.x)},${toY(to.y)}`, 'none', { color: '#999', width: 1 });
72
- line.setAttribute('stroke-dasharray', '4 3');
71
+ 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] });
72
+ line?.setAttribute?.('stroke-dasharray', '4 3');
73
73
  }
74
74
  }
75
75
 
@@ -77,7 +77,10 @@ export class ScatterChart extends Chart {
77
77
  const cx = toX(d.x);
78
78
  const cy = toY(d.y);
79
79
  const tooltip = d.label ? `${d.label}: (${d.x}, ${d.y})` : `(${d.x}, ${d.y})`;
80
- this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip });
80
+ const series = catField ? ChartData.readColorCol(d, catField) : null;
81
+ this._datum(this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip }), {
82
+ label: d.label ?? String(d.x), series: series != null ? String(series) : null,
83
+ });
81
84
 
82
85
  const xLow = d.errorXMinus != null ? toX(d.x - d.errorXMinus)
83
86
  : d.errorX != null ? toX(d.x - d.errorX) : null;
@@ -1,35 +1,106 @@
1
1
  import { Axis } from '../Axis.js';
2
+ import { Legend } from '../Legend.js';
2
3
  import { BarChartBase } from './BarChartBase.js';
4
+ import { TableChart } from './TableChart.js';
3
5
 
4
6
  /**
5
7
  * StackedBarChart — vertical stacked bar chart.
8
+ *
9
+ * Accepts either:
10
+ * - Multi-series `Array<{name: string, values: Array<{label: string, value: number}>}>` (the same
11
+ * shape as `GroupedBarChart`, `LineChart` and `StackedAreaChart`), or
12
+ * - Flat per-category stacks `Array<{label: string, values: number[]}>` with optional
13
+ * `options.seriesLabels`.
14
+ *
15
+ * Draws a numeric Y axis (`_drawYAxis`), optional series legend (`legend: true`), and records each
16
+ * segment with `this._datum()` so `onDatumClick` / `PivotChart` drilling works on every segment.
6
17
  */
7
18
  export class StackedBarChart extends BarChartBase {
8
19
  static chartType = 'stackedBar';
9
20
 
21
+ static _normalize(data, seriesLabels = null) {
22
+ if (!Array.isArray(data) || data.length === 0) return { categories: [], seriesNames: [] };
23
+ if (Array.isArray(data[0]?.values) && typeof data[0].values[0] === 'object' && data[0].values[0] !== null) {
24
+ const labels = [...new Set(data.flatMap(s => s.values.map(v => v.label)))];
25
+ const maps = data.map(s => new Map(s.values.map(v => [v.label, v.value])));
26
+ return {
27
+ seriesNames: data.map((s, i) => s.name ?? seriesLabels?.[i] ?? `Series ${i + 1}`),
28
+ categories: labels.map(label => ({ label, values: maps.map(m => m.get(label) ?? 0) })),
29
+ };
30
+ }
31
+ const maxLen = Math.max(0, ...data.map(d => d.values?.length ?? 0));
32
+ return {
33
+ seriesNames: Array.from({ length: maxLen }, (_, i) => seriesLabels?.[i] ?? `Series ${i + 1}`),
34
+ categories: data.map(d => ({ label: d.label, values: (d.values ?? []).map(v => v ?? 0) })),
35
+ };
36
+ }
37
+
38
+ _setup() {
39
+ this._normalized = StackedBarChart._normalize(this.data, this.options.seriesLabels);
40
+ super._setup();
41
+ if (this.options.legend && this.options.width === undefined && this._normalized.seriesNames.length > 0) {
42
+ this._plotBox.width = Legend.plotWidthBeside(this._plotBox, this.renderer.width, this._legendOptions());
43
+ }
44
+ }
45
+
46
+ _legendOptions() {
47
+ return {
48
+ items: this._normalized.seriesNames.map((name, i) => ({ label: name, color: this._palette.colorAt(i), dataIndex: i })),
49
+ itemHeight: 20, markerSize: 10,
50
+ };
51
+ }
52
+
53
+ _isNormalized() {
54
+ return Boolean(this.options.normalize || this.options.stackMode === 'percent');
55
+ }
56
+
10
57
  _computeMaxValue() {
11
- const data = this.data;
12
- return Math.max(...data.map(d => d.values.reduce((sum, val) => sum + val, 0)));
58
+ if (this._isNormalized()) {
59
+ return this._drawYAxis(100);
60
+ }
61
+ const { categories } = this._normalized;
62
+ const rawMax = Math.max(0, ...categories.map(d => d.values.reduce((sum, val) => sum + Math.max(0, val), 0))) || 1;
63
+ return this._drawYAxis(rawMax);
13
64
  }
14
65
 
15
66
  _renderBars(maxStackValue) {
16
- const data = this.data;
67
+ const { categories, seriesNames } = this._normalized;
68
+ if (categories.length === 0) return;
17
69
  const { x, y, width, height } = this._plotBox;
18
- const labelStep = Axis.categoryLabelStep(data.map(d => d.label), width);
19
- const { bandWidth, margin } = this._bandScale(data.length);
70
+ const normalize = this._isNormalized();
71
+ const labels = categories.map(d => d.label);
72
+ const { bandWidth, margin } = this._bandScale(categories.length);
20
73
  const textAnchor = this.renderer.centerTextAnchor;
74
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '12px' }) ?? s.length * 12 * 0.55;
75
+ const room = (bandWidth + margin) - 4;
76
+ const cut = room >= measure('x'.repeat(6));
77
+ const labelStep = cut ? 1 : Axis.categoryLabelStep(labels, width);
21
78
 
22
- data.forEach((d, i) => {
79
+ categories.forEach((d, i) => {
23
80
  let currentY = y + height;
24
81
  const barX = x + i * (bandWidth + margin);
82
+ const colTotal = d.values.reduce((sum, val) => sum + Math.max(0, val ?? 0), 0);
25
83
  d.values.forEach((value, j) => {
26
- const barHeight = (value / maxStackValue) * height;
84
+ if (value == null || value <= 0) return;
85
+ const plotted = normalize ? (colTotal > 0 ? (value / colTotal) * 100 : 0) : value;
86
+ const barHeight = (plotted / maxStackValue) * height;
27
87
  currentY -= barHeight;
28
- this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
29
- tooltip: `${d.label} - Series ${j + 1}: ${value}`,
30
- });
88
+ const series = seriesNames[j] ?? `Series ${j + 1}`;
89
+ const tipVal = normalize ? `${plotted.toFixed(1)}% (${value})` : `${value}`;
90
+ this._datum(this.renderer.rect(barX, currentY, bandWidth, barHeight, this._palette.colorAt(j), null, {
91
+ tooltip: `${d.label} - ${series}: ${tipVal}`,
92
+ }), { label: d.label, series });
31
93
  });
32
- if (i % labelStep === 0) this.renderer.text(d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
94
+ if (i % labelStep === 0) {
95
+ this.renderer.text(cut ? TableChart.fit(String(d.label), room, measure) : d.label, barX + bandWidth / 2, y + height + 20, { textAnchor });
96
+ }
33
97
  });
34
98
  }
99
+
100
+ _afterBars() {
101
+ const { x, y, width } = this._plotBox;
102
+ if (this.options.legend && this._normalized.seriesNames.length > 0) {
103
+ Legend.beside({ x, y, width }, this.renderer.width, this._legendOptions()).draw(this.renderer);
104
+ }
105
+ }
35
106
  }
@@ -43,13 +43,16 @@ export class StackedBarChart3DOrbit extends Chart3D {
43
43
  const segHeight = bar.d.values[j] / stackMax;
44
44
  const y0 = yCursor, y1 = yCursor + segHeight;
45
45
  yCursor = y1;
46
- this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen);
46
+ const series = options.seriesLabels?.[j] ?? `Series ${j + 1}`;
47
+ this.drawBox(this.renderer, bar.x0, bar.x1, y0, y1, bar.z0, bar.z1, palette.colorAt(j), toScreen, {
48
+ label: bar.d.x, series, x: bar.d.x, z: bar.d.z,
49
+ });
47
50
  }
48
51
  }
49
52
 
50
53
  const maxSegments = Math.max(...data.map(d => d.values.length));
51
54
  new Legend({
52
- items: Array.from({ length: maxSegments }, (_, j) => ({ label: `Series ${j + 1}`, color: palette.colorAt(j) })),
55
+ items: Array.from({ length: maxSegments }, (_, j) => ({ label: options.seriesLabels?.[j] ?? `Series ${j + 1}`, color: palette.colorAt(j) })),
53
56
  x: bx + width - 100, y: by + 5, itemHeight: 18, fontSize: '11px', marker: 'circle',
54
57
  }).draw(this.renderer);
55
58
  }
@@ -108,6 +108,14 @@ export class StreamGraphChart extends Chart {
108
108
  const path = StreamGraphChart.areaPath(topPts, bottomPts, smooth, tension);
109
109
  const { r, g, b: bl } = palette.hexToRgb(color);
110
110
  this.renderer.path(path, `rgba(${r},${g},${bl},0.75)`, { color: color, width: 1 }, { tooltip: s.name });
111
+ if (typeof this.renderer.circle === 'function') {
112
+ topPts.forEach((p, i) => {
113
+ const d = s.values[i];
114
+ this._datum(this.renderer.circle(p.x, p.y, 3, color, { color: '#fff', width: 1 }, {
115
+ tooltip: `${isMultiline ? s.name + ': ' : ''}${d.label}: ${d.value}`,
116
+ }), { label: d.label, series: isMultiline ? s.name : null });
117
+ });
118
+ }
111
119
  });
112
120
 
113
121
  // X-axis labels.
@@ -37,9 +37,9 @@ export class SunburstChart extends HierarchyChart {
37
37
  const textColor = this._textColorForNode(topAncestor, depth);
38
38
 
39
39
  const path = ShapePaths.donutSlicePath(ecx, ecy, rOuter, rInner, startAngle, endAngle);
40
- this.renderer.path(path, color, { color: '#fff', width: 1 }, {
40
+ this._datum(this.renderer.path(path, color, { color: '#fff', width: 1 }, {
41
41
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
42
- });
42
+ }), { label: node.label });
43
43
 
44
44
  if (endAngle - startAngle > 0.35) {
45
45
  const midAngle = (startAngle + endAngle) / 2;
@@ -27,9 +27,9 @@ export class TreemapChart extends HierarchyChart {
27
27
  const textColor = this._textColorForNode(topAncestor, depth);
28
28
 
29
29
  if (isLeaf) {
30
- this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
30
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
31
31
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
32
- });
32
+ }), { label: node.label });
33
33
  if (rw > 30 && rh > 14) {
34
34
  this.renderer.text(node.label, rx + rw / 2, ry + rh / 2, {
35
35
  textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
@@ -38,9 +38,9 @@ export class TreemapChart extends HierarchyChart {
38
38
  } else {
39
39
  const headerH = Math.min(rh, TreemapChart.HEADER_H);
40
40
  this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), 'rgba(0,0,0,0.03)', { color: '#ddd', width: 1 });
41
- this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
41
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), headerH, color, null, {
42
42
  tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
43
- });
43
+ }), { label: node.label });
44
44
  if (rw > 30) {
45
45
  this.renderer.text(node.label, rx + 4, ry + headerH / 2, {
46
46
  textAnchor: 'start', verticalAlign: 'middle', fontSize: '9px', fontWeight: 'bold', fill: textColor, 'pointer-events': 'none',