@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.
- package/README.md +45 -19
- package/bin/renderengine.mjs +157 -0
- package/dist/renderengine-workspace.umd.js +49391 -39748
- package/dist/renderengine.umd.js +25366 -15650
- package/documentation/REFERENCE.md +38 -0
- package/package.json +6 -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/parts/ArchimateCanvasPart.js +1 -1
- package/src/workspace/styles.js +36 -1
|
@@ -0,0 +1,217 @@
|
|
|
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 { Legend } from '../Legend.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DumbbellChart — connected pair of dots (`{label, start, end}` or two numeric
|
|
9
|
+
* series) per category showing before/after or target/actual change across
|
|
10
|
+
* categories.
|
|
11
|
+
*
|
|
12
|
+
* Supports both horizontal (categories along Y, values along X — default) and
|
|
13
|
+
* vertical (`orientation: 'vertical'`) layouts, optional delta callout badges
|
|
14
|
+
* (`showDelta: true`), directional coloring (`colorByDelta: true`), and hover
|
|
15
|
+
* group elevation.
|
|
16
|
+
*/
|
|
17
|
+
export class DumbbellChart extends Chart {
|
|
18
|
+
static chartType = 'dumbbell';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize either:
|
|
22
|
+
* 1. `Array<{label, start, end}>` (also accepts `from`/`to` or `before`/`after` or `values: [start, end]`)
|
|
23
|
+
* 2. Multi-series `Array<{name, values: Array<{label, value}|number>}>` (uses first two series)
|
|
24
|
+
* into `{ items: Array<{label: string, start: number, end: number, delta: number}>, startLabel: string, endLabel: string }`.
|
|
25
|
+
*/
|
|
26
|
+
static normalizeData(data, options = {}) {
|
|
27
|
+
let startLabel = options.startLabel ?? 'Start';
|
|
28
|
+
let endLabel = options.endLabel ?? 'End';
|
|
29
|
+
if (!Array.isArray(data) || !data.length) {
|
|
30
|
+
return { items: [], startLabel, endLabel };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const first = data[0];
|
|
34
|
+
// Multi-series shape: [{ name: '2025', values: [{label, value}, ...] }, { name: '2026', values: [...] }]
|
|
35
|
+
if (first && typeof first === 'object' && Array.isArray(first.values) && ('name' in first || data.length >= 2 && !('start' in first))) {
|
|
36
|
+
if ( data.length >= 2 && ('name' in first || typeof first.values[0] === 'object') ) {
|
|
37
|
+
const s0 = data[0];
|
|
38
|
+
const s1 = data[1];
|
|
39
|
+
startLabel = options.startLabel ?? s0.name ?? 'Series 1';
|
|
40
|
+
endLabel = options.endLabel ?? s1.name ?? 'Series 2';
|
|
41
|
+
const len = Math.max(s0.values?.length ?? 0, s1.values?.length ?? 0);
|
|
42
|
+
const items = [];
|
|
43
|
+
for (let i = 0; i < len; i++) {
|
|
44
|
+
const v0 = s0.values?.[i];
|
|
45
|
+
const v1 = s1.values?.[i];
|
|
46
|
+
const label = (typeof v0 === 'object' && v0 !== null ? v0.label : null)
|
|
47
|
+
?? (typeof v1 === 'object' && v1 !== null ? v1.label : null)
|
|
48
|
+
?? `#${i + 1}`;
|
|
49
|
+
const start = Number(typeof v0 === 'object' && v0 !== null ? v0.value : v0) || 0;
|
|
50
|
+
const end = Number(typeof v1 === 'object' && v1 !== null ? v1.value : v1) || 0;
|
|
51
|
+
items.push({ label: String(label), start, end, delta: end - start });
|
|
52
|
+
}
|
|
53
|
+
return { items, startLabel, endLabel };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const items = data.map((d, i) => {
|
|
58
|
+
const label = String(d?.label ?? d?.name ?? `#${i + 1}`);
|
|
59
|
+
const start = Number(
|
|
60
|
+
d?.start ?? d?.from ?? d?.before ?? (Array.isArray(d?.values) ? d.values[0] : d?.value ?? 0)
|
|
61
|
+
) || 0;
|
|
62
|
+
const end = Number(
|
|
63
|
+
d?.end ?? d?.to ?? d?.after ?? (Array.isArray(d?.values) ? (d.values[1] ?? d.values[0]) : d?.value ?? 0)
|
|
64
|
+
) || 0;
|
|
65
|
+
return { label, start, end, delta: end - start };
|
|
66
|
+
});
|
|
67
|
+
return { items, startLabel, endLabel };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_render() {
|
|
71
|
+
const { items, startLabel, endLabel } = DumbbellChart.normalizeData(this.data, this.options);
|
|
72
|
+
if (!items.length) return;
|
|
73
|
+
|
|
74
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
75
|
+
const options = this.options;
|
|
76
|
+
const {
|
|
77
|
+
x = box.x, y = box.y, width = box.width, height = box.height,
|
|
78
|
+
title, xAxisLabel, yAxisLabel, colors,
|
|
79
|
+
orientation = 'horizontal',
|
|
80
|
+
colorByDelta = true,
|
|
81
|
+
showDelta = true,
|
|
82
|
+
dotRadius = 6,
|
|
83
|
+
legend = true,
|
|
84
|
+
} = options;
|
|
85
|
+
|
|
86
|
+
const palette = new ColorPalette(colors);
|
|
87
|
+
const startColor = options.startColor ?? palette.colorAt(0);
|
|
88
|
+
const endColor = options.endColor ?? palette.colorAt(1);
|
|
89
|
+
const upColor = options.upColor ?? '#2ecc71';
|
|
90
|
+
const downColor = options.downColor ?? '#e74c3c';
|
|
91
|
+
const neutralColor = options.neutralColor ?? '#8892b0';
|
|
92
|
+
|
|
93
|
+
const isHorizontal = orientation !== 'vertical';
|
|
94
|
+
const leftPad = isHorizontal ? 38 : 0;
|
|
95
|
+
const plotBox = {
|
|
96
|
+
x: x + leftPad,
|
|
97
|
+
y,
|
|
98
|
+
width: Math.max(40, width - leftPad - (legend ? 20 : 0)),
|
|
99
|
+
height,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
103
|
+
|
|
104
|
+
const allVals = items.flatMap(it => [it.start, it.end]);
|
|
105
|
+
const rawMin = Math.min(...allVals);
|
|
106
|
+
const rawMax = Math.max(...allVals);
|
|
107
|
+
const pad = ((rawMax - rawMin) || Math.abs(rawMax) || 1) * 0.08;
|
|
108
|
+
const vMin = rawMin >= 0 && rawMin - pad < 0 ? 0 : rawMin - pad;
|
|
109
|
+
const vMax = rawMax + pad;
|
|
110
|
+
const vRange = (vMax - vMin) || 1;
|
|
111
|
+
const ticks = Axis.niceTicks(vMax - vMin, 5).map(t => vMin + t).filter(t => t <= vMax + 1e-6);
|
|
112
|
+
|
|
113
|
+
if (isHorizontal) {
|
|
114
|
+
const toX = v => plotBox.x + ((v - vMin) / vRange) * plotBox.width;
|
|
115
|
+
const slotH = plotBox.height / items.length;
|
|
116
|
+
|
|
117
|
+
// Vertical gridlines & bottom X ticks
|
|
118
|
+
this.renderer.path(`M${plotBox.x},${plotBox.y} L${plotBox.x},${plotBox.y + plotBox.height} L${plotBox.x + plotBox.width},${plotBox.y + plotBox.height}`, 'none', { color: 'black', width: 1 });
|
|
119
|
+
for (const tick of ticks) {
|
|
120
|
+
const tx = toX(tick);
|
|
121
|
+
this.renderer.path(`M${tx},${plotBox.y} L${tx},${plotBox.y + plotBox.height}`, 'none', { color: '#eee', width: 1, dasharray: '3,3' });
|
|
122
|
+
this.renderer.text(Axis.formatValue(tick), tx, plotBox.y + plotBox.height + 16, {
|
|
123
|
+
textAnchor: this.renderer.centerTextAnchor, fontSize: '10px', fill: '#666',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
items.forEach((item, i) => {
|
|
128
|
+
const cy = plotBox.y + i * slotH + slotH / 2;
|
|
129
|
+
const x1 = toX(item.start);
|
|
130
|
+
const x2 = toX(item.end);
|
|
131
|
+
const rodColor = colorByDelta
|
|
132
|
+
? (item.delta > 0 ? upColor : item.delta < 0 ? downColor : neutralColor)
|
|
133
|
+
: neutralColor;
|
|
134
|
+
const sign = item.delta > 0 ? '+' : '';
|
|
135
|
+
const tooltip = `${item.label}: ${startLabel} ${Axis.formatValue(item.start)} → ${endLabel} ${Axis.formatValue(item.end)} (${sign}${Axis.formatValue(item.delta)})`;
|
|
136
|
+
|
|
137
|
+
// Subtle horizontal guide
|
|
138
|
+
this.renderer.path(`M${plotBox.x},${cy} L${plotBox.x + plotBox.width},${cy}`, 'none', { color: '#f3f3f3', width: 1 });
|
|
139
|
+
|
|
140
|
+
// Category label on left
|
|
141
|
+
this.renderer.text(item.label, plotBox.x - 8, cy, {
|
|
142
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#333',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const rod = this.renderer.path(`M${x1},${cy} L${x2},${cy}`, 'none', {
|
|
146
|
+
color: rodColor, width: 3.5, 'stroke-linecap': 'round',
|
|
147
|
+
}, { tooltip });
|
|
148
|
+
const startDot = this._datum(this.renderer.circle(x1, cy, dotRadius, '#ffffff', {
|
|
149
|
+
color: startColor, width: 2.5,
|
|
150
|
+
}, { tooltip }), { label: item.label, series: startLabel, value: item.start });
|
|
151
|
+
const endDot = this._datum(this.renderer.circle(x2, cy, dotRadius + 0.5, endColor, {
|
|
152
|
+
color: '#ffffff', width: 1.5,
|
|
153
|
+
}, { tooltip }), { label: item.label, series: endLabel, value: item.end });
|
|
154
|
+
|
|
155
|
+
const groupEls = [rod, startDot, endDot];
|
|
156
|
+
if (showDelta) {
|
|
157
|
+
const midX = (x1 + x2) / 2;
|
|
158
|
+
const deltaLbl = this.renderer.text(`${sign}${Axis.formatValue(item.delta)}`, midX, cy - dotRadius - 5, {
|
|
159
|
+
textAnchor: this.renderer.centerTextAnchor, fontSize: '9px', fontWeight: 'bold',
|
|
160
|
+
fill: rodColor, 'pointer-events': 'none',
|
|
161
|
+
});
|
|
162
|
+
groupEls.push(deltaLbl);
|
|
163
|
+
}
|
|
164
|
+
this._wireHoverTransform(groupEls, { dx: 0, dy: -2 });
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
const toY = v => plotBox.y + plotBox.height - ((v - vMin) / vRange) * plotBox.height;
|
|
168
|
+
const slotW = plotBox.width / items.length;
|
|
169
|
+
|
|
170
|
+
this.renderer.path(`M${plotBox.x},${plotBox.y} L${plotBox.x},${plotBox.y + plotBox.height} L${plotBox.x + plotBox.width},${plotBox.y + plotBox.height}`, 'none', { color: 'black', width: 1 });
|
|
171
|
+
for (const tick of ticks) {
|
|
172
|
+
const ty = toY(tick);
|
|
173
|
+
this.renderer.path(`M${plotBox.x},${ty} L${plotBox.x + plotBox.width},${ty}`, 'none', { color: '#eee', width: 1, dasharray: '3,3' });
|
|
174
|
+
this.renderer.text(Axis.formatValue(tick), plotBox.x - 8, ty, {
|
|
175
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
items.forEach((item, i) => {
|
|
180
|
+
const cx = plotBox.x + i * slotW + slotW / 2;
|
|
181
|
+
const y1 = toY(item.start);
|
|
182
|
+
const y2 = toY(item.end);
|
|
183
|
+
const rodColor = colorByDelta
|
|
184
|
+
? (item.delta > 0 ? upColor : item.delta < 0 ? downColor : neutralColor)
|
|
185
|
+
: neutralColor;
|
|
186
|
+
const sign = item.delta > 0 ? '+' : '';
|
|
187
|
+
const tooltip = `${item.label}: ${startLabel} ${Axis.formatValue(item.start)} → ${endLabel} ${Axis.formatValue(item.end)} (${sign}${Axis.formatValue(item.delta)})`;
|
|
188
|
+
|
|
189
|
+
this.renderer.text(item.label, cx, plotBox.y + plotBox.height + 18, {
|
|
190
|
+
textAnchor: this.renderer.centerTextAnchor, fontSize: '10px', fill: '#333',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const rod = this.renderer.path(`M${cx},${y1} L${cx},${y2}`, 'none', {
|
|
194
|
+
color: rodColor, width: 3.5, 'stroke-linecap': 'round',
|
|
195
|
+
}, { tooltip });
|
|
196
|
+
const startDot = this._datum(this.renderer.circle(cx, y1, dotRadius, '#ffffff', {
|
|
197
|
+
color: startColor, width: 2.5,
|
|
198
|
+
}, { tooltip }), { label: item.label, series: startLabel, value: item.start });
|
|
199
|
+
const endDot = this._datum(this.renderer.circle(cx, y2, dotRadius + 0.5, endColor, {
|
|
200
|
+
color: '#ffffff', width: 1.5,
|
|
201
|
+
}, { tooltip }), { label: item.label, series: endLabel, value: item.end });
|
|
202
|
+
|
|
203
|
+
this._wireHoverTransform([rod, startDot, endDot], { dx: 0, dy: -2.5 });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (legend) {
|
|
208
|
+
Legend.beside(plotBox, this.renderer.width, {
|
|
209
|
+
items: [
|
|
210
|
+
{ label: startLabel, color: startColor },
|
|
211
|
+
{ label: endLabel, color: endColor },
|
|
212
|
+
],
|
|
213
|
+
marker: 'circle',
|
|
214
|
+
}).draw(this.renderer);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
* EcdfChart — Empirical Cumulative Distribution Function (step plot) for raw
|
|
10
|
+
* numeric samples.
|
|
11
|
+
*
|
|
12
|
+
* Plots the empirical probability P(X <= x) from 0 to 1 across sorted observations,
|
|
13
|
+
* supporting multiple comparison series and Kolmogorov–Smirnov distance calculation.
|
|
14
|
+
*/
|
|
15
|
+
export class EcdfChart extends Chart {
|
|
16
|
+
static chartType = 'ecdf';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute empirical CDF step points from raw numeric values.
|
|
20
|
+
* @returns {Array<{x: number, y: number}>}
|
|
21
|
+
*/
|
|
22
|
+
static computeEcdf(values) {
|
|
23
|
+
if (!Array.isArray(values) || !values.length) return [];
|
|
24
|
+
const sorted = [...values].filter(v => Number.isFinite(v)).sort((a, b) => a - b);
|
|
25
|
+
const n = sorted.length;
|
|
26
|
+
if (!n) return [];
|
|
27
|
+
const pts = [];
|
|
28
|
+
for (let i = 0; i < n; i++) {
|
|
29
|
+
pts.push({ x: sorted[i], y: (i + 1) / n });
|
|
30
|
+
}
|
|
31
|
+
return pts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Kolmogorov–Smirnov test statistic D = sup |F1(x) - F2(x)| between two samples.
|
|
36
|
+
*/
|
|
37
|
+
static ksDistance(s1, s2) {
|
|
38
|
+
const e1 = EcdfChart.computeEcdf(s1);
|
|
39
|
+
const e2 = EcdfChart.computeEcdf(s2);
|
|
40
|
+
if (!e1.length || !e2.length) return 0;
|
|
41
|
+
const allX = [...new Set([...e1.map(p => p.x), ...e2.map(p => p.x)])].sort((a, b) => a - b);
|
|
42
|
+
let maxD = 0;
|
|
43
|
+
let p1 = 0, p2 = 0;
|
|
44
|
+
for (const x of allX) {
|
|
45
|
+
const v1 = (e1.filter(p => p.x <= x).pop()?.y) ?? 0;
|
|
46
|
+
const v2 = (e2.filter(p => p.x <= x).pop()?.y) ?? 0;
|
|
47
|
+
const diff = Math.abs(v1 - v2);
|
|
48
|
+
if (diff > maxD) maxD = diff;
|
|
49
|
+
}
|
|
50
|
+
return maxD;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_render() {
|
|
54
|
+
const data = this.data;
|
|
55
|
+
if (!data) return;
|
|
56
|
+
|
|
57
|
+
const options = this.options;
|
|
58
|
+
const box = ChartBox.fromRenderer(this.renderer);
|
|
59
|
+
const {
|
|
60
|
+
x = box.x, y = box.y, width: optWidth, height = box.height,
|
|
61
|
+
title, xAxisLabel, yAxisLabel = 'Probability P(X ≤ x)',
|
|
62
|
+
colors, legend: showLegend = true, showMedian = true,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
const isGrouped = Array.isArray(data) && typeof data[0] === 'object' && Array.isArray(data[0].values);
|
|
66
|
+
const groups = isGrouped
|
|
67
|
+
? data.map(d => ({ label: d.label ?? d.name ?? 'Series', values: d.values }))
|
|
68
|
+
: [{ label: 'Sample', values: Array.isArray(data) ? data : [] }];
|
|
69
|
+
|
|
70
|
+
const palette = new ColorPalette(colors);
|
|
71
|
+
const legendItems = groups.map((g, i) => ({ label: g.label, color: palette.colorAt(i) }));
|
|
72
|
+
const width = optWidth ?? (showLegend && groups.length > 1
|
|
73
|
+
? Legend.plotWidthBeside({ x, width: box.width }, this.renderer.width, { items: legendItems })
|
|
74
|
+
: box.width);
|
|
75
|
+
const plotBox = { x, y, width, height };
|
|
76
|
+
|
|
77
|
+
new ChartLabels({ title, xAxisLabel, yAxisLabel }).draw(this.renderer, plotBox);
|
|
78
|
+
|
|
79
|
+
const allVals = groups.flatMap(g => g.values).filter(Number.isFinite);
|
|
80
|
+
if (!allVals.length) return;
|
|
81
|
+
const xMin = Math.min(...allVals);
|
|
82
|
+
const xMax = Math.max(...allVals);
|
|
83
|
+
const xRange = (xMax - xMin) || 1;
|
|
84
|
+
|
|
85
|
+
// X-axis and Y-axis (0..1)
|
|
86
|
+
new Axis().drawXRange(this.renderer, plotBox, xMin, xMax);
|
|
87
|
+
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
|
|
88
|
+
for (const yt of yTicks) {
|
|
89
|
+
const py = y + height - yt * height;
|
|
90
|
+
this.renderer.path(`M${x - 5},${py} L${x + width},${py}`, 'none', {
|
|
91
|
+
color: yt === 0.5 ? '#bbb' : '#eee', width: 1, dasharray: yt === 0.5 ? '4,4' : '2,2',
|
|
92
|
+
});
|
|
93
|
+
this.renderer.text((yt * 100).toFixed(0) + '%', x - 8, py, {
|
|
94
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '10px', fill: '#666',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
this.renderer.path(`M${x},${y} L${x},${y + height} L${x + width},${y + height}`, 'none', { color: 'black', width: 1 });
|
|
98
|
+
|
|
99
|
+
// Draw step lines
|
|
100
|
+
const toX = v => x + ((v - xMin) / xRange) * width;
|
|
101
|
+
const toY = p => y + height - p * height;
|
|
102
|
+
|
|
103
|
+
groups.forEach((g, gIdx) => {
|
|
104
|
+
const ecdf = EcdfChart.computeEcdf(g.values);
|
|
105
|
+
if (!ecdf.length) return;
|
|
106
|
+
const color = palette.colorAt(gIdx);
|
|
107
|
+
|
|
108
|
+
// Step path
|
|
109
|
+
let d = `M${toX(ecdf[0].x)},${toY(0)}`;
|
|
110
|
+
let prevX = toX(ecdf[0].x);
|
|
111
|
+
let prevY = toY(0);
|
|
112
|
+
|
|
113
|
+
for (const pt of ecdf) {
|
|
114
|
+
const px = toX(pt.x);
|
|
115
|
+
const py = toY(pt.y);
|
|
116
|
+
d += ` H${px} V${py}`;
|
|
117
|
+
prevX = px;
|
|
118
|
+
prevY = py;
|
|
119
|
+
}
|
|
120
|
+
d += ` H${x + width}`;
|
|
121
|
+
|
|
122
|
+
const lineEl = this._datum(this.renderer.path(d, 'none', {
|
|
123
|
+
color, width: 2.2,
|
|
124
|
+
}, {
|
|
125
|
+
tooltip: `${g.label}: n=${g.values.length}, min=${Axis.formatValue(xMin)}, max=${Axis.formatValue(xMax)}`,
|
|
126
|
+
}), { label: g.label, series: g.label });
|
|
127
|
+
|
|
128
|
+
// Median mark
|
|
129
|
+
if (showMedian) {
|
|
130
|
+
const median = Statistics.quantile([...g.values].sort((a, b) => a - b), 0.5);
|
|
131
|
+
const mx = toX(median);
|
|
132
|
+
this.renderer.circle(mx, toY(0.5), 3.5, color, { color: '#ffffff', width: 1.5 }, {
|
|
133
|
+
tooltip: `${g.label} Median: ${Axis.formatValue(median)}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// KS distance badge if exactly 2 groups
|
|
139
|
+
if (groups.length === 2 && groups[0].values.length && groups[1].values.length) {
|
|
140
|
+
const ks = EcdfChart.ksDistance(groups[0].values, groups[1].values);
|
|
141
|
+
this.renderer.text(`Kolmogorov-Smirnov D = ${ks.toFixed(3)}`, x + width - 10, y + 16, {
|
|
142
|
+
textAnchor: 'end', fontSize: '11px', fontWeight: 'bold', fill: '#444',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (showLegend && groups.length > 1) {
|
|
147
|
+
Legend.beside({ x, y, width }, this.renderer.width, { items: legendItems }).draw(this.renderer);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -39,7 +39,10 @@ export class FunnelChart extends Chart {
|
|
|
39
39
|
|
|
40
40
|
const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
|
|
41
41
|
this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
|
|
42
|
-
|
|
42
|
+
const showVals = options.showValues !== false;
|
|
43
|
+
const valText = typeof options.format === 'function' ? options.format(d.value) : d.value;
|
|
44
|
+
const stageLabel = showVals ? `${d.label} (${valText})` : d.label;
|
|
45
|
+
this.renderer.text(stageLabel, x + width / 2, top + stageHeight / 2, {
|
|
43
46
|
textAnchor, verticalAlign: 'middle', fontSize: '12px', fill: '#fff', fontWeight: 'bold',
|
|
44
47
|
});
|
|
45
48
|
});
|
|
@@ -29,6 +29,69 @@ export class GeoMapChart extends Chart {
|
|
|
29
29
|
|
|
30
30
|
static DEFAULT_COLOR_SCALE = ['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'];
|
|
31
31
|
|
|
32
|
+
static PROJECTIONS = ['equirectangular', 'mercator', 'albers', 'orthographic'];
|
|
33
|
+
|
|
34
|
+
constructor(renderer, data, options = {}) {
|
|
35
|
+
super(renderer, data, options);
|
|
36
|
+
this._zoom = 1;
|
|
37
|
+
this._pan = { x: 0, y: 0 };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_projectPoint(lon, lat, projection, cx, cy, scale, centerLon, centerLat) {
|
|
41
|
+
const rad = Math.PI / 180;
|
|
42
|
+
const dLon = lon - centerLon;
|
|
43
|
+
const dLat = lat - centerLat;
|
|
44
|
+
|
|
45
|
+
switch (projection) {
|
|
46
|
+
case 'mercator': {
|
|
47
|
+
const clampedLat = Math.max(-85, Math.min(85, lat));
|
|
48
|
+
const phi = clampedLat * rad;
|
|
49
|
+
const lam = dLon * rad;
|
|
50
|
+
const yMerc = Math.log(Math.tan(Math.PI / 4 + phi / 2));
|
|
51
|
+
const phi0 = Math.max(-85, Math.min(85, centerLat)) * rad;
|
|
52
|
+
const y0 = Math.log(Math.tan(Math.PI / 4 + phi0 / 2));
|
|
53
|
+
return {
|
|
54
|
+
x: cx + lam * scale * (180 / Math.PI),
|
|
55
|
+
y: cy - (yMerc - y0) * scale * (180 / Math.PI),
|
|
56
|
+
visible: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case 'albers': {
|
|
60
|
+
const phi1 = 29.5 * rad;
|
|
61
|
+
const phi2 = 45.5 * rad;
|
|
62
|
+
const n = 0.5 * (Math.sin(phi1) + Math.sin(phi2));
|
|
63
|
+
const c = Math.cos(phi1) ** 2 + 2 * n * Math.sin(phi1);
|
|
64
|
+
const rho0 = Math.sqrt(Math.max(0, c - 2 * n * Math.sin(centerLat * rad))) / n;
|
|
65
|
+
const theta = n * dLon * rad;
|
|
66
|
+
const rho = Math.sqrt(Math.max(0, c - 2 * n * Math.sin(lat * rad))) / n;
|
|
67
|
+
return {
|
|
68
|
+
x: cx + rho * Math.sin(theta) * scale * (180 / Math.PI),
|
|
69
|
+
y: cy - (rho0 - rho * Math.cos(theta)) * scale * (180 / Math.PI),
|
|
70
|
+
visible: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case 'orthographic': {
|
|
74
|
+
const lam = dLon * rad;
|
|
75
|
+
const phi = lat * rad;
|
|
76
|
+
const phi0 = centerLat * rad;
|
|
77
|
+
const cosC = Math.sin(phi0) * Math.sin(phi) + Math.cos(phi0) * Math.cos(phi) * Math.cos(lam);
|
|
78
|
+
const R = scale * 50;
|
|
79
|
+
return {
|
|
80
|
+
x: cx + R * Math.cos(phi) * Math.sin(lam),
|
|
81
|
+
y: cy - R * (Math.cos(phi0) * Math.sin(phi) - Math.sin(phi0) * Math.cos(phi) * Math.cos(lam)),
|
|
82
|
+
visible: cosC >= -0.05,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
case 'equirectangular':
|
|
86
|
+
default:
|
|
87
|
+
return {
|
|
88
|
+
x: cx + dLon * scale,
|
|
89
|
+
y: cy - dLat * scale,
|
|
90
|
+
visible: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
32
95
|
_render() {
|
|
33
96
|
const data = this.data || {};
|
|
34
97
|
const options = this.options || {};
|
|
@@ -38,6 +101,7 @@ export class GeoMapChart extends Chart {
|
|
|
38
101
|
title,
|
|
39
102
|
center: [centerLon = 0, centerLat = 20] = [],
|
|
40
103
|
scale: explicitScale,
|
|
104
|
+
projection = 'equirectangular',
|
|
41
105
|
colorScale = GeoMapChart.DEFAULT_COLOR_SCALE,
|
|
42
106
|
bubbleMaxRadius = 20,
|
|
43
107
|
bubbleColor = '#e74c3c',
|
|
@@ -47,13 +111,18 @@ export class GeoMapChart extends Chart {
|
|
|
47
111
|
|
|
48
112
|
new ChartLabels({ title }).draw(this.renderer, { x, y, width, height });
|
|
49
113
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
114
|
+
const baseScale = explicitScale || this._fitScale(features, width, height, centerLon, centerLat);
|
|
115
|
+
const scale = baseScale * this._zoom;
|
|
116
|
+
const cx = x + width / 2 + this._pan.x;
|
|
117
|
+
const cy = y + height / 2 + this._pan.y;
|
|
118
|
+
|
|
119
|
+
const project = (lon, lat) => this._projectPoint(lon, lat, projection, cx, cy, scale, centerLon, centerLat);
|
|
120
|
+
|
|
121
|
+
// Draw orthographic globe sphere outline
|
|
122
|
+
if (projection === 'orthographic') {
|
|
123
|
+
const globeR = scale * 50;
|
|
124
|
+
this.renderer.circle(cx, cy, globeR, '#f0f6ff', { color: '#9bb8dc', width: 1.5 });
|
|
125
|
+
}
|
|
57
126
|
|
|
58
127
|
const valueRange = this._valueRange(features);
|
|
59
128
|
const palette = new ColorPalette(colorScale);
|
|
@@ -70,14 +139,60 @@ export class GeoMapChart extends Chart {
|
|
|
70
139
|
if (bubbles.length) {
|
|
71
140
|
const bubbleValues = bubbles.map(b => b.value).filter(v => v != null && !Number.isNaN(v));
|
|
72
141
|
const maxBubbleValue = bubbleValues.length ? Math.max(...bubbleValues) : 1;
|
|
142
|
+
const zoomRadiusScale = Math.sqrt(this._zoom);
|
|
73
143
|
bubbles.forEach(b => {
|
|
74
144
|
const p = project(b.lon, b.lat);
|
|
75
|
-
|
|
145
|
+
if (!p.visible) return;
|
|
146
|
+
const baseR = b.radius || (b.value != null ? Math.max(3, (b.value / maxBubbleValue) * bubbleMaxRadius) : 5);
|
|
147
|
+
const r = baseR * zoomRadiusScale;
|
|
76
148
|
this.renderer.circle(p.x, p.y, r, b.color || bubbleColor, { color: '#fff', width: 1 }, {
|
|
77
149
|
tooltip: b.label ? `${b.label}: ${b.value ?? ''}` : String(b.value ?? ''),
|
|
78
150
|
});
|
|
79
151
|
});
|
|
80
152
|
}
|
|
153
|
+
|
|
154
|
+
this._wireZoomPan();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_wireZoomPan() {
|
|
158
|
+
if (this._zoomWired || this.options.zoomable === false || !this.renderer?.supportsSceneGraph) return;
|
|
159
|
+
const container = this.renderer.container;
|
|
160
|
+
if (!container?.addEventListener) return;
|
|
161
|
+
this._zoomWired = true;
|
|
162
|
+
|
|
163
|
+
container.addEventListener('wheel', (e) => {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
const factor = e.deltaY < 0 ? 1.15 : 0.87;
|
|
166
|
+
this._zoom = Math.max(0.4, Math.min(25, this._zoom * factor));
|
|
167
|
+
this.render();
|
|
168
|
+
}, { passive: false });
|
|
169
|
+
|
|
170
|
+
let isDragging = false;
|
|
171
|
+
let lastX = 0;
|
|
172
|
+
let lastY = 0;
|
|
173
|
+
|
|
174
|
+
container.addEventListener('pointerdown', (e) => {
|
|
175
|
+
if (e.button !== 0) return;
|
|
176
|
+
isDragging = true;
|
|
177
|
+
lastX = e.clientX;
|
|
178
|
+
lastY = e.clientY;
|
|
179
|
+
container.setPointerCapture?.(e.pointerId);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
container.addEventListener('pointermove', (e) => {
|
|
183
|
+
if (!isDragging) return;
|
|
184
|
+
const dx = e.clientX - lastX;
|
|
185
|
+
const dy = e.clientY - lastY;
|
|
186
|
+
lastX = e.clientX;
|
|
187
|
+
lastY = e.clientY;
|
|
188
|
+
this._pan.x += dx;
|
|
189
|
+
this._pan.y += dy;
|
|
190
|
+
this.render();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const stopDrag = () => { isDragging = false; };
|
|
194
|
+
container.addEventListener('pointerup', stopDrag);
|
|
195
|
+
container.addEventListener('pointercancel', stopDrag);
|
|
81
196
|
}
|
|
82
197
|
|
|
83
198
|
_featurePath(geometry, project) {
|
|
@@ -92,11 +207,17 @@ export class GeoMapChart extends Chart {
|
|
|
92
207
|
let d = '';
|
|
93
208
|
rings.forEach(polygon => {
|
|
94
209
|
polygon.forEach(ring => {
|
|
95
|
-
|
|
210
|
+
let segmentStarted = false;
|
|
211
|
+
ring.forEach(([lon, lat]) => {
|
|
96
212
|
const p = project(lon, lat);
|
|
97
|
-
|
|
213
|
+
if (p.visible !== false) {
|
|
214
|
+
d += `${segmentStarted ? 'L' : 'M'}${p.x.toFixed(1)},${p.y.toFixed(1)} `;
|
|
215
|
+
segmentStarted = true;
|
|
216
|
+
} else {
|
|
217
|
+
segmentStarted = false;
|
|
218
|
+
}
|
|
98
219
|
});
|
|
99
|
-
d += 'Z ';
|
|
220
|
+
if (segmentStarted) d += 'Z ';
|
|
100
221
|
});
|
|
101
222
|
});
|
|
102
223
|
return d.trim();
|
|
@@ -17,10 +17,14 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
17
17
|
static chartType = 'groupedBar';
|
|
18
18
|
|
|
19
19
|
_computeMaxValue() {
|
|
20
|
-
const
|
|
20
|
+
const activeSeries = this.data.filter((_, i) => !this._dataFilter.has(i));
|
|
21
|
+
const series = activeSeries.length ? activeSeries : this.data;
|
|
21
22
|
const values = series.flatMap(s => s.values.map(v => v.value).filter(v => v != null));
|
|
22
23
|
const rawMax = Math.max(...values, 1);
|
|
23
|
-
|
|
24
|
+
const rawMin = Math.min(0, ...values);
|
|
25
|
+
return this._isHorizontal()
|
|
26
|
+
? this._drawXAxis(rawMax, rawMin)
|
|
27
|
+
: this._drawYAxis(rawMax, rawMin);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
_renderBars(maxValue) {
|
|
@@ -41,6 +45,37 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
41
45
|
return map;
|
|
42
46
|
});
|
|
43
47
|
|
|
48
|
+
if (this._isHorizontal()) {
|
|
49
|
+
const clusterHeight = height / categoryCount;
|
|
50
|
+
const groupGap = clusterHeight * groupPadding;
|
|
51
|
+
const availableHeight = clusterHeight - groupGap;
|
|
52
|
+
const barHeight = availableHeight / seriesCount;
|
|
53
|
+
const barGap = barHeight * barPadding;
|
|
54
|
+
const finalBarHeight = barHeight - barGap;
|
|
55
|
+
|
|
56
|
+
labels.forEach((label, categoryIndex) => {
|
|
57
|
+
const clusterY = y + categoryIndex * clusterHeight + groupGap / 2;
|
|
58
|
+
|
|
59
|
+
series.forEach((seriesData, seriesIndex) => {
|
|
60
|
+
if (this._dataFilter.has(seriesIndex)) return;
|
|
61
|
+
const value = valueBySeriesLabel[seriesIndex].get(label);
|
|
62
|
+
if (value == null) return;
|
|
63
|
+
const bar = this._barSpan(value);
|
|
64
|
+
const barY = clusterY + seriesIndex * barHeight + barGap / 2;
|
|
65
|
+
const color = this._palette.colorAt(seriesIndex);
|
|
66
|
+
|
|
67
|
+
this._datum(this.renderer.rect(bar.top, barY, bar.height, finalBarHeight, color, null, {
|
|
68
|
+
tooltip: `${seriesData.name} — ${label}: ${value}`,
|
|
69
|
+
}), { label, series: seriesData.name });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.renderer.text(label, x - 8, clusterY + availableHeight / 2, {
|
|
73
|
+
textAnchor: 'end', verticalAlign: 'middle', fontSize: '11px', fill: '#333',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
const clusterWidth = width / categoryCount;
|
|
45
80
|
const groupGap = clusterWidth * groupPadding;
|
|
46
81
|
const availableWidth = clusterWidth - groupGap;
|
|
@@ -60,6 +95,7 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
60
95
|
const clusterX = x + categoryIndex * clusterWidth + groupGap / 2;
|
|
61
96
|
|
|
62
97
|
series.forEach((seriesData, seriesIndex) => {
|
|
98
|
+
if (this._dataFilter.has(seriesIndex)) return;
|
|
63
99
|
const value = valueBySeriesLabel[seriesIndex].get(label);
|
|
64
100
|
if (value == null) return;
|
|
65
101
|
const bar = this._barSpan(value);
|
|
@@ -87,7 +123,12 @@ export class GroupedBarChart extends BarChartBase {
|
|
|
87
123
|
|
|
88
124
|
_legendOptions() {
|
|
89
125
|
return {
|
|
90
|
-
items: this.data.map((s, i) => ({
|
|
126
|
+
items: this.data.map((s, i) => ({
|
|
127
|
+
label: s.name,
|
|
128
|
+
color: this._palette.colorAt(i),
|
|
129
|
+
hidden: this._dataFilter.has(i),
|
|
130
|
+
dataIndex: i,
|
|
131
|
+
})),
|
|
91
132
|
itemHeight: 20, markerSize: 10,
|
|
92
133
|
};
|
|
93
134
|
}
|