@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.
Files changed (124) hide show
  1. package/README.md +480 -302
  2. package/bin/renderengine.mjs +157 -0
  3. package/dist/renderengine-workspace.umd.js +49217 -37484
  4. package/dist/renderengine.umd.js +24899 -13093
  5. package/documentation/REFERENCE.md +68 -1
  6. package/package.json +13 -3
  7. package/src/CanvasRenderer.js +225 -200
  8. package/src/SVGRenderer.js +476 -454
  9. package/src/archimate.js +22 -2
  10. package/src/archimateArchiFormat.js +1735 -1701
  11. package/src/archimateArchive.js +36 -7
  12. package/src/archimateCSV.js +93 -37
  13. package/src/archimateDrawio.js +2 -0
  14. package/src/archimateExchangeFormat.js +3 -1
  15. package/src/chartCatalog.js +269 -3
  16. package/src/charting/Annotations.js +169 -0
  17. package/src/charting/ArchimateDiagramEditor.js +54 -10
  18. package/src/charting/Axis.js +217 -5
  19. package/src/charting/Chart.js +99 -9
  20. package/src/charting/Chart3D.js +7 -2
  21. package/src/charting/ChartBox.js +8 -1
  22. package/src/charting/ColorPalette.js +42 -4
  23. package/src/charting/DiagramEditor.js +174 -39
  24. package/src/charting/DiagramEditorCommands.js +28 -0
  25. package/src/charting/Edge.js +7 -0
  26. package/src/charting/Hierarchy.js +142 -0
  27. package/src/charting/InteractionManager.js +4 -0
  28. package/src/charting/ModelFileFormat.js +5 -1
  29. package/src/charting/SettingsManager.js +35 -0
  30. package/src/charting/ShapePaths.js +82 -10
  31. package/src/charting/archimate/ArchimateCsvFileFormat.js +51 -12
  32. package/src/charting/archimate/ArchimateElementTemplate.js +235 -231
  33. package/src/charting/ask/AskBar.js +388 -0
  34. package/src/charting/ask/AskView.js +400 -0
  35. package/src/charting/charts/AlluvialDiagram.js +311 -0
  36. package/src/charting/charts/AreaChart.js +69 -15
  37. package/src/charting/charts/BarChart.js +38 -3
  38. package/src/charting/charts/BarChart3D.js +6 -4
  39. package/src/charting/charts/BarChart3DOrbit.js +3 -1
  40. package/src/charting/charts/BarChartBase.js +26 -6
  41. package/src/charting/charts/BeeswarmChart.js +164 -0
  42. package/src/charting/charts/BoxPlotChart.js +25 -3
  43. package/src/charting/charts/BubbleChart.js +4 -1
  44. package/src/charting/charts/CalendarHeatmapChart.js +2 -1
  45. package/src/charting/charts/CandlestickChart.js +172 -4
  46. package/src/charting/charts/ChordDiagram.js +107 -4
  47. package/src/charting/charts/CirclePackChart.js +81 -0
  48. package/src/charting/charts/ComboChart.js +233 -0
  49. package/src/charting/charts/ContourChart.js +310 -0
  50. package/src/charting/charts/DensityChart.js +1 -1
  51. package/src/charting/charts/DonutChart3D.js +13 -0
  52. package/src/charting/charts/DumbbellChart.js +217 -0
  53. package/src/charting/charts/EcdfChart.js +150 -0
  54. package/src/charting/charts/FunnelChart.js +5 -2
  55. package/src/charting/charts/GeoMapChart.js +132 -11
  56. package/src/charting/charts/GroupedBarChart.js +44 -3
  57. package/src/charting/charts/GroupedDistributionChart.js +26 -1
  58. package/src/charting/charts/HeatmapChart.js +6 -3
  59. package/src/charting/charts/HexbinChart.js +137 -0
  60. package/src/charting/charts/HierarchyChart.js +77 -3
  61. package/src/charting/charts/HistogramChart.js +2 -2
  62. package/src/charting/charts/HorizonChart.js +105 -0
  63. package/src/charting/charts/IcicleChart.js +47 -0
  64. package/src/charting/charts/KagiChart.js +167 -0
  65. package/src/charting/charts/LineChart.js +74 -18
  66. package/src/charting/charts/LollipopChart.js +104 -0
  67. package/src/charting/charts/MarimekkoChart.js +75 -0
  68. package/src/charting/charts/OhlcChart.js +79 -0
  69. package/src/charting/charts/OutlineChart.js +10 -8
  70. package/src/charting/charts/ParallelCoordinatesChart.js +11 -3
  71. package/src/charting/charts/ParetoChart.js +92 -0
  72. package/src/charting/charts/PieChart.js +195 -17
  73. package/src/charting/charts/PieChart3D.js +137 -0
  74. package/src/charting/charts/PipeChart.js +5 -2
  75. package/src/charting/charts/PointAndFigureChart.js +198 -0
  76. package/src/charting/charts/PolarAreaChart.js +189 -0
  77. package/src/charting/charts/PopulationPyramidChart.js +4 -4
  78. package/src/charting/charts/RadarChart.js +12 -5
  79. package/src/charting/charts/RadialBarChart.js +6 -3
  80. package/src/charting/charts/RenkoChart.js +178 -0
  81. package/src/charting/charts/RidgelineChart.js +113 -0
  82. package/src/charting/charts/SankeyDiagram.js +129 -6
  83. package/src/charting/charts/ScatterChart.js +19 -3
  84. package/src/charting/charts/ScatterMatrixChart.js +119 -0
  85. package/src/charting/charts/SlopegraphChart.js +205 -0
  86. package/src/charting/charts/SparklineTableChart.js +166 -0
  87. package/src/charting/charts/StackedBarChart.js +125 -11
  88. package/src/charting/charts/StackedBarChart3DOrbit.js +14 -6
  89. package/src/charting/charts/StreamGraphChart.js +18 -3
  90. package/src/charting/charts/SunburstChart.js +28 -8
  91. package/src/charting/charts/SurfaceChart3D.js +157 -0
  92. package/src/charting/charts/TreemapChart.js +31 -11
  93. package/src/charting/charts/ViolinChart.js +20 -2
  94. package/src/charting/charts/WaterfallChart.js +2 -2
  95. package/src/charting/charts/WordCloudChart.js +2 -2
  96. package/src/charting/datamodel/ArchimateDataLink.js +70 -0
  97. package/src/charting/datamodel/DataLineage.js +150 -0
  98. package/src/charting/datamodel/DataModel.js +83 -1
  99. package/src/charting/datamodel/ModelMapping.js +87 -0
  100. package/src/charting/datamodel/SqlDdl.js +110 -85
  101. package/src/charting/datamodel/index.js +3 -0
  102. package/src/charting/decision/DecisionWorkbench.js +295 -0
  103. package/src/charting/diagrams/ArchimateDiagram.js +25 -9
  104. package/src/charting/diagrams/ArchimateModel.js +1382 -1238
  105. package/src/charting/diagrams/GraphDiagram.js +1424 -1350
  106. package/src/charting/diagrams/SketchView.js +121 -0
  107. package/src/charting/diagrams/sketchBoxKinds.js +30 -0
  108. package/src/charting/modelTree/AnalysisPanel.js +101 -23
  109. package/src/charting/modelTree/NavigatorModel.js +410 -0
  110. package/src/charting/modelTree/NavigatorPanel.js +453 -0
  111. package/src/charting/pivot/PivotChart.js +310 -13
  112. package/src/charting/pivot/PivotGridChart.js +322 -34
  113. package/src/charting/pivot/PivotView.js +626 -21
  114. package/src/charting/report/TemplatedReport.js +547 -0
  115. package/src/charting/templates/BoxTemplate.js +28 -27
  116. package/src/charting/templates/PackageTemplate.js +26 -25
  117. package/src/charting/templates/SwimlaneTemplate.js +19 -18
  118. package/src/charting/templates/Template.js +219 -212
  119. package/src/cli.js +146 -0
  120. package/src/index.js +3370 -2899
  121. package/src/workspace/Workspace.js +126 -7
  122. package/src/workspace/archimateServices.js +4 -0
  123. package/src/workspace/parts/ArchimateCanvasPart.js +1 -1
  124. 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
+ }
@@ -38,8 +38,11 @@ export class FunnelChart extends Chart {
38
38
  const bottomRight = bottomLeft + bottomWidth;
39
39
 
40
40
  const path = `M${topLeft},${top} L${topRight},${top} L${bottomRight},${bottom} L${bottomLeft},${bottom} Z`;
41
- this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` });
42
- this.renderer.text(`${d.label} (${d.value})`, x + width / 2, top + stageHeight / 2, {
41
+ this._datum(this.renderer.path(path, palette.colorAt(i), { color: '#fff', width: 1 }, { tooltip: `${d.label}: ${d.value}` }), { label: d.label });
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 scale = explicitScale || this._fitScale(features, width, height, centerLon, centerLat);
51
- const cx = x + width / 2;
52
- const cy = y + height / 2;
53
- const project = (lon, lat) => ({
54
- x: cx + (lon - centerLon) * scale,
55
- y: cy - (lat - centerLat) * scale,
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
- const r = b.radius || (b.value != null ? Math.max(3, (b.value / maxBubbleValue) * bubbleMaxRadius) : 5);
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
- ring.forEach(([lon, lat], i) => {
210
+ let segmentStarted = false;
211
+ ring.forEach(([lon, lat]) => {
96
212
  const p = project(lon, lat);
97
- d += `${i === 0 ? 'M' : 'L'}${p.x},${p.y} `;
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 series = this.data;
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
- return this._drawYAxis(rawMax, Math.min(0, ...values));
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) => ({ label: s.name, color: this._palette.colorAt(i), dataIndex: 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
  }