@peter.prib/renderengine 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +454 -296
  2. package/dist/renderengine-workspace.umd.js +4122 -1167
  3. package/dist/renderengine.umd.js +4103 -1164
  4. package/documentation/REFERENCE.md +96 -6
  5. package/package.json +8 -3
  6. package/src/chartCatalog.js +50 -3
  7. package/src/charting/Axis.js +47 -5
  8. package/src/charting/Chart3D.js +4 -2
  9. package/src/charting/DiagramEditor.js +239 -34
  10. package/src/charting/Hierarchy.js +28 -0
  11. package/src/charting/ModelFileFormat.js +47 -0
  12. package/src/charting/archimate/ArchimateCanvasEditor.js +1108 -1012
  13. package/src/charting/archimate/deleteFromModel.js +57 -0
  14. package/src/charting/archimate/elementMenuItems.js +3 -1
  15. package/src/charting/archimate/heatMaps.js +169 -0
  16. package/src/charting/archimate/index.js +1 -0
  17. package/src/charting/ask/AskBar.js +388 -0
  18. package/src/charting/ask/AskView.js +400 -0
  19. package/src/charting/charts/AreaChart.js +20 -3
  20. package/src/charting/charts/BarChart.js +5 -5
  21. package/src/charting/charts/BarChart3D.js +2 -2
  22. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  23. package/src/charting/charts/BarChartBase.js +21 -3
  24. package/src/charting/charts/BoxPlotChart.js +2 -2
  25. package/src/charting/charts/BubbleChart.js +4 -1
  26. package/src/charting/charts/ChordDiagram.js +4 -4
  27. package/src/charting/charts/DensityChart.js +1 -1
  28. package/src/charting/charts/FunnelChart.js +1 -1
  29. package/src/charting/charts/GroupedBarChart.js +7 -9
  30. package/src/charting/charts/HeatmapChart.js +4 -1
  31. package/src/charting/charts/HistogramChart.js +2 -2
  32. package/src/charting/charts/IcicleChart.js +47 -0
  33. package/src/charting/charts/LollipopChart.js +104 -0
  34. package/src/charting/charts/MarimekkoChart.js +75 -0
  35. package/src/charting/charts/OutlineChart.js +10 -8
  36. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  37. package/src/charting/charts/ParetoChart.js +92 -0
  38. package/src/charting/charts/PipeChart.js +5 -2
  39. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  40. package/src/charting/charts/RadarChart.js +5 -4
  41. package/src/charting/charts/RadialBarChart.js +2 -2
  42. package/src/charting/charts/SankeyDiagram.js +4 -4
  43. package/src/charting/charts/ScatterChart.js +6 -3
  44. package/src/charting/charts/StackedBarChart.js +82 -11
  45. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -2
  46. package/src/charting/charts/StreamGraphChart.js +8 -0
  47. package/src/charting/charts/SunburstChart.js +2 -2
  48. package/src/charting/charts/TableChart.js +18 -2
  49. package/src/charting/charts/TreemapChart.js +4 -4
  50. package/src/charting/charts/ViolinChart.js +2 -2
  51. package/src/charting/charts/WaterfallChart.js +2 -2
  52. package/src/charting/charts/WordCloudChart.js +2 -2
  53. package/src/charting/diagrams/ArchimateModel.js +33 -0
  54. package/src/charting/diagrams/DeleteFromModelCommand.js +81 -0
  55. package/src/charting/diagrams/EdgeGeometry.js +16 -0
  56. package/src/charting/edgeStyle/EdgeStyleSchema.js +6 -2
  57. package/src/charting/pivot/PivotChart.js +205 -13
  58. package/src/charting/pivot/PivotGridChart.js +1 -1
  59. package/src/charting/pivot/PivotView.js +555 -23
  60. package/src/charting/table/TableView.js +10 -1
  61. package/src/charting/table/TableViewChart.js +1 -1
  62. package/src/charting/templates/Template.js +10 -0
  63. package/src/exportImage.js +45 -18
  64. package/src/index.js +115 -6
  65. package/src/workspace/parts/ArchimateCanvasPart.js +3 -0
  66. package/src/workspace/parts/DiagramEditorPart.js +2 -0
  67. package/src/workspace/parts/ModelHosted.js +15 -4
@@ -52,9 +52,12 @@ export class HeatmapChart extends Chart {
52
52
  const cellX = x + xi * cellW;
53
53
  const cellY = y + yi * cellH;
54
54
  const color = v == null ? '#eee' : ColorPalette.scaleColor(colorScale, vMin, vMax, v);
55
- this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
55
+ const cellRect = this.renderer.rect(cellX, cellY, Math.max(1, cellW - 1), Math.max(1, cellH - 1), color, null, {
56
56
  tooltip: `${xc} / ${yc}: ${v ?? 'n/a'}`,
57
57
  });
58
+ if (v != null) {
59
+ this._datum(cellRect, { label: yc, series: xc, x: xc, y: yc });
60
+ }
58
61
  if (showValues && v != null) {
59
62
  this.renderer.text(Axis.formatValue(v), cellX + cellW / 2, cellY + cellH / 2, {
60
63
  textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: ColorPalette.isDark(color) ? '#fff' : '#333',
@@ -34,9 +34,9 @@ export class HistogramChart extends Chart {
34
34
  const barY = y + height - barHeight;
35
35
  // No gap between bars (unlike barChart's categorical gaps) — bins
36
36
  // are contiguous numeric ranges, not independent categories.
37
- this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
37
+ this._datum(this.renderer.rect(barX, barY, Math.max(1, barWidth - 1), barHeight, barColor, null, {
38
38
  tooltip: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}: ${bin.count}`,
39
- });
39
+ }), { label: `${Axis.formatValue(bin.from)}–${Axis.formatValue(bin.to)}` });
40
40
  if (i % Math.ceil(bins.length / 10 || 1) === 0) {
41
41
  this.renderer.text(Axis.formatValue(bin.from), barX, y + height + 16, { textAnchor: 'start', fontSize: '9px' });
42
42
  }
@@ -0,0 +1,47 @@
1
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
2
+ import { Hierarchy } from '../Hierarchy.js';
3
+ import { HierarchyChart } from './HierarchyChart.js';
4
+ import { TableChart } from './TableChart.js';
5
+
6
+ /**
7
+ * IcicleChart — rectangular hierarchical partition chart (top-down flame/icicle layout).
8
+ * Each depth level forms a horizontal band and each node's width is proportional to its value
9
+ * within its parent's span.
10
+ */
11
+ export class IcicleChart extends HierarchyChart {
12
+ static chartType = 'icicle';
13
+
14
+ _render() {
15
+ const data = this.data ?? [];
16
+ const options = this.options;
17
+ const box = ChartBox.fromRenderer(this.renderer);
18
+ const { x = box.x, y = box.y, width = box.width, height = box.height, title } = options;
19
+ const plotBox = { x, y, width, height };
20
+
21
+ new ChartLabels({ title }).draw(this.renderer, plotBox);
22
+ if (data.length === 0) return;
23
+
24
+ const maxDepth = Math.max(1, ...data.map(n => Hierarchy.depth(n)));
25
+ const bandHeight = height / maxDepth;
26
+ const layout = Hierarchy.icicleLayout(data, x, y, width, bandHeight);
27
+ const textAnchor = this.renderer.centerTextAnchor;
28
+ const measure = (s) => this.renderer.measureText?.(s, { fontSize: '10px' }) ?? s.length * 10 * 0.55;
29
+
30
+ layout.forEach(entry => {
31
+ const { node, x: rx, y: ry, width: rw, height: rh, depth, topAncestor } = entry;
32
+ const color = this._colorForNode(topAncestor, depth);
33
+ const textColor = this._textColorForNode(topAncestor, depth);
34
+
35
+ this._datum(this.renderer.rect(rx, ry, Math.max(0, rw), Math.max(0, rh), color, { color: '#fff', width: 1 }, {
36
+ tooltip: `${node.label}: ${this._formatNodeValue(node)}`,
37
+ }), { label: node.label });
38
+
39
+ if (rw > 28 && rh > 14) {
40
+ const fitted = TableChart.fit(String(node.label), rw - 6, measure);
41
+ this.renderer.text(fitted, rx + rw / 2, ry + rh / 2, {
42
+ textAnchor, verticalAlign: 'middle', fontSize: '10px', fill: textColor, 'pointer-events': 'none',
43
+ });
44
+ }
45
+ });
46
+ }
47
+ }
@@ -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
  }