@peter.prib/renderengine 0.14.0 → 0.15.1
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 +45 -19
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49641 -39747
- package/dist/renderengine.umd.js +25366 -15650
- package/documentation/REFERENCE.md +206 -0
- package/examples/decision-workbench.html +26 -0
- package/package.json +7 -1
- 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 +219 -0
- 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 +3 -0
- 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 +114 -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/charts/AlluvialDiagram.js +311 -0
- package/src/charting/charts/AreaChart.js +52 -15
- package/src/charting/charts/BarChart.js +38 -3
- package/src/charting/charts/BarChart3D.js +5 -3
- package/src/charting/charts/BarChartBase.js +26 -6
- package/src/charting/charts/BeeswarmChart.js +164 -0
- package/src/charting/charts/BoxPlotChart.js +23 -1
- package/src/charting/charts/CalendarHeatmapChart.js +2 -1
- package/src/charting/charts/CandlestickChart.js +172 -4
- package/src/charting/charts/ChordDiagram.js +105 -2
- 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/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 +4 -1
- 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 +2 -2
- package/src/charting/charts/HexbinChart.js +137 -0
- package/src/charting/charts/HierarchyChart.js +77 -3
- package/src/charting/charts/HorizonChart.js +105 -0
- package/src/charting/charts/KagiChart.js +167 -0
- package/src/charting/charts/LineChart.js +74 -18
- package/src/charting/charts/OhlcChart.js +79 -0
- package/src/charting/charts/ParallelCoordinatesChart.js +9 -1
- package/src/charting/charts/PieChart.js +195 -17
- package/src/charting/charts/PieChart3D.js +137 -0
- package/src/charting/charts/PointAndFigureChart.js +198 -0
- package/src/charting/charts/PolarAreaChart.js +189 -0
- package/src/charting/charts/RadarChart.js +7 -1
- package/src/charting/charts/RadialBarChart.js +5 -2
- package/src/charting/charts/RenkoChart.js +178 -0
- package/src/charting/charts/RidgelineChart.js +113 -0
- package/src/charting/charts/SankeyDiagram.js +127 -4
- package/src/charting/charts/ScatterChart.js +13 -0
- 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 +48 -5
- package/src/charting/charts/StackedBarChart3DOrbit.js +10 -5
- package/src/charting/charts/StreamGraphChart.js +10 -3
- package/src/charting/charts/SunburstChart.js +27 -7
- package/src/charting/charts/SurfaceChart3D.js +157 -0
- package/src/charting/charts/TreemapChart.js +28 -8
- package/src/charting/charts/ViolinChart.js +18 -0
- 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 +109 -4
- package/src/charting/pivot/PivotGridChart.js +321 -33
- package/src/charting/pivot/PivotView.js +82 -0
- 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 -2995
- package/src/workspace/Workspace.js +126 -7
- package/src/workspace/archimateServices.js +4 -0
- package/src/workspace/index.js +3 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/parts/DecisionWorkbenchPart.js +58 -0
- package/src/workspace/parts/ViewsPart.js +196 -0
- 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
|
+
}
|