@peter.prib/renderengine 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +480 -302
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49217 -37484
- package/dist/renderengine.umd.js +24899 -13093
- package/documentation/REFERENCE.md +68 -1
- package/package.json +13 -3
- package/src/CanvasRenderer.js +225 -200
- package/src/SVGRenderer.js +476 -454
- package/src/archimate.js +22 -2
- package/src/archimateArchiFormat.js +1735 -1701
- package/src/archimateArchive.js +36 -7
- package/src/archimateCSV.js +93 -37
- package/src/archimateDrawio.js +2 -0
- package/src/archimateExchangeFormat.js +3 -1
- package/src/chartCatalog.js +269 -3
- package/src/charting/Annotations.js +169 -0
- package/src/charting/ArchimateDiagramEditor.js +54 -10
- package/src/charting/Axis.js +217 -5
- package/src/charting/Chart.js +99 -9
- package/src/charting/Chart3D.js +7 -2
- package/src/charting/ChartBox.js +8 -1
- package/src/charting/ColorPalette.js +42 -4
- package/src/charting/DiagramEditor.js +174 -39
- package/src/charting/DiagramEditorCommands.js +28 -0
- package/src/charting/Edge.js +7 -0
- package/src/charting/Hierarchy.js +142 -0
- package/src/charting/InteractionManager.js +4 -0
- package/src/charting/ModelFileFormat.js +5 -1
- package/src/charting/SettingsManager.js +35 -0
- package/src/charting/ShapePaths.js +82 -10
- package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
- package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
- package/src/charting/ask/AskBar.js +388 -0
- package/src/charting/ask/AskView.js +400 -0
- package/src/charting/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +69 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +6 -4
- package/src/charting/charts/BarChart3DOrbit.js +3 -1
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +25 -3
- package/src/charting/charts/BubbleChart.js +4 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +107 -4
- package/src/charting/charts/CirclePackChart.js +81 -0
- package/src/charting/charts/ComboChart.js +233 -0
- package/src/charting/charts/ContourChart.js +310 -0
- package/src/charting/charts/DensityChart.js +1 -1
- package/src/charting/charts/DonutChart3D.js +13 -0
- package/src/charting/charts/DumbbellChart.js +217 -0
- package/src/charting/charts/EcdfChart.js +150 -0
- package/src/charting/charts/FunnelChart.js +5 -2
- package/src/charting/charts/GeoMapChart.js +132 -11
- package/src/charting/charts/GroupedBarChart.js +44 -3
- package/src/charting/charts/GroupedDistributionChart.js +26 -1
- package/src/charting/charts/HeatmapChart.js +6 -3
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HistogramChart.js +2 -2
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/IcicleChart.js +47 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/LollipopChart.js +104 -0
- package/src/charting/charts/MarimekkoChart.js +75 -0
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/OutlineChart.js +10 -8
- package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
- package/src/charting/charts/ParetoChart.js +92 -0
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PipeChart.js +5 -2
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/PopulationPyramidChart.js +4 -4
- package/src/charting/charts/RadarChart.js +12 -5
- package/src/charting/charts/RadialBarChart.js +6 -3
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +129 -6
- package/src/charting/charts/ScatterChart.js +19 -3
- package/src/charting/charts/ScatterMatrixChart.js +119 -0
- package/src/charting/charts/SlopegraphChart.js +205 -0
- package/src/charting/charts/SparklineTableChart.js +166 -0
- package/src/charting/charts/StackedBarChart.js +125 -11
- package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
- package/src/charting/charts/StreamGraphChart.js +18 -3
- package/src/charting/charts/SunburstChart.js +28 -8
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +31 -11
- package/src/charting/charts/ViolinChart.js +20 -2
- package/src/charting/charts/WaterfallChart.js +2 -2
- package/src/charting/charts/WordCloudChart.js +2 -2
- package/src/charting/datamodel/ArchimateDataLink.js +70 -0
- package/src/charting/datamodel/DataLineage.js +150 -0
- package/src/charting/datamodel/DataModel.js +83 -1
- package/src/charting/datamodel/ModelMapping.js +87 -0
- package/src/charting/datamodel/SqlDdl.js +110 -85
- package/src/charting/datamodel/index.js +3 -0
- package/src/charting/decision/DecisionWorkbench.js +295 -0
- package/src/charting/diagrams/ArchimateDiagram.js +25 -9
- package/src/charting/diagrams/ArchimateModel.js +1382 -1238
- package/src/charting/diagrams/GraphDiagram.js +1424 -1350
- package/src/charting/diagrams/SketchView.js +121 -0
- package/src/charting/diagrams/sketchBoxKinds.js +30 -0
- package/src/charting/modelTree/AnalysisPanel.js +101 -23
- package/src/charting/modelTree/NavigatorModel.js +410 -0
- package/src/charting/modelTree/NavigatorPanel.js +453 -0
- package/src/charting/pivot/PivotChart.js +310 -13
- package/src/charting/pivot/PivotGridChart.js +322 -34
- package/src/charting/pivot/PivotView.js +626 -21
- package/src/charting/report/TemplatedReport.js +547 -0
- package/src/charting/templates/BoxTemplate.js +28 -27
- package/src/charting/templates/PackageTemplate.js +26 -25
- package/src/charting/templates/SwimlaneTemplate.js +19 -18
- package/src/charting/templates/Template.js +219 -212
- package/src/cli.js +146 -0
- package/src/index.js +3370 -2899
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -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.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.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
|
-
});
|
|
128
|
-
this.renderer.text(n.label, n.x + nodeWidth / 2, n.y - 6, {
|
|
134
|
+
}), { label: n.label });
|
|
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) {
|
|
@@ -68,16 +72,28 @@ export class ScatterChart extends Chart {
|
|
|
68
72
|
({ from, to } = referenceLine);
|
|
69
73
|
}
|
|
70
74
|
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
|
|
75
|
+
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] });
|
|
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);
|
|
78
91
|
const cy = toY(d.y);
|
|
79
92
|
const tooltip = d.label ? `${d.label}: (${d.x}, ${d.y})` : `(${d.x}, ${d.y})`;
|
|
80
|
-
|
|
93
|
+
const series = catField ? ChartData.readColorCol(d, catField) : null;
|
|
94
|
+
this._datum(this.renderer.circle(cx, cy, pointRadius, colorFor(d), { color: 'rgba(0,0,0,0.2)', width: 1 }, { tooltip }), {
|
|
95
|
+
label: d.label ?? String(d.x), series: series != null ? String(series) : null,
|
|
96
|
+
});
|
|
81
97
|
|
|
82
98
|
const xLow = d.errorXMinus != null ? toX(d.x - d.errorXMinus)
|
|
83
99
|
: d.errorX != null ? toX(d.x - d.errorX) : null;
|
|
@@ -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
|
+
}
|