@peter.prib/renderengine 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +45 -19
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49391 -39748
  4. package/dist/renderengine.umd.js +25366 -15650
  5. package/documentation/REFERENCE.md +38 -0
  6. package/package.json +6 -1
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +219 -0
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +3 -0
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +114 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/charts/AlluvialDiagram.js +311 -0
  34. package/src/charting/charts/AreaChart.js +52 -15
  35. package/src/charting/charts/BarChart.js +38 -3
  36. package/src/charting/charts/BarChart3D.js +5 -3
  37. package/src/charting/charts/BarChartBase.js +26 -6
  38. package/src/charting/charts/BeeswarmChart.js +164 -0
  39. package/src/charting/charts/BoxPlotChart.js +23 -1
  40. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  41. package/src/charting/charts/CandlestickChart.js +172 -4
  42. package/src/charting/charts/ChordDiagram.js +105 -2
  43. package/src/charting/charts/CirclePackChart.js +81 -0
  44. package/src/charting/charts/ComboChart.js +233 -0
  45. package/src/charting/charts/ContourChart.js +310 -0
  46. package/src/charting/charts/DonutChart3D.js +13 -0
  47. package/src/charting/charts/DumbbellChart.js +217 -0
  48. package/src/charting/charts/EcdfChart.js +150 -0
  49. package/src/charting/charts/FunnelChart.js +4 -1
  50. package/src/charting/charts/GeoMapChart.js +132 -11
  51. package/src/charting/charts/GroupedBarChart.js +44 -3
  52. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  53. package/src/charting/charts/HeatmapChart.js +2 -2
  54. package/src/charting/charts/HexbinChart.js +137 -0
  55. package/src/charting/charts/HierarchyChart.js +77 -3
  56. package/src/charting/charts/HorizonChart.js +105 -0
  57. package/src/charting/charts/KagiChart.js +167 -0
  58. package/src/charting/charts/LineChart.js +74 -18
  59. package/src/charting/charts/OhlcChart.js +79 -0
  60. package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
  61. package/src/charting/charts/PieChart.js +195 -17
  62. package/src/charting/charts/PieChart3D.js +137 -0
  63. package/src/charting/charts/PointAndFigureChart.js +198 -0
  64. package/src/charting/charts/PolarAreaChart.js +189 -0
  65. package/src/charting/charts/RadarChart.js +7 -1
  66. package/src/charting/charts/RadialBarChart.js +5 -2
  67. package/src/charting/charts/RenkoChart.js +178 -0
  68. package/src/charting/charts/RidgelineChart.js +113 -0
  69. package/src/charting/charts/SankeyDiagram.js +127 -4
  70. package/src/charting/charts/ScatterChart.js +13 -0
  71. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  72. package/src/charting/charts/SlopegraphChart.js +205 -0
  73. package/src/charting/charts/SparklineTableChart.js +166 -0
  74. package/src/charting/charts/StackedBarChart.js +48 -5
  75. package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
  76. package/src/charting/charts/StreamGraphChart.js +10 -3
  77. package/src/charting/charts/SunburstChart.js +27 -7
  78. package/src/charting/charts/SurfaceChart3D.js +157 -0
  79. package/src/charting/charts/TreemapChart.js +28 -8
  80. package/src/charting/charts/ViolinChart.js +18 -0
  81. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  82. package/src/charting/datamodel/DataLineage.js +150 -0
  83. package/src/charting/datamodel/DataModel.js +83 -1
  84. package/src/charting/datamodel/ModelMapping.js +87 -0
  85. package/src/charting/datamodel/SqlDdl.js +110 -85
  86. package/src/charting/datamodel/index.js +3 -0
  87. package/src/charting/decision/DecisionWorkbench.js +295 -0
  88. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  89. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  90. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  91. package/src/charting/diagrams/SketchView.js +121 -0
  92. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  93. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  94. package/src/charting/modelTree/NavigatorModel.js +410 -0
  95. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  96. package/src/charting/pivot/PivotChart.js +109 -4
  97. package/src/charting/pivot/PivotGridChart.js +321 -33
  98. package/src/charting/pivot/PivotView.js +82 -0
  99. package/src/charting/report/TemplatedReport.js +547 -0
  100. package/src/charting/templates/BoxTemplate.js +28 -27
  101. package/src/charting/templates/PackageTemplate.js +26 -25
  102. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  103. package/src/charting/templates/Template.js +219 -212
  104. package/src/cli.js +146 -0
  105. package/src/index.js +3370 -2995
  106. package/src/workspace/Workspace.js +126 -7
  107. package/src/workspace/archimateServices.js +4 -0
  108. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  109. package/src/workspace/styles.js +36 -1
@@ -0,0 +1,113 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Statistics } from '../Statistics.js';
3
+ import { GroupedDistributionChart } from './GroupedDistributionChart.js';
4
+
5
+ /**
6
+ * RidgelineChart (Joy Plot) — vertically staggered, partially overlapping
7
+ * Gaussian-KDE density curves (`Array<{label, values: number[]}>`, the same
8
+ * data contract as `BoxPlotChart`, `DensityChart`, and `ViolinChart`).
9
+ *
10
+ * Reuses `GroupedDistributionChart` for cartesian setup and value-domain math
11
+ * and `Statistics.kernelDensityEstimate` for smooth density estimation.
12
+ */
13
+ export class RidgelineChart extends GroupedDistributionChart {
14
+ static chartType = 'ridgeline';
15
+
16
+ _render() {
17
+ if (!Array.isArray(this.data) || !this.data.length) return;
18
+ this._setup();
19
+ this._domain = this._computeDomain();
20
+
21
+ const options = this.options;
22
+ const {
23
+ overlap = 0.65,
24
+ fillOpacity = 0.74,
25
+ showMedian = true,
26
+ } = options;
27
+
28
+ const data = this.data;
29
+ const n = data.length;
30
+ const leftPad = 56;
31
+ const plotX = this._plotBox.x + leftPad;
32
+ const plotW = Math.max(40, this._plotBox.width - leftPad);
33
+ const plotY = this._plotBox.y;
34
+ const plotH = this._plotBox.height;
35
+
36
+ // Allocate vertical lanes with headroom for the top ridge's peak
37
+ const rowStep = plotH / (n + overlap);
38
+ const maxPeakH = rowStep * (1 + Math.max(0, overlap));
39
+ const { vMin, vMax, range } = this._domain;
40
+ const toX = v => plotX + ((v - vMin) / range) * plotW;
41
+
42
+ // Draw bottom X-axis and nice ticks
43
+ this.renderer.path(`M${plotX},${plotY + plotH} L${plotX + plotW},${plotY + plotH}`, 'none', {
44
+ color: 'black', width: 1,
45
+ });
46
+ const ticks = Axis.niceTicks(range, 5).map(t => vMin + t).filter(t => t <= vMax + 1e-6);
47
+ for (const tick of ticks) {
48
+ const tx = toX(tick);
49
+ this.renderer.path(`M${tx},${plotY} L${tx},${plotY + plotH}`, 'none', {
50
+ color: '#eee', width: 1, dasharray: '3,3',
51
+ });
52
+ this.renderer.text(Axis.formatValue(tick), tx, plotY + plotH + 16, {
53
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '10px', fill: '#666',
54
+ });
55
+ }
56
+
57
+ // Render from top row (i=0) to bottom row (i=n-1) so lower ridges
58
+ // naturally overlap in front of upper ridges (the classic Joy Plot look).
59
+ data.forEach((d, i) => {
60
+ const values = Array.isArray(d.values) ? d.values : [];
61
+ if (!values.length) return;
62
+ const baselineY = plotY + (i + 1 + overlap * 0.35) * rowStep;
63
+ const clampedBaselineY = Math.min(plotY + plotH, baselineY);
64
+ const color = this._palette.colorAt(i);
65
+ const curve = Statistics.kernelDensityEstimate(values, { min: vMin, max: vMax });
66
+ const maxDensity = Math.max(...curve.map(p => p.density)) || 1;
67
+
68
+ const pts = curve.map(p => ({
69
+ px: toX(p.x),
70
+ py: clampedBaselineY - (p.density / maxDensity) * maxPeakH,
71
+ }));
72
+
73
+ const sorted = [...values].sort((a, b) => a - b);
74
+ const median = Statistics.quantile(sorted, 0.5);
75
+ const tooltip = `${d.label}: n=${values.length}, median=${Axis.formatValue(median)}`;
76
+
77
+ // Baseline guide + category label on left
78
+ this.renderer.path(`M${plotX},${clampedBaselineY} L${plotX + plotW},${clampedBaselineY}`, 'none', {
79
+ color: '#ddd', width: 1,
80
+ });
81
+ this.renderer.text(d.label, plotX - 8, clampedBaselineY - 2, {
82
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fontWeight: '600', fill: '#333',
83
+ });
84
+
85
+ // Closed area path + crisp upper ridge stroke
86
+ const areaPath = `M${pts[0].px},${clampedBaselineY} L`
87
+ + pts.map(p => `${p.px},${p.py}`).join(' L')
88
+ + ` L${pts[pts.length - 1].px},${clampedBaselineY} Z`;
89
+ const ridgePath = 'M' + pts.map(p => `${p.px},${p.py}`).join(' L');
90
+
91
+ const areaEl = this._datum(this.renderer.path(areaPath, color, null, {
92
+ 'fill-opacity': fillOpacity, tooltip,
93
+ }), { label: d.label, series: d.label, value: median });
94
+ const strokeEl = this.renderer.path(ridgePath, 'none', {
95
+ color: this._palette.darken(color, 0.22), width: 2,
96
+ }, { tooltip });
97
+
98
+ const groupEls = [areaEl, strokeEl];
99
+ if (showMedian) {
100
+ const mx = toX(median);
101
+ // Interpolate density at median for median tick height
102
+ const nearest = curve.reduce((best, p) => Math.abs(p.x - median) < Math.abs(best.x - median) ? p : best, curve[0]);
103
+ const myTop = clampedBaselineY - ((nearest?.density ?? maxDensity * 0.5) / maxDensity) * maxPeakH;
104
+ const medLine = this.renderer.path(`M${mx},${clampedBaselineY} L${mx},${myTop}`, 'none', {
105
+ color: '#ffffff', width: 1.8, dasharray: '3,2',
106
+ });
107
+ groupEls.push(medLine);
108
+ }
109
+
110
+ this._wireHoverTransform(groupEls, { dx: 0, dy: -4 });
111
+ });
112
+ }
113
+ }
@@ -83,8 +83,12 @@ export class SankeyDiagram extends Chart {
83
83
  let cy = y + (height - totalH) / 2;
84
84
  levelNodes.forEach(n => {
85
85
  n.x = x + n.level * levelWidth;
86
- n.y = cy;
87
86
  n.h = n.value * scale;
87
+ if (options.nodePositions && options.nodePositions[n.id] != null) {
88
+ n.y = options.nodePositions[n.id];
89
+ } else {
90
+ n.y = cy;
91
+ }
88
92
  cy += n.h + nodePadding;
89
93
  });
90
94
  });
@@ -103,6 +107,7 @@ export class SankeyDiagram extends Chart {
103
107
  }
104
108
 
105
109
  // Draw links first so nodes sit on top.
110
+ const linkRecords = [];
106
111
  nodes.forEach(n => {
107
112
  let outY = n.y;
108
113
  n.out.forEach(l => {
@@ -113,22 +118,140 @@ export class SankeyDiagram extends Chart {
113
118
  const linkH = l.value * scale;
114
119
  const path = this._linkPath(n.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
115
120
  const { r, g, b: bl } = palette.hexToRgb(n.color);
116
- this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
121
+ const el = this._datum(this.renderer.path(path, `rgba(${r},${g},${bl},${linkOpacity})`, null, {
117
122
  tooltip: `${n.label} → ${t.label}: ${l.value}`,
118
123
  }), { label: n.label, series: t.label });
124
+ linkRecords.push({ el, source: n, target: t, link: l, r, g, b: bl, value: l.value });
119
125
  outY += linkH;
120
126
  });
121
127
  });
122
128
 
123
129
  // Draw nodes.
130
+ const nodeRecords = [];
124
131
  nodes.forEach(n => {
125
- this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
132
+ const el = this._datum(this.renderer.rect(n.x, n.y, nodeWidth, Math.max(1, n.h), n.color, null, {
126
133
  tooltip: `${n.label}: ${n.value}`,
127
134
  }), { label: n.label });
128
- this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
135
+ const textEl = this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
129
136
  textAnchor: 'middle', fontSize: '10px', fill: '#333',
130
137
  });
138
+ nodeRecords.push({ el, textEl, node: n });
131
139
  });
140
+
141
+ this._wireFlowHover(nodeRecords, linkRecords, linkOpacity, { y, height, nodeWidth, scale, options });
142
+ }
143
+
144
+ _wireFlowHover(nodeRecords, linkRecords, baseOpacity, layoutContext = {}) {
145
+ if (this.renderer?.supportsSceneGraph !== true || this.options?.hoverEffect === false) return;
146
+ const { y = 0, height = 400, nodeWidth = 16, scale = 1, options = {} } = layoutContext;
147
+
148
+ const updateLinks = () => {
149
+ linkRecords.forEach(rec => {
150
+ const s = rec.source;
151
+ const t = rec.target;
152
+ let outY = s.y;
153
+ for (let k = 0; k < s.out.indexOf(rec.link); k++) outY += s.out[k].value * scale;
154
+ const inIndex = t.in.findIndex(inL => inL.source === s);
155
+ let inY = t.y;
156
+ for (let k = 0; k < inIndex; k++) inY += t.in[k].value * scale;
157
+ const linkH = rec.value * scale;
158
+ const path = this._linkPath(s.x + nodeWidth, outY + linkH / 2, t.x, inY + linkH / 2, linkH);
159
+ rec.el?.setAttribute?.('d', path);
160
+ });
161
+ };
162
+
163
+ const setLinkVisual = (rec, alpha, shadow = '') => {
164
+ if (!rec.el) return;
165
+ rec.el.setAttribute?.('fill', `rgba(${rec.r},${rec.g},${rec.b},${alpha})`);
166
+ if (rec.el.style) rec.el.style.filter = shadow;
167
+ };
168
+ const resetAll = () => {
169
+ for (const rec of linkRecords) setLinkVisual(rec, baseOpacity, '');
170
+ for (const nRec of nodeRecords) {
171
+ if (nRec.el?.style) {
172
+ nRec.el.style.opacity = '';
173
+ nRec.el.style.filter = '';
174
+ }
175
+ }
176
+ };
177
+
178
+ for (const nRec of nodeRecords) {
179
+ if (typeof nRec.el?.addEventListener !== 'function') continue;
180
+ nRec.el._hoverWired = true;
181
+ if (nRec.el.style) {
182
+ nRec.el.style.transition = 'opacity 0.2s ease, filter 0.2s ease';
183
+ nRec.el.style.cursor = options.draggable !== false ? 'ns-resize' : 'pointer';
184
+ }
185
+
186
+ if (options.draggable !== false) {
187
+ nRec.el.addEventListener('pointerdown', (e) => {
188
+ if (e.button !== 0) return;
189
+ e.preventDefault();
190
+ const startClientY = e.clientY;
191
+ const initialY = nRec.node.y;
192
+ const onPointerMove = (me) => {
193
+ const dy = me.clientY - startClientY;
194
+ const newY = Math.max(y, Math.min(y + height - nRec.node.h, initialY + dy));
195
+ nRec.node.y = newY;
196
+ nRec.el?.setAttribute?.('y', newY);
197
+ nRec.textEl?.setAttribute?.('y', newY - 6);
198
+ updateLinks();
199
+ options.onNodeDrag?.(nRec.node.id, newY);
200
+ };
201
+ const onPointerUp = () => {
202
+ window.removeEventListener('pointermove', onPointerMove);
203
+ window.removeEventListener('pointerup', onPointerUp);
204
+ };
205
+ window.addEventListener('pointermove', onPointerMove);
206
+ window.addEventListener('pointerup', onPointerUp);
207
+ });
208
+ }
209
+
210
+ nRec.el.addEventListener('mouseenter', () => {
211
+ const connectedNodes = new Set([nRec.node]);
212
+ for (const rec of linkRecords) {
213
+ const hit = rec.source === nRec.node || rec.target === nRec.node;
214
+ if (hit) {
215
+ connectedNodes.add(rec.source);
216
+ connectedNodes.add(rec.target);
217
+ setLinkVisual(rec, Math.min(0.88, baseOpacity + 0.38), 'drop-shadow(0 2px 5px rgba(0,0,0,0.2))');
218
+ } else {
219
+ setLinkVisual(rec, Math.max(0.1, baseOpacity * 0.28), '');
220
+ }
221
+ }
222
+ for (const other of nodeRecords) {
223
+ if (!other.el?.style) continue;
224
+ other.el.style.opacity = connectedNodes.has(other.node) ? '1' : '0.45';
225
+ other.el.style.filter = other.node === nRec.node ? 'drop-shadow(0 3px 6px rgba(0,0,0,0.25))' : '';
226
+ }
227
+ });
228
+ nRec.el.addEventListener('mouseleave', resetAll);
229
+ }
230
+
231
+ for (const rec of linkRecords) {
232
+ if (typeof rec.el?.addEventListener !== 'function') continue;
233
+ rec.el._hoverWired = true;
234
+ if (rec.el.style) {
235
+ rec.el.style.transition = 'filter 0.2s ease, opacity 0.2s ease';
236
+ rec.el.style.cursor = 'pointer';
237
+ }
238
+ rec.el.addEventListener('mouseenter', () => {
239
+ for (const other of linkRecords) {
240
+ if (other === rec) {
241
+ setLinkVisual(other, Math.min(0.9, baseOpacity + 0.42), 'drop-shadow(0 3px 6px rgba(0,0,0,0.24))');
242
+ } else {
243
+ setLinkVisual(other, Math.max(0.1, baseOpacity * 0.28), '');
244
+ }
245
+ }
246
+ for (const nRec of nodeRecords) {
247
+ if (!nRec.el?.style) continue;
248
+ const hit = nRec.node === rec.source || nRec.node === rec.target;
249
+ nRec.el.style.opacity = hit ? '1' : '0.45';
250
+ nRec.el.style.filter = hit ? 'drop-shadow(0 2px 5px rgba(0,0,0,0.22))' : '';
251
+ }
252
+ });
253
+ rec.el.addEventListener('mouseleave', resetAll);
254
+ }
132
255
  }
133
256
 
134
257
  _assignLevels(nodes) {
@@ -4,6 +4,7 @@ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
4
  import { ColorPalette } from '../ColorPalette.js';
5
5
  import { ChartData } from '../ChartData.js';
6
6
  import { ErrorBars } from '../ErrorBars.js';
7
+ import { Annotations } from '../Annotations.js';
7
8
 
8
9
  /**
9
10
  * ScatterChart — x/y point scatter plot with optional categorical coloring.
@@ -59,6 +60,9 @@ export class ScatterChart extends Chart {
59
60
 
60
61
  this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
61
62
 
63
+ // Threshold bands
64
+ Annotations.drawThresholdBands(this.renderer, plotBox, options.thresholdBands, { toY });
65
+
62
66
  if (referenceLine) {
63
67
  let from, to;
64
68
  if (referenceLine === true) {
@@ -72,6 +76,15 @@ export class ScatterChart extends Chart {
72
76
  line?.setAttribute?.('stroke-dasharray', '4 3');
73
77
  }
74
78
  }
79
+ if (options.referenceLines) {
80
+ Annotations.drawReferenceLines(this.renderer, plotBox, options.referenceLines, { toX, toY });
81
+ }
82
+
83
+ // Linear regression trendline
84
+ if (options.trendline) {
85
+ const numericPts = data.filter(d => typeof d.x === 'number' && typeof d.y === 'number');
86
+ Annotations.drawTrendline(this.renderer, plotBox, numericPts, options.trendline, { toX, toY, xMin, xMax });
87
+ }
75
88
 
76
89
  data.forEach(d => {
77
90
  const cx = toX(d.x);
@@ -0,0 +1,119 @@
1
+ import { Axis } from '../Axis.js';
2
+ import { Chart } from '../Chart.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+ import { Legend } from '../Legend.js';
5
+ import { ColorPalette } from '../ColorPalette.js';
6
+ import { Statistics } from '../Statistics.js';
7
+
8
+ /**
9
+ * ScatterMatrixChart (SPLOM) — N x N grid of pairwise scatter plots for
10
+ * multi-variable numeric datasets, with univariate histograms or KDE curves
11
+ * on the diagonal cells.
12
+ */
13
+ export class ScatterMatrixChart extends Chart {
14
+ static chartType = 'splom';
15
+
16
+ _render() {
17
+ const data = this.data;
18
+ if (!Array.isArray(data) || !data.length) return;
19
+
20
+ const options = this.options;
21
+ const box = ChartBox.fromRenderer(this.renderer);
22
+ const {
23
+ x = box.x, y = box.y, width: optWidth, height = box.height,
24
+ title, colors, colorCol, dotRadius = 3,
25
+ } = options;
26
+
27
+ // Auto-detect numeric dimensions if not explicitly passed
28
+ const dimensions = options.dimensions ?? Object.keys(data[0]).filter(k => {
29
+ if (k === colorCol || k === 'category' || k === 'label') return false;
30
+ return typeof data[0][k] === 'number';
31
+ });
32
+
33
+ const N = dimensions.length;
34
+ if (N < 2) return;
35
+
36
+ // Group categories for coloring
37
+ const categoryKey = colorCol ?? ('category' in data[0] ? 'category' : null);
38
+ const categories = categoryKey ? [...new Set(data.map(d => String(d[categoryKey])))] : [];
39
+ const palette = new ColorPalette(colors);
40
+ const legendItems = categories.map((cat, i) => ({ label: cat, color: palette.colorAt(i), marker: 'circle' }));
41
+
42
+ const width = optWidth ?? (categories.length > 1
43
+ ? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, { items: legendItems })
44
+ : box.width);
45
+ const plotBox = { x, y, width, height };
46
+
47
+ new ChartLabels({ title }).draw(this.renderer, plotBox);
48
+
49
+ // Precompute min/max range for each dimension
50
+ const dimStats = new Map();
51
+ for (const dim of dimensions) {
52
+ const vals = data.map(d => Number(d[dim]) || 0);
53
+ const min = Math.min(...vals);
54
+ const max = Math.max(...vals);
55
+ dimStats.set(dim, { min, max, range: (max - min) || 1, vals });
56
+ }
57
+
58
+ const cellPadding = 6;
59
+ const cellW = (width - cellPadding * (N - 1)) / N;
60
+ const cellH = (height - cellPadding * (N - 1)) / N;
61
+
62
+ for (let row = 0; row < N; row++) {
63
+ const yDim = dimensions[row];
64
+ const yStat = dimStats.get(yDim);
65
+ const cellY = y + row * (cellH + cellPadding);
66
+
67
+ for (let col = 0; col < N; col++) {
68
+ const xDim = dimensions[col];
69
+ const xStat = dimStats.get(xDim);
70
+ const cellX = x + col * (cellW + cellPadding);
71
+
72
+ // Cell boundary
73
+ this.renderer.rect(cellX, cellY, cellW, cellH, 'rgba(0,0,0,0.02)', { color: '#ddd', width: 1 });
74
+
75
+ if (row === col) {
76
+ // Diagonal: Univariate KDE curve or histogram + dimension label
77
+ const vals = xStat.vals;
78
+ const curve = Statistics.kernelDensityEstimate(vals, { min: xStat.min, max: xStat.max });
79
+ const maxDensity = Math.max(...curve.map(p => p.density)) || 1;
80
+ const pts = curve.map(p => ({
81
+ px: cellX + ((p.x - xStat.min) / xStat.range) * cellW,
82
+ py: cellY + cellH - 4 - (p.density / maxDensity) * (cellH * 0.65),
83
+ }));
84
+ const curvePath = 'M' + pts.map(p => `${p.px},${p.py}`).join(' L');
85
+ const areaPath = curvePath + ` L${cellX + cellW},${cellY + cellH - 4} L${cellX},${cellY + cellH - 4} Z`;
86
+
87
+ this.renderer.path(areaPath, 'rgba(79, 142, 247, 0.2)', null);
88
+ this.renderer.path(curvePath, 'none', { color: '#4f8ef7', width: 1.5 });
89
+
90
+ // Centered dimension label
91
+ this.renderer.text(xDim, cellX + cellW / 2, cellY + 16, {
92
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '11px', fontWeight: 'bold', fill: '#222',
93
+ });
94
+ } else {
95
+ // Off-diagonal: Pairwise scatter plot
96
+ data.forEach((d, idx) => {
97
+ const valX = Number(d[xDim]) || 0;
98
+ const valY = Number(d[yDim]) || 0;
99
+ const px = cellX + ((valX - xStat.min) / xStat.range) * cellW;
100
+ const py = cellY + cellH - ((valY - yStat.min) / yStat.range) * cellH;
101
+
102
+ const catIdx = categoryKey ? categories.indexOf(String(d[categoryKey])) : 0;
103
+ const color = palette.colorAt(catIdx !== -1 ? catIdx : 0);
104
+ const label = d.label ?? `#${idx + 1}`;
105
+ const tooltip = `${label} (${xDim}: ${Axis.formatValue(valX)}, ${yDim}: ${Axis.formatValue(valY)})`;
106
+
107
+ this._datum(this.renderer.circle(px, py, dotRadius, color, { color: '#ffffff', width: 0.8 }, {
108
+ tooltip,
109
+ }), { label, x: valX, y: valY });
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ if (categories.length > 1) {
116
+ Legend.beside({ x, y, width }, this.renderer.width, { items: legendItems }).draw(this.renderer);
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,205 @@
1
+ import { Chart } from '../Chart.js';
2
+ import { Axis } from '../Axis.js';
3
+ import { ChartBox, ChartLabels } from '../ChartBox.js';
4
+ import { ColorPalette } from '../ColorPalette.js';
5
+ import { DumbbellChart } from './DumbbellChart.js';
6
+
7
+ /**
8
+ * SlopegraphChart — two (or K) vertical parallel axes connecting categories'
9
+ * values across time points or stages (`Array<{label, start, end}>`,
10
+ * `Array<{label, values: number[]}>`, or multi-series `Array<{name, values}>`).
11
+ *
12
+ * Features direct left/right labels with automatic vertical collision avoidance
13
+ * and interactive hover highlighting that elevates the hovered category's
14
+ * trajectory while dimming non-hovered lines.
15
+ */
16
+ export class SlopegraphChart extends Chart {
17
+ static chartType = 'slopegraph';
18
+
19
+ /**
20
+ * Normalize input into `{ stageLabels: string[], series: Array<{label: string, values: number[], color?: string}> }`.
21
+ */
22
+ static normalizeData(data, options = {}) {
23
+ if (!Array.isArray(data) || !data.length) {
24
+ return { stageLabels: options.stageLabels ?? ['Start', 'End'], series: [] };
25
+ }
26
+
27
+ const first = data[0];
28
+ // Case 1: Array<{label, values: number[]}> (e.g. K stages per category)
29
+ if (first && typeof first === 'object' && Array.isArray(first.values) && typeof first.values[0] === 'number' && !('name' in first && !('label' in first))) {
30
+ const k = Math.max(2, ...data.map(d => d.values?.length ?? 0));
31
+ const stageLabels = options.stageLabels ?? Array.from({ length: k }, (_, i) => `Stage ${i + 1}`);
32
+ const series = data.map((d, i) => ({
33
+ label: String(d.label ?? d.name ?? `#${i + 1}`),
34
+ values: Array.from({ length: k }, (_, j) => Number(d.values?.[j] ?? 0) || 0),
35
+ color: d.color,
36
+ }));
37
+ return { stageLabels, series };
38
+ }
39
+
40
+ // Case 2: Multi-series [{ name: '2025', values: [{label, value}, ...] }, { name: '2026', values: [...] }]
41
+ if (first && typeof first === 'object' && Array.isArray(first.values) && ('name' in first || typeof first.values[0] === 'object')) {
42
+ const stageLabels = options.stageLabels ?? data.map((s, i) => String(s.name ?? `Stage ${i + 1}`));
43
+ const catCount = Math.max(0, ...data.map(s => s.values?.length ?? 0));
44
+ const series = [];
45
+ for (let c = 0; c < catCount; c++) {
46
+ let label = `#${c + 1}`;
47
+ const values = data.map(s => {
48
+ const v = s.values?.[c];
49
+ if (typeof v === 'object' && v !== null) {
50
+ if (v.label != null) label = String(v.label);
51
+ return Number(v.value) || 0;
52
+ }
53
+ return Number(v) || 0;
54
+ });
55
+ series.push({ label, values });
56
+ }
57
+ return { stageLabels, series };
58
+ }
59
+
60
+ // Case 3: {label, start, end} (or before/after, from/to)
61
+ const { items, startLabel, endLabel } = DumbbellChart.normalizeData(data, options);
62
+ const stageLabels = options.stageLabels ?? [startLabel, endLabel];
63
+ const series = items.map(it => ({
64
+ label: it.label,
65
+ values: [it.start, it.end],
66
+ }));
67
+ return { stageLabels, series };
68
+ }
69
+
70
+ /**
71
+ * Nudge label Y positions (`Array<{y: number, ...}>`) apart so adjacent
72
+ * labels stay at least `minGap` pixels apart within `[minY, maxY]`.
73
+ */
74
+ static resolveLabelCollisions(entries, minGap = 13, minY = 0, maxY = 1000) {
75
+ const sorted = [...entries].sort((a, b) => a.y - b.y);
76
+ for (let pass = 0; pass < 4; pass++) {
77
+ for (let i = 1; i < sorted.length; i++) {
78
+ const overlap = (sorted[i - 1].y + minGap) - sorted[i].y;
79
+ if (overlap > 0) {
80
+ sorted[i - 1].y = Math.max(minY, sorted[i - 1].y - overlap * 0.5);
81
+ sorted[i].y = Math.min(maxY, sorted[i].y + overlap * 0.5);
82
+ }
83
+ }
84
+ }
85
+ return sorted;
86
+ }
87
+
88
+ _render() {
89
+ const { stageLabels, series } = SlopegraphChart.normalizeData(this.data, this.options);
90
+ if (!series.length || stageLabels.length < 2) return;
91
+
92
+ const box = ChartBox.fromRenderer(this.renderer);
93
+ const options = this.options;
94
+ const {
95
+ x = box.x, y = box.y, width = box.width, height = box.height,
96
+ title, colors, dotRadius = 5,
97
+ } = options;
98
+
99
+ const palette = new ColorPalette(colors);
100
+ const sidePad = Math.min(115, Math.max(72, width * 0.22));
101
+ const topPad = 24;
102
+ const plotBox = {
103
+ x: x + sidePad,
104
+ y: y + topPad,
105
+ width: Math.max(40, width - sidePad * 2),
106
+ height: Math.max(40, height - topPad),
107
+ };
108
+
109
+ new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
110
+
111
+ const allVals = series.flatMap(s => s.values);
112
+ const vMin = Math.min(...allVals);
113
+ const vMax = Math.max(...allVals);
114
+ const vRange = (vMax - vMin) || 1;
115
+ const toY = v => plotBox.y + plotBox.height - ((v - vMin) / vRange) * plotBox.height;
116
+
117
+ const k = stageLabels.length;
118
+ const stageX = stageLabels.map((_, j) => plotBox.x + (j / (k - 1)) * plotBox.width);
119
+
120
+ // Draw vertical stage axes and headers
121
+ stageX.forEach((sx, j) => {
122
+ this.renderer.path(`M${sx},${plotBox.y - 6} L${sx},${plotBox.y + plotBox.height + 6}`, 'none', {
123
+ color: '#ccd6e0', width: 1.5, dasharray: '4,3',
124
+ });
125
+ this.renderer.text(stageLabels[j], sx, plotBox.y - 14, {
126
+ textAnchor: this.renderer.centerTextAnchor, fontSize: '11px', fontWeight: 'bold', fill: '#333',
127
+ });
128
+ });
129
+
130
+ // Precompute collision-resolved left & right label positions
131
+ const leftLabels = SlopegraphChart.resolveLabelCollisions(
132
+ series.map((s, i) => ({ idx: i, y: toY(s.values[0]), text: `${s.label} (${Axis.formatValue(s.values[0])})` })),
133
+ 13, plotBox.y, plotBox.y + plotBox.height
134
+ );
135
+ const rightLabels = SlopegraphChart.resolveLabelCollisions(
136
+ series.map((s, i) => ({
137
+ idx: i,
138
+ y: toY(s.values[k - 1]),
139
+ text: `${Axis.formatValue(s.values[k - 1])} ${s.label}`,
140
+ })),
141
+ 13, plotBox.y, plotBox.y + plotBox.height
142
+ );
143
+ const leftYByIdx = new Map(leftLabels.map(l => [l.idx, l]));
144
+ const rightYByIdx = new Map(rightLabels.map(l => [l.idx, l]));
145
+
146
+ const seriesGroups = [];
147
+
148
+ series.forEach((s, i) => {
149
+ const color = s.color ?? palette.colorAt(i);
150
+ const pts = s.values.map((v, j) => ({ x: stageX[j], y: toY(v), v }));
151
+ const delta = s.values[k - 1] - s.values[0];
152
+ const sign = delta > 0 ? '+' : '';
153
+ const tooltip = `${s.label}: ${s.values.map((v, j) => `${stageLabels[j]}=${Axis.formatValue(v)}`).join(' → ')} (${sign}${Axis.formatValue(delta)})`;
154
+
155
+ const pathD = 'M' + pts.map(p => `${p.x},${p.y}`).join(' L');
156
+ const lineEl = this._datum(this.renderer.path(pathD, 'none', {
157
+ color, width: 2.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
158
+ }, { tooltip }), { label: s.label, series: s.label, value: s.values[k - 1] });
159
+
160
+ const dotEls = pts.map((p, j) => this._datum(this.renderer.circle(p.x, p.y, dotRadius, color, {
161
+ color: '#ffffff', width: 1.5,
162
+ }, { tooltip: `${s.label} (${stageLabels[j]}): ${Axis.formatValue(p.v)}` }), {
163
+ label: stageLabels[j], series: s.label, value: p.v,
164
+ }));
165
+
166
+ const leftInfo = leftYByIdx.get(i);
167
+ const rightInfo = rightYByIdx.get(i);
168
+ const leftLbl = this.renderer.text(leftInfo.text, stageX[0] - 10, leftInfo.y, {
169
+ textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fontWeight: '600', fill: color,
170
+ });
171
+ const rightLbl = this.renderer.text(rightInfo.text, stageX[k - 1] + 10, rightInfo.y, {
172
+ textAnchor: 'start', verticalAlign: 'middle', fontSize: '10px', fontWeight: '600', fill: color,
173
+ });
174
+
175
+ seriesGroups.push({ idx: i, lineEl, dotEls, leftLbl, rightLbl, allEls: [lineEl, ...dotEls, leftLbl, rightLbl] });
176
+ });
177
+
178
+ // Interactive hover highlighting: emphasize hovered trajectory, dim non-hovered
179
+ const highlightSeries = (activeIdx) => {
180
+ for (const g of seriesGroups) {
181
+ const isHovered = activeIdx === null || g.idx === activeIdx;
182
+ if (g.lineEl?.setAttribute) {
183
+ g.lineEl.setAttribute('stroke-width', activeIdx !== null && g.idx === activeIdx ? '4' : '2.5');
184
+ }
185
+ for (const el of g.allEls) {
186
+ if (!el?.style) continue;
187
+ el.style.transition = 'opacity 0.16s ease, filter 0.16s ease';
188
+ el.style.opacity = isHovered ? '1' : '0.22';
189
+ el.style.filter = (activeIdx !== null && g.idx === activeIdx)
190
+ ? 'drop-shadow(0 3px 6px rgba(0,0,0,0.28))'
191
+ : '';
192
+ }
193
+ }
194
+ };
195
+
196
+ for (const g of seriesGroups) {
197
+ for (const el of g.allEls) {
198
+ if (!el?.addEventListener) continue;
199
+ el.style.cursor = 'pointer';
200
+ el.addEventListener('mouseenter', () => highlightSeries(g.idx));
201
+ el.addEventListener('mouseleave', () => highlightSeries(null));
202
+ }
203
+ }
204
+ }
205
+ }