radiant-charts-core 0.2.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/LICENSE +21 -0
- package/LICENSE.md +21 -0
- package/dist/index.d.mts +431 -0
- package/dist/index.d.ts +431 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/Declarative.tsx +503 -0
- package/src/RadiantChart.tsx +446 -0
- package/src/ResponsiveContainer.tsx +128 -0
- package/src/axes/CartesianAxis.ts +305 -0
- package/src/core/Animator.ts +119 -0
- package/src/core/ChartManager.ts +1062 -0
- package/src/core/CrosshairManager.ts +334 -0
- package/src/core/Legend.ts +269 -0
- package/src/core/ThemeManager.ts +98 -0
- package/src/index.ts +31 -0
- package/src/scale/Scale.ts +99 -0
- package/src/scene/Node.ts +183 -0
- package/src/scene/Scene.ts +197 -0
- package/src/scene/Shapes.ts +446 -0
- package/src/series/AreaSeries.ts +315 -0
- package/src/series/BarSeries.ts +502 -0
- package/src/series/LineSeries.ts +284 -0
- package/src/series/PieSeries.ts +203 -0
- package/src/series/ScatterSeries.ts +305 -0
- package/src/tooltip/TooltipContext.ts +22 -0
- package/src/tooltip/TooltipStore.ts +169 -0
- package/src/tooltip/__tests__/TooltipStore.test.ts +176 -0
- package/src/tooltip/coordUtils.ts +41 -0
- package/src/tooltip/index.ts +18 -0
- package/src/tooltip/types.ts +57 -0
- package/src/tooltip/useChartTooltip.ts +43 -0
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
import { Scene } from '../scene/Scene';
|
|
2
|
+
import { Group, Rect, Text } from '../scene/Shapes';
|
|
3
|
+
import { RadiantChartOptions } from '../RadiantChart';
|
|
4
|
+
import { BandScale, LinearScale, Scale } from '../scale/Scale';
|
|
5
|
+
import { CartesianAxis } from '../axes/CartesianAxis';
|
|
6
|
+
import { BarSeries } from '../series/BarSeries';
|
|
7
|
+
import { LineSeries } from '../series/LineSeries';
|
|
8
|
+
import { PieSeries } from '../series/PieSeries';
|
|
9
|
+
import { AreaSeries } from '../series/AreaSeries';
|
|
10
|
+
import { ScatterSeries } from '../series/ScatterSeries';
|
|
11
|
+
import { TooltipStore } from '../tooltip/TooltipStore';
|
|
12
|
+
import { ThemeManager, ThemeOption } from './ThemeManager';
|
|
13
|
+
import { Legend, LegendItem } from './Legend';
|
|
14
|
+
import { CrosshairRenderer } from './CrosshairManager';
|
|
15
|
+
|
|
16
|
+
const NON_CARTESIAN = new Set([
|
|
17
|
+
'pie',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export class ChartManager {
|
|
21
|
+
private container: HTMLElement;
|
|
22
|
+
private options: RadiantChartOptions;
|
|
23
|
+
private scene: Scene;
|
|
24
|
+
private tooltipStore = new TooltipStore();
|
|
25
|
+
private themeManager = new ThemeManager();
|
|
26
|
+
private legend = new Legend();
|
|
27
|
+
private legendProxy?: HTMLElement;
|
|
28
|
+
private crosshair = new CrosshairRenderer();
|
|
29
|
+
private resizeObserver: ResizeObserver;
|
|
30
|
+
|
|
31
|
+
// ── System theme media query listener ──────────────────────────────────────
|
|
32
|
+
private mediaQuery: MediaQueryList | null = null;
|
|
33
|
+
private _onSystemThemeChange = () => {
|
|
34
|
+
// Only react if the user's option is 'system'; ignore for explicit themes.
|
|
35
|
+
if (this.options.theme !== 'system') return;
|
|
36
|
+
// Re-resolve the theme and re-render the chart with the new palette.
|
|
37
|
+
this.themeManager.setTheme('system');
|
|
38
|
+
const bg = this.themeManager.theme.backgroundColor;
|
|
39
|
+
this.container.style.backgroundColor = bg;
|
|
40
|
+
this.scene.backgroundColor = bg;
|
|
41
|
+
this.processData();
|
|
42
|
+
this.performLayout();
|
|
43
|
+
this.render();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
private titleGroup = new Group();
|
|
47
|
+
private subtitleGroup = new Group();
|
|
48
|
+
private legendGroup = new Group();
|
|
49
|
+
private seriesGroup = new Group();
|
|
50
|
+
private axesGroup = new Group();
|
|
51
|
+
private crosshairGroup = new Group();
|
|
52
|
+
private emptyGroup = new Group();
|
|
53
|
+
|
|
54
|
+
private categoryScale: Scale<any, number> = new BandScale();
|
|
55
|
+
private valueScale: Scale<any, number> = new LinearScale();
|
|
56
|
+
private valueScaleRight: Scale<any, number> = new LinearScale();
|
|
57
|
+
private xAxis?: CartesianAxis;
|
|
58
|
+
private yAxis?: CartesianAxis;
|
|
59
|
+
private yAxisRight?: CartesianAxis;
|
|
60
|
+
|
|
61
|
+
private series: any[] = [];
|
|
62
|
+
private seriesInstances = new Map<string, any>();
|
|
63
|
+
private isCartesian = true;
|
|
64
|
+
private seriesRect = { x: 0, y: 0, width: 0, height: 0 };
|
|
65
|
+
private initialized = false;
|
|
66
|
+
private animRafId: number | null = null;
|
|
67
|
+
private hoveredSeriesId: string | null = null;
|
|
68
|
+
private hiddenSeries: string[] = [];
|
|
69
|
+
private minY: number = 0;
|
|
70
|
+
private maxY: number = 0;
|
|
71
|
+
|
|
72
|
+
private lastDataRef: readonly any[] | null = null;
|
|
73
|
+
|
|
74
|
+
/** Keyboard navigation index for tooltip accessibility */
|
|
75
|
+
private _kbIndex = -1;
|
|
76
|
+
private readonly _onKeyDown: (e: KeyboardEvent) => void;
|
|
77
|
+
private readonly _onPointerLeave: (e: PointerEvent) => void;
|
|
78
|
+
|
|
79
|
+
constructor(container: HTMLElement, options: RadiantChartOptions) {
|
|
80
|
+
this.container = container;
|
|
81
|
+
this.options = options;
|
|
82
|
+
this.scene = new Scene(container);
|
|
83
|
+
this.tooltipStore.setOptions(options.tooltip ?? {});
|
|
84
|
+
|
|
85
|
+
// Keyboard navigation for tooltip accessibility
|
|
86
|
+
this.container.setAttribute('tabindex', '0');
|
|
87
|
+
this._onKeyDown = this._handleKeyboard.bind(this);
|
|
88
|
+
this.container.addEventListener('keydown', this._onKeyDown);
|
|
89
|
+
|
|
90
|
+
this._onPointerLeave = () => {
|
|
91
|
+
this.tooltipStore.deactivate();
|
|
92
|
+
this.crosshair.hide();
|
|
93
|
+
this.crosshair.hideSeriesSnaps();
|
|
94
|
+
this.hoveredSeriesId = null;
|
|
95
|
+
this.series.forEach(s => { s.getGroup().opacity = 1; });
|
|
96
|
+
};
|
|
97
|
+
this.container.addEventListener('pointerleave', this._onPointerLeave);
|
|
98
|
+
|
|
99
|
+
this.scene.root.add(this.titleGroup);
|
|
100
|
+
this.scene.root.add(this.subtitleGroup);
|
|
101
|
+
this.scene.root.add(this.axesGroup);
|
|
102
|
+
this.scene.root.add(this.seriesGroup);
|
|
103
|
+
// Legend must render AFTER seriesGroup so it paints on top of chart content
|
|
104
|
+
// (prevents occlusion for full-area charts like Pie, etc.)
|
|
105
|
+
this.scene.root.add(this.legendGroup);
|
|
106
|
+
this.scene.root.add(this.crosshairGroup);
|
|
107
|
+
this.scene.root.add(this.emptyGroup);
|
|
108
|
+
|
|
109
|
+
this.legendGroup.add(this.legend.getGroup());
|
|
110
|
+
this.crosshairGroup.add(this.crosshair.getGroup());
|
|
111
|
+
|
|
112
|
+
// DOM proxy for the legend — allows external CSS/DOM targeting while
|
|
113
|
+
// the legend itself is rendered to the canvas for performance.
|
|
114
|
+
this.legendProxy = document.createElement('div');
|
|
115
|
+
this.legendProxy.className = 'radiant-chart-legend';
|
|
116
|
+
this.legendProxy.style.position = 'absolute';
|
|
117
|
+
this.legendProxy.style.pointerEvents = 'none';
|
|
118
|
+
this.legendProxy.style.display = 'none';
|
|
119
|
+
this.container.appendChild(this.legendProxy);
|
|
120
|
+
|
|
121
|
+
this.setupInteractivity();
|
|
122
|
+
this.startLoop();
|
|
123
|
+
|
|
124
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (e.contentRect.width > 0 && e.contentRect.height > 0) {
|
|
127
|
+
this.scene.resize();
|
|
128
|
+
this.performLayout();
|
|
129
|
+
this.render();
|
|
130
|
+
this.initialized = true;
|
|
131
|
+
// Notify tooltip portal to recompute position after resize
|
|
132
|
+
this.tooltipStore.notifyResize();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
this.resizeObserver.observe(container);
|
|
137
|
+
|
|
138
|
+
// Listen for OS-level theme toggles only when the user explicitly opts in to 'system'.
|
|
139
|
+
// _syncMediaQueryListener() handles attaching / detaching on every update() call.
|
|
140
|
+
this._syncMediaQueryListener();
|
|
141
|
+
|
|
142
|
+
this.update(options);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private startLoop() {
|
|
146
|
+
const loop = () => {
|
|
147
|
+
this.crosshair.tick();
|
|
148
|
+
this.render();
|
|
149
|
+
this.animRafId = requestAnimationFrame(loop);
|
|
150
|
+
};
|
|
151
|
+
this.animRafId = requestAnimationFrame(loop);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private stopLoop() {
|
|
155
|
+
if (this.animRafId !== null) { cancelAnimationFrame(this.animRafId); this.animRafId = null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private setupInteractivity() {
|
|
159
|
+
this.scene.onHover = (node, event) => {
|
|
160
|
+
if (!this.initialized) return;
|
|
161
|
+
|
|
162
|
+
if (node === null) {
|
|
163
|
+
this.tooltipStore.deactivate();
|
|
164
|
+
this.crosshair.hide();
|
|
165
|
+
this.crosshair.hideSeriesSnaps();
|
|
166
|
+
this.hoveredSeriesId = null;
|
|
167
|
+
this.series.forEach(s => { s.getGroup().opacity = 1; });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const rect = this.container.getBoundingClientRect();
|
|
172
|
+
const mx = event.clientX - rect.left;
|
|
173
|
+
const my = event.clientY - rect.top;
|
|
174
|
+
const theme = this.themeManager.theme;
|
|
175
|
+
const sr = this.seriesRect;
|
|
176
|
+
const palette = theme.palette;
|
|
177
|
+
|
|
178
|
+
// Snap-to-data crosshair
|
|
179
|
+
if (this.isCartesian && mx >= sr.x && mx <= sr.x + sr.width && my >= sr.y && my <= sr.y + sr.height) {
|
|
180
|
+
const xOpt = this.options.series[0]?.xKey;
|
|
181
|
+
this.crosshair.show(
|
|
182
|
+
mx, my, sr, theme,
|
|
183
|
+
xOpt ? (this.categoryScale as BandScale) : undefined,
|
|
184
|
+
xOpt ? this.options.data : undefined,
|
|
185
|
+
xOpt
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
this.crosshair.hide();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.options.tooltip?.enabled !== false) {
|
|
192
|
+
if (this.isCartesian && mx >= sr.x && mx <= sr.x + sr.width && my >= sr.y && my <= sr.y + sr.height) {
|
|
193
|
+
// Emit raw hover data for external tooltip implementations
|
|
194
|
+
this.tooltipStore.activate(
|
|
195
|
+
[],
|
|
196
|
+
{ x: event.clientX, y: event.clientY },
|
|
197
|
+
{ x: event.clientX, y: event.clientY },
|
|
198
|
+
{ x: mx, y: my },
|
|
199
|
+
null,
|
|
200
|
+
);
|
|
201
|
+
} else if (node?.datum && !this.isCartesian) {
|
|
202
|
+
this.tooltipStore.activate(
|
|
203
|
+
[],
|
|
204
|
+
{ x: event.clientX, y: event.clientY },
|
|
205
|
+
{ x: event.clientX, y: event.clientY },
|
|
206
|
+
{ x: mx, y: my },
|
|
207
|
+
null,
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
this.tooltipStore.deactivate();
|
|
211
|
+
this.crosshair.hide();
|
|
212
|
+
this.crosshair.hideSeriesSnaps();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Focus dimming
|
|
217
|
+
const seriesId = node?.seriesId ?? null;
|
|
218
|
+
if (node?.datum && seriesId !== this.hoveredSeriesId) {
|
|
219
|
+
this.hoveredSeriesId = seriesId;
|
|
220
|
+
this.series.forEach((s, i) => {
|
|
221
|
+
const id = this.options.series[i]?.yKey ?? this.options.series[i]?.angleKey ?? `s${i}`;
|
|
222
|
+
s.getGroup().opacity = (id === seriesId || this.series.length === 1) ? 1 : 0.15;
|
|
223
|
+
});
|
|
224
|
+
} else if (!node?.datum && this.hoveredSeriesId !== null) {
|
|
225
|
+
this.hoveredSeriesId = null;
|
|
226
|
+
this.series.forEach(s => { s.getGroup().opacity = 1; });
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ── Click handler for legend toggle ────────────────────────────────
|
|
231
|
+
this.scene.onClick = (node, _event) => {
|
|
232
|
+
if (!node) return;
|
|
233
|
+
|
|
234
|
+
// Walk up the scene graph and check if the clicked node (or any
|
|
235
|
+
// ancestor) lives inside the legendGroup. If it does, the first
|
|
236
|
+
// non-empty seriesId we encounter on the way up is the toggle target.
|
|
237
|
+
let current = node;
|
|
238
|
+
let seriesId: string | null = null;
|
|
239
|
+
let insideLegend = false;
|
|
240
|
+
|
|
241
|
+
while (current) {
|
|
242
|
+
if (current.seriesId && !seriesId) {
|
|
243
|
+
seriesId = current.seriesId;
|
|
244
|
+
}
|
|
245
|
+
if (current === this.legendGroup || current === this.legend.getGroup()) {
|
|
246
|
+
insideLegend = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
current = current.parent;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── User-supplied node click listener ─────────────────────────────────
|
|
253
|
+
// Fires for any clicked data node outside the legend that carries a
|
|
254
|
+
// datum payload — bars, markers, sectors, tiles, etc. The seriesId is
|
|
255
|
+
// resolved by walking up the scene graph above.
|
|
256
|
+
if (!insideLegend && node.datum && this.options.listeners?.nodeClick) {
|
|
257
|
+
const seriesOpt = this.options.series.find(
|
|
258
|
+
so => (so.yKey ?? so.angleKey ?? '') === seriesId
|
|
259
|
+
);
|
|
260
|
+
this.options.listeners.nodeClick({
|
|
261
|
+
seriesId: seriesId ?? '',
|
|
262
|
+
datum: node.datum,
|
|
263
|
+
xKey: seriesOpt?.xKey,
|
|
264
|
+
yKey: seriesOpt?.yKey,
|
|
265
|
+
type: seriesOpt?.type,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!insideLegend || !seriesId) return;
|
|
270
|
+
|
|
271
|
+
// ── Pagination arrows ──────────────────────────────────────────────────
|
|
272
|
+
if (seriesId === '__legend_prev__' || seriesId === '__legend_next__') {
|
|
273
|
+
this.legend.paginate(seriesId === '__legend_next__' ? 1 : -1);
|
|
274
|
+
this.performLayout();
|
|
275
|
+
this.render();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Series toggle ──────────────────────────────────────────────────────
|
|
280
|
+
const idx = this.hiddenSeries.indexOf(seriesId);
|
|
281
|
+
if (idx >= 0) {
|
|
282
|
+
this.hiddenSeries.splice(idx, 1);
|
|
283
|
+
} else {
|
|
284
|
+
this.hiddenSeries.push(seriesId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── User-supplied legend item click listener ──────────────────────────
|
|
288
|
+
this.options.legend?.listeners?.legendItemClick?.({
|
|
289
|
+
seriesId,
|
|
290
|
+
visible: !this.hiddenSeries.includes(seriesId),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Sync state to the Legend renderer and re-process everything so the
|
|
294
|
+
// value-scale domain is recalculated without the hidden series.
|
|
295
|
+
this.legend.setHiddenSeries([...this.hiddenSeries]);
|
|
296
|
+
this.processData();
|
|
297
|
+
this.performLayout();
|
|
298
|
+
this.render();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// ── Double-click handler ──────────────────────────────────────────────
|
|
302
|
+
// Two responsibilities:
|
|
303
|
+
// 1. Inside the legend → isolate the dbl-clicked series (hide all others;
|
|
304
|
+
// if already isolated, restore the full set). Fires the user-supplied
|
|
305
|
+
// legendItemDoubleClick callback.
|
|
306
|
+
// 2. Outside the legend on a data node → fire nodeDoubleClick.
|
|
307
|
+
this.scene.onDblClick = (node, _event) => {
|
|
308
|
+
if (!node) return;
|
|
309
|
+
|
|
310
|
+
let current = node;
|
|
311
|
+
let seriesId: string | null = null;
|
|
312
|
+
let insideLegend = false;
|
|
313
|
+
while (current) {
|
|
314
|
+
if (current.seriesId && !seriesId) seriesId = current.seriesId;
|
|
315
|
+
if (current === this.legendGroup || current === this.legend.getGroup()) {
|
|
316
|
+
insideLegend = true;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
current = current.parent;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (insideLegend) {
|
|
323
|
+
if (!seriesId || seriesId === '__legend_prev__' || seriesId === '__legend_next__') return;
|
|
324
|
+
|
|
325
|
+
// Build the full set of series IDs the same way the legend item builder did.
|
|
326
|
+
const allIds = this.options.series.map((s, i) => s.yKey ?? s.angleKey ?? `s${i}`);
|
|
327
|
+
const others = allIds.filter(id => id !== seriesId);
|
|
328
|
+
// Already isolated when every "other" series is currently hidden.
|
|
329
|
+
const isIsolated = others.every(id => this.hiddenSeries.includes(id));
|
|
330
|
+
|
|
331
|
+
if (isIsolated) {
|
|
332
|
+
this.hiddenSeries = []; // restore everything
|
|
333
|
+
} else {
|
|
334
|
+
this.hiddenSeries = others; // hide every other series
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.options.legend?.listeners?.legendItemDoubleClick?.({
|
|
338
|
+
seriesId,
|
|
339
|
+
isolated: !isIsolated,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
this.legend.setHiddenSeries([...this.hiddenSeries]);
|
|
343
|
+
this.processData();
|
|
344
|
+
this.performLayout();
|
|
345
|
+
this.render();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (node.datum && this.options.listeners?.nodeDoubleClick) {
|
|
350
|
+
const seriesOpt = this.options.series.find(
|
|
351
|
+
so => (so.yKey ?? so.angleKey ?? '') === seriesId
|
|
352
|
+
);
|
|
353
|
+
this.options.listeners.nodeDoubleClick({
|
|
354
|
+
seriesId: seriesId ?? '',
|
|
355
|
+
datum: node.datum,
|
|
356
|
+
xKey: seriesOpt?.xKey,
|
|
357
|
+
yKey: seriesOpt?.yKey,
|
|
358
|
+
type: seriesOpt?.type,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Public accessor for the tooltip state store (consumed by React wrappers). */
|
|
365
|
+
getTooltipStore(): TooltipStore { return this.tooltipStore; }
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Keyboard navigation handler for tooltip accessibility.
|
|
369
|
+
* ArrowLeft/Right cycle through X-axis data points.
|
|
370
|
+
*/
|
|
371
|
+
private _handleKeyboard(e: KeyboardEvent) {
|
|
372
|
+
if (!this.initialized || !this.isCartesian) return;
|
|
373
|
+
const data = this.options.data;
|
|
374
|
+
if (!data?.length) return;
|
|
375
|
+
|
|
376
|
+
const xKey = this.options.series[0]?.xKey;
|
|
377
|
+
if (!xKey) return;
|
|
378
|
+
|
|
379
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
const delta = e.key === 'ArrowRight' ? 1 : -1;
|
|
382
|
+
this._kbIndex = Math.max(0, Math.min(data.length - 1, this._kbIndex + delta));
|
|
383
|
+
|
|
384
|
+
const sr = this.seriesRect;
|
|
385
|
+
const theme = this.themeManager.theme;
|
|
386
|
+
const palette = theme.palette;
|
|
387
|
+
|
|
388
|
+
this.crosshair.show(
|
|
389
|
+
sr.x + sr.width / 2, sr.y + sr.height / 2, sr, theme,
|
|
390
|
+
this.categoryScale instanceof BandScale ? this.categoryScale : undefined,
|
|
391
|
+
data, xKey,
|
|
392
|
+
);
|
|
393
|
+
this.options.onFocusChange?.(this._kbIndex);
|
|
394
|
+
} else if (e.key === 'Escape') {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
this.tooltipStore.deactivate();
|
|
397
|
+
this.crosshair.hide();
|
|
398
|
+
this.crosshair.hideSeriesSnaps();
|
|
399
|
+
this._kbIndex = -1;
|
|
400
|
+
this.options.onFocusChange?.(null);
|
|
401
|
+
} else if (e.key === 'Home') {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
this._kbIndex = 0;
|
|
404
|
+
// Re-trigger via synthetic ArrowRight at index 0
|
|
405
|
+
this._handleKeyboard(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
|
406
|
+
this._kbIndex = 0;
|
|
407
|
+
} else if (e.key === 'End') {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
this._kbIndex = data.length - 1;
|
|
410
|
+
this._handleKeyboard(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
|
411
|
+
this._kbIndex = data.length - 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
update(options: RadiantChartOptions) {
|
|
416
|
+
const dataChanged = options.data !== this.lastDataRef;
|
|
417
|
+
this.lastDataRef = options.data;
|
|
418
|
+
|
|
419
|
+
this.options = options;
|
|
420
|
+
if (options.theme) this.themeManager.setTheme(options.theme as ThemeOption);
|
|
421
|
+
const themeBackground = this.themeManager.theme.backgroundColor;
|
|
422
|
+
this.container.style.backgroundColor = themeBackground;
|
|
423
|
+
this.scene.backgroundColor = themeBackground;
|
|
424
|
+
this.container.style.transition = 'background-color 0.3s';
|
|
425
|
+
this.crosshair.updateOptions(options.crosshair ?? {});
|
|
426
|
+
this.tooltipStore.setOptions(options.tooltip ?? {});
|
|
427
|
+
this._syncMediaQueryListener();
|
|
428
|
+
this.processData();
|
|
429
|
+
this.performLayout(dataChanged);
|
|
430
|
+
this.render();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Attaches the `prefers-color-scheme` listener when theme is 'system', or
|
|
435
|
+
* removes it when the theme has changed away from 'system'. Safe to call
|
|
436
|
+
* repeatedly — it is idempotent (won't double-attach).
|
|
437
|
+
*/
|
|
438
|
+
private _syncMediaQueryListener() {
|
|
439
|
+
if (typeof window === 'undefined') return;
|
|
440
|
+
|
|
441
|
+
const needsListener = this.options.theme === 'system';
|
|
442
|
+
|
|
443
|
+
if (needsListener && !this.mediaQuery) {
|
|
444
|
+
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
445
|
+
this.mediaQuery.addEventListener('change', this._onSystemThemeChange);
|
|
446
|
+
} else if (!needsListener && this.mediaQuery) {
|
|
447
|
+
this.mediaQuery.removeEventListener('change', this._onSystemThemeChange);
|
|
448
|
+
this.mediaQuery = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private processData() {
|
|
453
|
+
// Normalize series types to avoid fallback rendering
|
|
454
|
+
const series = (this.options.series || []).map(s => {
|
|
455
|
+
const type = (s.type || '').toLowerCase().replace(/[-_ ]/g, '');
|
|
456
|
+
let yAxisId = s.yAxisId;
|
|
457
|
+
if (!yAxisId && type === 'line' && s.yKey && /percent|cumulative|pareto/i.test(s.yKey)) {
|
|
458
|
+
yAxisId = 'right';
|
|
459
|
+
}
|
|
460
|
+
return { ...s, type, yAxisId };
|
|
461
|
+
});
|
|
462
|
+
this.options = { ...this.options, series: series as any };
|
|
463
|
+
const { data } = this.options;
|
|
464
|
+
this.emptyGroup.clear();
|
|
465
|
+
|
|
466
|
+
if (!data?.length || !series?.length) {
|
|
467
|
+
this.isCartesian = false;
|
|
468
|
+
this.initialized = true;
|
|
469
|
+
this.axesGroup.visible = false;
|
|
470
|
+
this.seriesGroup.clear();
|
|
471
|
+
// Hide the legend when there is no data — prevents stale legend items
|
|
472
|
+
// from persisting after a data clear (e.g. Interactive Editor removes
|
|
473
|
+
// the `data` key and clicks "Run").
|
|
474
|
+
this.legendGroup.visible = false;
|
|
475
|
+
this.legend.getGroup().visible = false;
|
|
476
|
+
this.series = [];
|
|
477
|
+
this.seriesInstances.clear();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this.isCartesian = series.every(s => !NON_CARTESIAN.has(s.type ?? ''));
|
|
482
|
+
|
|
483
|
+
if (this.isCartesian) {
|
|
484
|
+
const xKeys = new Set<any>();
|
|
485
|
+
this.minY = Infinity;
|
|
486
|
+
this.maxY = -Infinity;
|
|
487
|
+
|
|
488
|
+
const isContinuousX = series.some(s => s.type === 'scatter');
|
|
489
|
+
if (isContinuousX) {
|
|
490
|
+
this.categoryScale = new LinearScale();
|
|
491
|
+
this.valueScale = new LinearScale();
|
|
492
|
+
} else {
|
|
493
|
+
this.categoryScale = new BandScale();
|
|
494
|
+
this.valueScale = new LinearScale();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const stackedBars = series.filter(s => s.type === 'bar' && (s.stacked || s.normalized));
|
|
498
|
+
const stackedAreas = series.filter(s => s.type === 'area' && (s.stacked || s.normalized));
|
|
499
|
+
const colTotals = data.map(d => stackedBars.reduce((t, s) => t + (d[s.yKey!] ?? 0), 0));
|
|
500
|
+
const areaColTotals = data.map(d => stackedAreas.reduce((t, s) => t + (d[s.yKey!] ?? 0), 0));
|
|
501
|
+
|
|
502
|
+
if (stackedBars.length > 0) {
|
|
503
|
+
if (series.some(s => s.type === 'bar' && s.normalized)) this.maxY = 100;
|
|
504
|
+
else {
|
|
505
|
+
data.forEach((_, i) => { this.maxY = Math.max(this.maxY, colTotals[i]); });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (stackedAreas.length > 0) {
|
|
509
|
+
if (series.some(s => s.type === 'area' && s.normalized)) {
|
|
510
|
+
this.maxY = Math.max(this.maxY, 100);
|
|
511
|
+
} else {
|
|
512
|
+
data.forEach((_, i) => { this.maxY = Math.max(this.maxY, areaColTotals[i]); });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const xKeyFields = new Set<string>();
|
|
517
|
+
const yKeyFields = new Set<string>();
|
|
518
|
+
series.forEach(s => {
|
|
519
|
+
if (s.xKey) xKeyFields.add(s.xKey);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
series.forEach((s, sIdx) => {
|
|
523
|
+
const seriesId = s.yKey ?? s.angleKey ?? `s${sIdx}`;
|
|
524
|
+
const isHidden = this.hiddenSeries.includes(seriesId);
|
|
525
|
+
if (!isHidden && s.yKey && !s.stacked && !s.normalized && s.type !== 'histogram') {
|
|
526
|
+
yKeyFields.add(s.yKey);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
data.forEach(d => {
|
|
531
|
+
xKeyFields.forEach(k => { if (d[k] !== undefined) xKeys.add(d[k]); });
|
|
532
|
+
yKeyFields.forEach(k => {
|
|
533
|
+
const v = d[k];
|
|
534
|
+
if (v > this.maxY) this.maxY = v;
|
|
535
|
+
if (v < this.minY) this.minY = v;
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (this.categoryScale instanceof BandScale) {
|
|
540
|
+
this.categoryScale.domain = Array.from(xKeys);
|
|
541
|
+
} else {
|
|
542
|
+
const numericX = Array.from(xKeys).map(v => typeof v === 'number' ? v : Number(new Date(v))).filter(v => !isNaN(v));
|
|
543
|
+
if (numericX.length > 0) {
|
|
544
|
+
const minX = Math.min(...numericX);
|
|
545
|
+
const maxX = Math.max(...numericX);
|
|
546
|
+
this.categoryScale.domain = [minX, maxX];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (this.valueScale instanceof BandScale) {
|
|
551
|
+
const yKeys = new Set<any>();
|
|
552
|
+
series.forEach(s => {
|
|
553
|
+
if (s.yKey) data.forEach(d => yKeys.add(d[s.yKey!]));
|
|
554
|
+
});
|
|
555
|
+
this.valueScale.domain = Array.from(yKeys);
|
|
556
|
+
} else {
|
|
557
|
+
const mustStartAtZero = series.some(s => s.type === 'bar' || s.type === 'area');
|
|
558
|
+
const dMin = mustStartAtZero ? Math.min(0, this.minY) : this.minY;
|
|
559
|
+
const dMax = this.maxY === -Infinity ? 100 : this.maxY;
|
|
560
|
+
this.valueScale.domain = [dMin * (dMin < 0 ? 1.1 : 0.98), dMax * 1.05];
|
|
561
|
+
}
|
|
562
|
+
const hasRightAxis = series.some(s => s.yAxisId === 'right');
|
|
563
|
+
if (hasRightAxis) {
|
|
564
|
+
let minYRight = 0, maxYRight = -Infinity;
|
|
565
|
+
series.forEach(s => {
|
|
566
|
+
if (s.yAxisId === 'right' && s.yKey) {
|
|
567
|
+
data.forEach(d => {
|
|
568
|
+
const v = d[s.yKey!];
|
|
569
|
+
if (v > maxYRight) maxYRight = v;
|
|
570
|
+
if (v < minYRight) minYRight = v;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
this.valueScaleRight.domain = [minYRight, (maxYRight === -Infinity ? 100 : maxYRight) * 1.12];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const isH = series.some(s =>
|
|
578
|
+
(s.type === 'bar' && s.direction === 'horizontal')
|
|
579
|
+
);
|
|
580
|
+
const theme = this.themeManager.theme;
|
|
581
|
+
this.axesGroup.clear();
|
|
582
|
+
|
|
583
|
+
if (!isH) {
|
|
584
|
+
this.xAxis = new CartesianAxis(this.categoryScale, {
|
|
585
|
+
position: 'bottom',
|
|
586
|
+
fontSize: this.options.xAxisFontSize,
|
|
587
|
+
fontFamily: this.options.xAxisFontFamily,
|
|
588
|
+
rotation: this.options.xAxisRotation,
|
|
589
|
+
labelAlignment: this.options.xAxisLabelAlignment
|
|
590
|
+
});
|
|
591
|
+
this.yAxis = new CartesianAxis(this.valueScale, {
|
|
592
|
+
position: 'left',
|
|
593
|
+
fontSize: this.options.yAxisFontSize,
|
|
594
|
+
fontFamily: this.options.yAxisFontFamily,
|
|
595
|
+
rotation: this.options.yAxisRotation,
|
|
596
|
+
labelAlignment: this.options.yAxisLabelAlignment
|
|
597
|
+
});
|
|
598
|
+
if (series.some(s => s.yAxisId === 'right')) {
|
|
599
|
+
this.yAxisRight = new CartesianAxis(this.valueScaleRight, {
|
|
600
|
+
position: 'right',
|
|
601
|
+
fontSize: this.options.yAxisRightFontSize ?? this.options.yAxisFontSize,
|
|
602
|
+
fontFamily: this.options.yAxisRightFontFamily ?? this.options.yAxisFontFamily,
|
|
603
|
+
rotation: this.options.yAxisRightRotation ?? this.options.yAxisRotation,
|
|
604
|
+
labelAlignment: this.options.yAxisRightLabelAlignment ?? this.options.yAxisLabelAlignment
|
|
605
|
+
});
|
|
606
|
+
} else {
|
|
607
|
+
this.yAxisRight = undefined;
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
this.xAxis = new CartesianAxis(this.valueScale, {
|
|
611
|
+
position: 'bottom',
|
|
612
|
+
fontSize: this.options.xAxisFontSize,
|
|
613
|
+
fontFamily: this.options.xAxisFontFamily,
|
|
614
|
+
rotation: this.options.xAxisRotation,
|
|
615
|
+
labelAlignment: this.options.xAxisLabelAlignment
|
|
616
|
+
});
|
|
617
|
+
this.yAxis = new CartesianAxis(this.categoryScale, {
|
|
618
|
+
position: 'left',
|
|
619
|
+
fontSize: this.options.yAxisFontSize,
|
|
620
|
+
fontFamily: this.options.yAxisFontFamily,
|
|
621
|
+
rotation: this.options.yAxisRotation,
|
|
622
|
+
labelAlignment: this.options.yAxisLabelAlignment
|
|
623
|
+
});
|
|
624
|
+
this.yAxisRight = undefined;
|
|
625
|
+
}
|
|
626
|
+
this.xAxis.setTheme(theme); this.yAxis.setTheme(theme);
|
|
627
|
+
this.axesGroup.add(this.xAxis.getGroup());
|
|
628
|
+
this.axesGroup.add(this.yAxis.getGroup());
|
|
629
|
+
if (this.yAxisRight) {
|
|
630
|
+
this.yAxisRight.setTheme(theme);
|
|
631
|
+
this.axesGroup.add(this.yAxisRight.getGroup());
|
|
632
|
+
}
|
|
633
|
+
this.axesGroup.visible = true;
|
|
634
|
+
} else {
|
|
635
|
+
this.axesGroup.visible = false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private createSeriesInstance(s: any, i: number): any {
|
|
640
|
+
const palette = this.themeManager.theme.palette;
|
|
641
|
+
const color = s.fill ?? palette[i % palette.length];
|
|
642
|
+
const animation = { ...this.options.animation, ...s.animation };
|
|
643
|
+
const base = { ...s, fill: color, stroke: s.stroke ?? color, animation };
|
|
644
|
+
|
|
645
|
+
switch (s.type) {
|
|
646
|
+
case 'bar': return new BarSeries(base as any);
|
|
647
|
+
case 'line': return new LineSeries(base as any);
|
|
648
|
+
case 'pie': return new PieSeries({ ...s, fills: s.fills ?? palette } as any);
|
|
649
|
+
case 'donut': return new PieSeries({ ...s, fills: s.fills ?? palette, innerRadius: s.innerRadius ?? 0.6 } as any);
|
|
650
|
+
case 'area': return new AreaSeries(base as any);
|
|
651
|
+
case 'scatter': return new ScatterSeries({ ...base, jitter: s.jitter } as any);
|
|
652
|
+
default: return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private renderEmpty() {
|
|
657
|
+
const { width, height } = this.container.getBoundingClientRect();
|
|
658
|
+
if (!width || !height) return;
|
|
659
|
+
const theme = this.themeManager.theme;
|
|
660
|
+
|
|
661
|
+
const icon = new Text();
|
|
662
|
+
icon.text = '⬜';
|
|
663
|
+
icon.x = width / 2; icon.y = height / 2 - 20;
|
|
664
|
+
icon.fontSize = 26; icon.fill = theme.axisColor;
|
|
665
|
+
icon.textAlign = 'center'; icon.textBaseline = 'middle';
|
|
666
|
+
this.emptyGroup.add(icon);
|
|
667
|
+
|
|
668
|
+
const msg = new Text();
|
|
669
|
+
msg.text = 'No data available';
|
|
670
|
+
msg.x = width / 2; msg.y = height / 2 + 16;
|
|
671
|
+
msg.fontSize = 13; msg.fill = theme.subtextColor;
|
|
672
|
+
msg.fontFamily = theme.fontFamily;
|
|
673
|
+
msg.textAlign = 'center'; msg.textBaseline = 'middle';
|
|
674
|
+
this.emptyGroup.add(msg);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private performLayout(shouldAnimate: boolean = false) {
|
|
678
|
+
const { width, height } = this.container.getBoundingClientRect();
|
|
679
|
+
if (!width || !height) return;
|
|
680
|
+
|
|
681
|
+
this.axesGroup.opacity = 1;
|
|
682
|
+
this.seriesGroup.opacity = 1;
|
|
683
|
+
|
|
684
|
+
// Default padding is intentionally minimal so charts fill their container.
|
|
685
|
+
// When the user sets padding these defaults are replaced entirely.
|
|
686
|
+
const pad = this.options.padding ?? { top: 4, right: 4, bottom: 0, left: 4 };
|
|
687
|
+
let top = pad.top ?? 4;
|
|
688
|
+
let bottom = height - (pad.bottom ?? 0);
|
|
689
|
+
let left = pad.left ?? 4;
|
|
690
|
+
let right = width - (pad.right ?? 4);
|
|
691
|
+
const innerPad = (pad as any).inner ?? 0;
|
|
692
|
+
|
|
693
|
+
const theme = this.themeManager.theme;
|
|
694
|
+
|
|
695
|
+
// Title / subtitle
|
|
696
|
+
this.titleGroup.clear(); this.subtitleGroup.clear();
|
|
697
|
+
if (this.options.title) {
|
|
698
|
+
const t = new Text();
|
|
699
|
+
t.text = this.options.title.text; t.fontSize = this.options.title.fontSize ?? 16;
|
|
700
|
+
t.fontWeight = '600'; t.fill = theme.textColor; t.fontFamily = theme.fontFamily;
|
|
701
|
+
t.x = width / 2; t.y = top + t.fontSize; t.textAlign = 'center';
|
|
702
|
+
this.titleGroup.add(t);
|
|
703
|
+
top += t.fontSize + 8;
|
|
704
|
+
}
|
|
705
|
+
if (this.options.subtitle) {
|
|
706
|
+
const s = new Text();
|
|
707
|
+
s.text = this.options.subtitle.text; s.fontSize = this.options.subtitle.fontSize ?? 12;
|
|
708
|
+
s.fill = theme.subtextColor; s.fontFamily = theme.fontFamily;
|
|
709
|
+
s.x = width / 2; s.y = top + s.fontSize; s.textAlign = 'center';
|
|
710
|
+
this.subtitleGroup.add(s);
|
|
711
|
+
top += s.fontSize + 6;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Legend
|
|
715
|
+
const legendOpt = this.options.legend;
|
|
716
|
+
const showLegend = legendOpt?.enabled !== false && this.options.series.length > 1;
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
if (showLegend) {
|
|
720
|
+
let items: LegendItem[] = [];
|
|
721
|
+
// Chart types that live in the non-Cartesian performLayout branch and
|
|
722
|
+
// therefore store their series instance under the `${type}-${i}-null`
|
|
723
|
+
// key. When we have to create an instance ahead of the layout pass to
|
|
724
|
+
// build the legend, we cache it under the same key so the real render
|
|
725
|
+
// re-uses it instead of throwing away our temp and losing its config.
|
|
726
|
+
const NON_CARTESIAN_TYPES = new Set(NON_CARTESIAN);
|
|
727
|
+
|
|
728
|
+
this.options.series.forEach((sOpt, i) => {
|
|
729
|
+
// Try to find an existing instance to delegate legend item generation
|
|
730
|
+
let instance: any = null;
|
|
731
|
+
for (const [key, inst] of this.seriesInstances.entries()) {
|
|
732
|
+
if (key.startsWith(`${sOpt.type}-${i}-`)) {
|
|
733
|
+
instance = inst;
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// If no instance exists (first render), create one and cache it so the
|
|
739
|
+
// downstream performLayout pass finds the same object. Previously the
|
|
740
|
+
// temp instance was discarded, which — combined with performLayout
|
|
741
|
+
// re-creating its own instance — could leave the legend items out of
|
|
742
|
+
// sync with the series config on the first paint.
|
|
743
|
+
if (!instance) {
|
|
744
|
+
instance = this.createSeriesInstance(sOpt, i);
|
|
745
|
+
if (instance && NON_CARTESIAN_TYPES.has(sOpt.type as string)) {
|
|
746
|
+
this.seriesInstances.set(`${sOpt.type}-${i}-null`, instance);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (instance && typeof instance.getLegendData === 'function') {
|
|
751
|
+
const legendItems = instance.getLegendData(theme, this.options.data);
|
|
752
|
+
if (Array.isArray(legendItems) && legendItems.length > 0) {
|
|
753
|
+
items.push(...legendItems);
|
|
754
|
+
} else {
|
|
755
|
+
items.push({
|
|
756
|
+
id: sOpt.yKey ?? sOpt.angleKey ?? `s${i}`,
|
|
757
|
+
label: sOpt.title ?? sOpt.yKey ?? sOpt.angleKey ?? `Series ${i + 1}`,
|
|
758
|
+
fill: sOpt.fill ?? theme.palette[i % theme.palette.length],
|
|
759
|
+
markerType: (sOpt.type === 'line' || sOpt.type === 'scatter') ? 'circle' : 'square',
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
} else {
|
|
763
|
+
items.push({
|
|
764
|
+
id: sOpt.yKey ?? sOpt.angleKey ?? `s${i}`,
|
|
765
|
+
label: sOpt.title ?? sOpt.yKey ?? sOpt.angleKey ?? `Series ${i + 1}`,
|
|
766
|
+
fill: sOpt.fill ?? theme.palette[i % theme.palette.length],
|
|
767
|
+
markerType: (sOpt.type === 'line' || sOpt.type === 'scatter') ? 'circle' : 'square',
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
this.legend.setHiddenSeries([...this.hiddenSeries]);
|
|
773
|
+
|
|
774
|
+
const position = legendOpt?.position ?? 'bottom';
|
|
775
|
+
const isVertical = position === 'left' || position === 'right';
|
|
776
|
+
// For vertical legend, limit width to 30% of container or 200px.
|
|
777
|
+
const legendMaxWidth = isVertical ? Math.min(width * 0.3, 200) : width;
|
|
778
|
+
|
|
779
|
+
this.legend.update(items, theme, legendMaxWidth, height, position);
|
|
780
|
+
// Ensure the legend layer is visible — a previous pass may have hidden
|
|
781
|
+
// it if it was empty, and opacity defaults can drift from context.
|
|
782
|
+
this.legendGroup.visible = true;
|
|
783
|
+
this.legendGroup.opacity = 1;
|
|
784
|
+
const legendGroupNode = this.legend.getGroup();
|
|
785
|
+
legendGroupNode.visible = true;
|
|
786
|
+
legendGroupNode.opacity = 1;
|
|
787
|
+
|
|
788
|
+
const lb = this.legend.getBBox();
|
|
789
|
+
if (lb.width > 0 && lb.height > 0) {
|
|
790
|
+
let lx = 0, ly = 0;
|
|
791
|
+
if (position === 'bottom') {
|
|
792
|
+
lx = (width - lb.width) / 2 - lb.x;
|
|
793
|
+
ly = bottom - lb.height - lb.y;
|
|
794
|
+
bottom -= lb.height + 10;
|
|
795
|
+
} else if (position === 'top') {
|
|
796
|
+
lx = (width - lb.width) / 2 - lb.x;
|
|
797
|
+
ly = top - lb.y;
|
|
798
|
+
top += lb.height + 10;
|
|
799
|
+
} else if (position === 'left') {
|
|
800
|
+
lx = left - lb.x;
|
|
801
|
+
ly = (height - lb.height) / 2 - lb.y;
|
|
802
|
+
left += lb.width + 10;
|
|
803
|
+
} else if (position === 'right') {
|
|
804
|
+
lx = right - lb.width - lb.x;
|
|
805
|
+
ly = (height - lb.height) / 2 - lb.y;
|
|
806
|
+
right -= lb.width + 10;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
legendGroupNode.translation = { x: lx, y: ly };
|
|
810
|
+
|
|
811
|
+
// Update DOM proxy — allows external CSS/DOM targeting (e.g. for
|
|
812
|
+
// accessibility tools or CSS-based layout verification) while the
|
|
813
|
+
// legend itself remains canvas-rendered for high performance.
|
|
814
|
+
if (this.legendProxy) {
|
|
815
|
+
this.legendProxy.style.display = 'block';
|
|
816
|
+
this.legendProxy.style.left = `${lx + lb.x}px`;
|
|
817
|
+
this.legendProxy.style.top = `${ly + lb.y}px`;
|
|
818
|
+
this.legendProxy.style.width = `${lb.width}px`;
|
|
819
|
+
this.legendProxy.style.height = `${lb.height}px`;
|
|
820
|
+
}
|
|
821
|
+
} else {
|
|
822
|
+
// Fallback for empty/zero bbox
|
|
823
|
+
if (position === 'bottom') {
|
|
824
|
+
legendGroupNode.translation = { x: 0, y: bottom - 24 };
|
|
825
|
+
bottom -= 24;
|
|
826
|
+
} else if (position === 'top') {
|
|
827
|
+
legendGroupNode.translation = { x: 0, y: top };
|
|
828
|
+
top += 24;
|
|
829
|
+
}
|
|
830
|
+
if (this.legendProxy) this.legendProxy.style.display = 'none';
|
|
831
|
+
}
|
|
832
|
+
} else {
|
|
833
|
+
this.legendGroup.visible = false;
|
|
834
|
+
if (this.legendProxy) this.legendProxy.style.display = 'none';
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Axis visibility — default true; false collapses the reserved space to 0
|
|
838
|
+
// so the plot fills the full edge with no dead zone.
|
|
839
|
+
const showXAxis = this.options.showXAxis !== false;
|
|
840
|
+
const showYAxis = this.options.showYAxis !== false;
|
|
841
|
+
const showYAxisRight = this.options.showYAxisRight !== false;
|
|
842
|
+
|
|
843
|
+
// Dynamic axis sizing — ask each axis how much space its labels actually need.
|
|
844
|
+
const ctx = this.scene.getContext();
|
|
845
|
+
let yAxisW = 0;
|
|
846
|
+
let yAxisRightW = 0;
|
|
847
|
+
let xAxisH = 0;
|
|
848
|
+
let topAxisH = 0;
|
|
849
|
+
const Y_LABEL_DATA_GAP = 12;
|
|
850
|
+
|
|
851
|
+
if (this.isCartesian && this.yAxis && showYAxis) {
|
|
852
|
+
this.yAxis.setTheme(theme);
|
|
853
|
+
this.yAxis.updateOptions({
|
|
854
|
+
fontSize: this.options.yAxisFontSize,
|
|
855
|
+
fontFamily: this.options.yAxisFontFamily,
|
|
856
|
+
rotation: this.options.yAxisRotation,
|
|
857
|
+
labelAlignment: this.options.yAxisLabelAlignment
|
|
858
|
+
});
|
|
859
|
+
yAxisW = this.yAxis.getRequiredSpace(ctx).size + Y_LABEL_DATA_GAP;
|
|
860
|
+
// Cap Y-axis width to 40% of container to prevent pushing the chart to oblivion on mobile
|
|
861
|
+
const maxYWidth = width * 0.4;
|
|
862
|
+
if (yAxisW > maxYWidth) {
|
|
863
|
+
yAxisW = maxYWidth;
|
|
864
|
+
this.yAxis.setLayoutMaxWidth(yAxisW - Y_LABEL_DATA_GAP);
|
|
865
|
+
} else {
|
|
866
|
+
this.yAxis.setLayoutMaxWidth(0); // clear any previous cap
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (this.isCartesian && this.yAxisRight && showYAxisRight) {
|
|
870
|
+
this.yAxisRight.setTheme(theme);
|
|
871
|
+
this.yAxisRight.updateOptions({
|
|
872
|
+
fontSize: this.options.yAxisRightFontSize ?? this.options.yAxisFontSize,
|
|
873
|
+
fontFamily: this.options.yAxisRightFontFamily ?? this.options.yAxisFontFamily,
|
|
874
|
+
rotation: this.options.yAxisRightRotation ?? this.options.yAxisRotation,
|
|
875
|
+
labelAlignment: this.options.yAxisRightLabelAlignment ?? this.options.yAxisLabelAlignment
|
|
876
|
+
});
|
|
877
|
+
yAxisRightW = this.yAxisRight.getRequiredSpace(ctx).size + Y_LABEL_DATA_GAP;
|
|
878
|
+
}
|
|
879
|
+
if (this.isCartesian && this.xAxis && showXAxis) {
|
|
880
|
+
this.xAxis.setTheme(theme);
|
|
881
|
+
this.xAxis.updateOptions({
|
|
882
|
+
fontSize: this.options.xAxisFontSize,
|
|
883
|
+
fontFamily: this.options.xAxisFontFamily,
|
|
884
|
+
rotation: this.options.xAxisRotation,
|
|
885
|
+
labelAlignment: this.options.xAxisLabelAlignment
|
|
886
|
+
});
|
|
887
|
+
xAxisH = this.xAxis.getRequiredSpace(ctx).size;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
this.seriesRect = {
|
|
891
|
+
x: left + yAxisW,
|
|
892
|
+
y: top + topAxisH,
|
|
893
|
+
width: right - left - yAxisW - yAxisRightW,
|
|
894
|
+
height: bottom - top - xAxisH - topAxisH,
|
|
895
|
+
};
|
|
896
|
+
const sr = this.seriesRect;
|
|
897
|
+
|
|
898
|
+
// Inner plot area (shrunk by innerPad)
|
|
899
|
+
const ip = {
|
|
900
|
+
x: sr.x + innerPad,
|
|
901
|
+
y: sr.y + innerPad,
|
|
902
|
+
width: sr.width - innerPad * 2,
|
|
903
|
+
height: sr.height - innerPad * 2,
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
if (this.isCartesian && this.xAxis && this.yAxis) {
|
|
907
|
+
this.xAxis.setTheme(theme); this.yAxis.setTheme(theme);
|
|
908
|
+
|
|
909
|
+
this.xAxis.getGroup().translation = { x: sr.x, y: sr.y + sr.height };
|
|
910
|
+
// Range the axis to include the inner padding
|
|
911
|
+
this.xAxis.update(sr.width, 0, showXAxis, innerPad);
|
|
912
|
+
this.yAxis.getGroup().translation = { x: sr.x - Y_LABEL_DATA_GAP, y: sr.y };
|
|
913
|
+
this.yAxis.update(sr.height, sr.width + Y_LABEL_DATA_GAP, showYAxis, innerPad, Y_LABEL_DATA_GAP);
|
|
914
|
+
|
|
915
|
+
if (this.yAxisRight) {
|
|
916
|
+
this.yAxisRight.getGroup().translation = { x: sr.x + sr.width + Y_LABEL_DATA_GAP, y: sr.y };
|
|
917
|
+
this.yAxisRight.update(sr.height, sr.width + Y_LABEL_DATA_GAP, showYAxisRight, innerPad);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
this.seriesGroup.translation = { x: sr.x, y: sr.y };
|
|
921
|
+
this.seriesGroup.clipRect = { x: 0, y: 0, width: sr.width, height: sr.height };
|
|
922
|
+
|
|
923
|
+
this.seriesGroup.clear();
|
|
924
|
+
const usedSeries: any[] = [];
|
|
925
|
+
const fData = this.options.data;
|
|
926
|
+
|
|
927
|
+
this.options.series.forEach((sOpt, sIdx) => {
|
|
928
|
+
const sId = sOpt.yKey ?? sOpt.angleKey ?? `s${sIdx}`;
|
|
929
|
+
if (this.hiddenSeries.includes(sId)) return;
|
|
930
|
+
|
|
931
|
+
const isH = (sOpt.type === 'bar' && sOpt.direction === 'horizontal');
|
|
932
|
+
let catScale: any = isH ? this.yAxis?.scale : this.xAxis?.scale;
|
|
933
|
+
let valScale: any = sOpt.yAxisId === 'right' && this.yAxisRight ? this.yAxisRight.scale : (isH ? this.xAxis?.scale : this.yAxis?.scale);
|
|
934
|
+
|
|
935
|
+
const decimationType = sOpt.type === 'line' || sOpt.type === 'area';
|
|
936
|
+
|
|
937
|
+
const instanceKey = `${sOpt.type}-${sIdx}-null`;
|
|
938
|
+
let s = this.seriesInstances.get(instanceKey);
|
|
939
|
+
if (!s) {
|
|
940
|
+
s = this.createSeriesInstance(sOpt, sIdx);
|
|
941
|
+
if (s) this.seriesInstances.set(instanceKey, s);
|
|
942
|
+
}
|
|
943
|
+
if (!s) return;
|
|
944
|
+
usedSeries.push(s);
|
|
945
|
+
|
|
946
|
+
this.seriesGroup.add(s.getGroup());
|
|
947
|
+
|
|
948
|
+
const subRect = { x: 0, y: 0, width: sr.width, height: sr.height };
|
|
949
|
+
|
|
950
|
+
const seriesOptForUpdate: any = decimationType && fData.length > 1000
|
|
951
|
+
? { ...(sOpt as any), marker: { ...(sOpt as any).marker, enabled: false } }
|
|
952
|
+
: (sOpt as any);
|
|
953
|
+
|
|
954
|
+
if (s instanceof BarSeries) {
|
|
955
|
+
const allBars = this.options.series.filter(so => so.type === 'bar');
|
|
956
|
+
let barOffsets: number[] | undefined;
|
|
957
|
+
let barTotals: number[] | undefined;
|
|
958
|
+
|
|
959
|
+
if (sOpt.stacked || sOpt.normalized) {
|
|
960
|
+
const allStackedBars = allBars.filter(so => so.stacked || so.normalized);
|
|
961
|
+
const myIdx = allStackedBars.indexOf(sOpt);
|
|
962
|
+
barOffsets = fData.map(d =>
|
|
963
|
+
allStackedBars.slice(0, myIdx).reduce((acc, so) => acc + (d[so.yKey!] ?? 0), 0)
|
|
964
|
+
);
|
|
965
|
+
barTotals = fData.map(d =>
|
|
966
|
+
allStackedBars.reduce((acc, so) => acc + (d[so.yKey!] ?? 0), 0)
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
s.update(fData, catScale as BandScale, valScale, subRect, true, sIdx, allBars.length, barOffsets, barTotals, sOpt as any);
|
|
971
|
+
} else if (s instanceof AreaSeries) {
|
|
972
|
+
let areaOffsets: number[] | undefined;
|
|
973
|
+
let areaTotals: number[] | undefined;
|
|
974
|
+
if (sOpt.stacked || sOpt.normalized) {
|
|
975
|
+
const allStackedAreas = this.options.series.filter(
|
|
976
|
+
so => so.type === 'area' && (so.stacked || so.normalized)
|
|
977
|
+
);
|
|
978
|
+
const myIdx = allStackedAreas.indexOf(sOpt);
|
|
979
|
+
areaOffsets = fData.map(d =>
|
|
980
|
+
allStackedAreas.slice(0, myIdx).reduce((acc, so) => acc + (d[so.yKey!] ?? 0), 0)
|
|
981
|
+
);
|
|
982
|
+
areaTotals = fData.map(d =>
|
|
983
|
+
allStackedAreas.reduce((acc, so) => acc + (d[so.yKey!] ?? 0), 0)
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
s.update(fData, catScale as BandScale, valScale, subRect, true, areaOffsets, seriesOptForUpdate, areaTotals);
|
|
987
|
+
} else if (
|
|
988
|
+
s instanceof LineSeries ||
|
|
989
|
+
s instanceof ScatterSeries
|
|
990
|
+
) {
|
|
991
|
+
s.update(fData, catScale as any, valScale, subRect, true, seriesOptForUpdate);
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
this.series = usedSeries;
|
|
995
|
+
} else {
|
|
996
|
+
this.seriesGroup.translation = { x: 0, y: 0 };
|
|
997
|
+
this.seriesGroup.clipRect = { x: sr.x - innerPad, y: sr.y - innerPad, width: sr.width + innerPad * 2, height: sr.height + innerPad * 2 };
|
|
998
|
+
this.seriesGroup.clear();
|
|
999
|
+
const usedSeries: any[] = [];
|
|
1000
|
+
this.options.series.forEach((sOpt, i) => {
|
|
1001
|
+
// Skip hidden series in non-cartesian rendering.
|
|
1002
|
+
const sId = sOpt.yKey ?? sOpt.angleKey ?? `s${i}`;
|
|
1003
|
+
if (this.hiddenSeries.includes(sId)) return;
|
|
1004
|
+
|
|
1005
|
+
const instanceKey = `${sOpt.type}-${i}-null`;
|
|
1006
|
+
let s = this.seriesInstances.get(instanceKey);
|
|
1007
|
+
if (!s) {
|
|
1008
|
+
s = this.createSeriesInstance(sOpt, i);
|
|
1009
|
+
if (s) this.seriesInstances.set(instanceKey, s);
|
|
1010
|
+
}
|
|
1011
|
+
if (!s) return;
|
|
1012
|
+
usedSeries.push(s);
|
|
1013
|
+
|
|
1014
|
+
this.seriesGroup.add(s.getGroup());
|
|
1015
|
+
if (s instanceof PieSeries) {
|
|
1016
|
+
s.update(this.options.data, sr, shouldAnimate, sOpt as any);
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
this.series = usedSeries;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private render() {
|
|
1025
|
+
this.scene.render();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Exports the current chart as a PNG file and triggers a browser download.
|
|
1030
|
+
*
|
|
1031
|
+
* @param filename Download filename. Defaults to 'chart.png'.
|
|
1032
|
+
*/
|
|
1033
|
+
exportToPng(filename = 'chart.png') {
|
|
1034
|
+
const canvas = this.scene.getCanvas();
|
|
1035
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
1036
|
+
const a = document.createElement('a');
|
|
1037
|
+
a.href = dataUrl;
|
|
1038
|
+
a.download = filename;
|
|
1039
|
+
document.body.appendChild(a);
|
|
1040
|
+
a.click();
|
|
1041
|
+
document.body.removeChild(a);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
destroy() {
|
|
1046
|
+
this.stopLoop();
|
|
1047
|
+
this.resizeObserver.disconnect();
|
|
1048
|
+
// Clean up the OS-level color-scheme listener
|
|
1049
|
+
if (this.mediaQuery) {
|
|
1050
|
+
this.mediaQuery.removeEventListener('change', this._onSystemThemeChange);
|
|
1051
|
+
this.mediaQuery = null;
|
|
1052
|
+
}
|
|
1053
|
+
this.container.removeEventListener('keydown', this._onKeyDown);
|
|
1054
|
+
this.container.removeEventListener('pointerleave', this._onPointerLeave);
|
|
1055
|
+
this.scene.destroy();
|
|
1056
|
+
this.tooltipStore.destroy();
|
|
1057
|
+
if (this.legendProxy) {
|
|
1058
|
+
this.legendProxy.remove();
|
|
1059
|
+
this.legendProxy = undefined;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|