@lineandvertexsoftware/vertexa-chart 0.1.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/dist/Chart.js ADDED
@@ -0,0 +1,3186 @@
1
+ import { OverlayD3 } from "@lineandvertexsoftware/overlay-d3";
2
+ import { WebGPURenderer } from "@lineandvertexsoftware/renderer-webgpu";
3
+ const DEFAULT_PALETTE = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"];
4
+ const DEFAULT_FONT_FAMILY = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
5
+ const DEFAULT_BAR_WIDTH_PX = 10;
6
+ const DEFAULT_AREA_OPACITY = 0.26;
7
+ const DEFAULT_HEATMAP_OPACITY = 0.84;
8
+ const DEFAULT_HEATMAP_COLORSCALE = ["#0b3c5d", "#328cc1", "#8fd694", "#f6ae2d", "#d7263d"];
9
+ const HIGH_CONTRAST_PALETTE = ["#00e5ff", "#ffd700", "#ff3ea5", "#7dff4d", "#ff8c00", "#8ab4ff"];
10
+ const DEFAULT_TOOLBAR_EXPORT_FORMATS = ["png", "svg", "csv"];
11
+ /**
12
+ * High-performance chart component with a frozen, minimal public API.
13
+ *
14
+ * Public API (v1):
15
+ * - `setTraces`
16
+ * - `appendPoints`
17
+ * - `exportPng`
18
+ * - `exportSvg`
19
+ * - `exportCsvPoints`
20
+ * - `setLayout`
21
+ * - `setSize`
22
+ * - `getPerformanceStats`
23
+ * - `destroy`
24
+ */
25
+ export class Chart {
26
+ root;
27
+ container;
28
+ svgGrid;
29
+ canvas;
30
+ svg;
31
+ tooltip;
32
+ toolbar;
33
+ toolbarEl = null;
34
+ toolbarExportWrap = null;
35
+ toolbarExportMenu = null;
36
+ toolbarExportButton = null;
37
+ toolbarFullscreenButton = null;
38
+ toolbarExportOpen = false;
39
+ toolbarExportBusy = false;
40
+ toolbarPreFullscreenSize = null;
41
+ overlay;
42
+ renderer = new WebGPURenderer();
43
+ initialized = false;
44
+ destroyed = false;
45
+ initPromise = null;
46
+ width;
47
+ height;
48
+ basePadding;
49
+ padding;
50
+ layout = {};
51
+ theme;
52
+ a11y;
53
+ traces;
54
+ pickingMode = "both";
55
+ onHoverHook;
56
+ onClickHook;
57
+ onZoomHook;
58
+ onLegendToggleHook;
59
+ onSelectHook;
60
+ tooltipFormatter;
61
+ tooltipRenderer;
62
+ handleContainerKeyDown = (event) => this.onContainerKeyDown(event);
63
+ handleToolbarExportToggle = (event) => this.onToolbarExportToggle(event);
64
+ handleToolbarExportMenuClick = (event) => {
65
+ void this.onToolbarExportMenuClick(event);
66
+ };
67
+ handleToolbarFullscreenClick = () => {
68
+ void this.onToolbarFullscreenClick();
69
+ };
70
+ handleToolbarDocumentPointerDown = (event) => this.onToolbarDocumentPointerDown(event);
71
+ handleToolbarDocumentKeyDown = (event) => this.onToolbarDocumentKeyDown(event);
72
+ handleToolbarFullscreenChange = () => this.onToolbarFullscreenChange();
73
+ handleToolbarWindowResize = () => this.onToolbarWindowResize();
74
+ zoom = { k: 1, x: 0, y: 0 };
75
+ dpr = Math.max(1, window.devicePixelRatio || 1);
76
+ // numeric domains (time uses ms)
77
+ xDomainNum = [0, 1];
78
+ yDomainNum = [0, 1];
79
+ // trace data cache (raw)
80
+ traceData = [];
81
+ heatmapValueByTrace = new Map();
82
+ heatmapHoverSizeByTrace = new Map();
83
+ // cache normalized markers per trace for CPU operations
84
+ markerNormByTrace = new Map();
85
+ markerNormLayers = [];
86
+ // GPU id mapping (markers only)
87
+ idRanges = [];
88
+ // Hover sorting for hovermode x/y (only built for smaller traces)
89
+ xSorted = [];
90
+ ySorted = [];
91
+ // Tooltip / hover
92
+ hoverRpx = 8;
93
+ hoverThrottleMs = 16;
94
+ lastHoverTs = 0;
95
+ hoverRaf = 0;
96
+ aspectLockEnabled = false;
97
+ performanceMode = "balanced";
98
+ // ---- CPU grid index (screen space, stored in "grid base space") ----
99
+ gridCellPx = 18;
100
+ gridMap = new Map(); // cell -> global indices
101
+ gridX = new Float32Array(0); // base-space x
102
+ gridY = new Float32Array(0); // base-space y
103
+ gridTrace = new Uint32Array(0); // global -> trace
104
+ gridPoint = new Uint32Array(0); // global -> point
105
+ gridBuilt = false;
106
+ gridRebuildPending = false;
107
+ gridRebuildTimer = null;
108
+ gridLastBuildTs = 0;
109
+ gridMinBuildIntervalMs = 60;
110
+ // grid transform signature - IMPROVED: now checks translation too
111
+ lastGridZoomK = 1;
112
+ lastGridZoomX = 0;
113
+ lastGridZoomY = 0;
114
+ gridMinScaleRelDelta = 0.06; // rebuild if scale changes > ~6%
115
+ gridMinTransRelDelta = 0.3; // NEW: rebuild if translation > 30% of plot size
116
+ // Performance monitoring
117
+ enablePerfMonitoring = false;
118
+ perfStats = {
119
+ lastGridBuildMs: 0,
120
+ avgGridBuildMs: 0,
121
+ gridBuildCount: 0
122
+ };
123
+ /**
124
+ * Create a new chart and start async renderer initialization immediately.
125
+ */
126
+ constructor(target, opts) {
127
+ this.root = typeof target === "string" ? document.querySelector(target) : target;
128
+ if (!this.root)
129
+ throw new Error("Chart target not found.");
130
+ this.width = opts.width;
131
+ this.height = opts.height;
132
+ this.layout = opts.layout ?? {};
133
+ this.basePadding = opts.padding ?? { l: 55, r: 20, t: 20, b: 45 };
134
+ this.padding = this.resolveLayoutPadding(this.layout, this.basePadding);
135
+ this.a11y = resolveChartA11y(opts.a11y);
136
+ this.theme = resolveChartTheme(opts.theme, this.a11y.highContrast);
137
+ this.toolbar = resolveChartToolbar(opts.toolbar);
138
+ this.traces = opts.traces.map((t) => this.toRuntimeTrace(t));
139
+ this.pickingMode = opts.pickingMode ?? "both";
140
+ this.onHoverHook = opts.onHover;
141
+ this.onClickHook = opts.onClick;
142
+ this.onZoomHook = opts.onZoom;
143
+ this.onLegendToggleHook = opts.onLegendToggle;
144
+ this.onSelectHook = opts.onSelect;
145
+ this.tooltipFormatter = opts.tooltip?.formatter;
146
+ this.tooltipRenderer = opts.tooltip?.renderer;
147
+ this.setPerformanceMode("balanced");
148
+ this.mountDom();
149
+ this.initPromise = this.init().catch((error) => {
150
+ if (!this.destroyed) {
151
+ console.error("[vertexa-chart] Chart initialization failed.", error);
152
+ }
153
+ });
154
+ }
155
+ async init() {
156
+ if (this.initialized || this.destroyed)
157
+ return;
158
+ await this.renderer.mount({ canvas: this.canvas });
159
+ if (this.destroyed) {
160
+ this.renderer.destroy();
161
+ return;
162
+ }
163
+ // Compile once before overlay creation
164
+ const scene = this.compileScene();
165
+ this.renderer.setLayers(scene);
166
+ this.rebuildGridIndex();
167
+ const xType = this.resolveAxisType("x");
168
+ const yType = this.resolveAxisType("y");
169
+ this.overlay = new OverlayD3({
170
+ svg: this.svg,
171
+ gridSvg: this.svgGrid,
172
+ width: this.width,
173
+ height: this.height,
174
+ padding: this.padding,
175
+ xAxis: this.makeOverlayAxisSpec("x", xType, this.xDomainNum),
176
+ yAxis: this.makeOverlayAxisSpec("y", yType, this.yDomainNum),
177
+ grid: this.resolveOverlayGrid(),
178
+ annotations: this.makeOverlayAnnotations(xType, yType),
179
+ onZoom: (z) => {
180
+ this.zoom = z;
181
+ this.render();
182
+ this.scheduleGridRebuild();
183
+ this.onZoomHook?.(z);
184
+ },
185
+ onHover: (e) => this.onHover(e),
186
+ onClick: this.onClickHook ? (e) => {
187
+ void this.handleClick(e).catch(() => {
188
+ // ignore click handler errors
189
+ });
190
+ } : undefined,
191
+ onBoxSelect: this.onSelectHook ? (e) => this.handleSelection(e) : undefined,
192
+ legend: {
193
+ items: this.isLegendVisible() ? this.makeLegendItems() : [],
194
+ onToggle: (i) => this.toggleTrace(i)
195
+ }
196
+ });
197
+ this.initialized = true;
198
+ this.render();
199
+ }
200
+ /**
201
+ * Replace all traces and redraw the chart.
202
+ *
203
+ * @param traces New full trace list.
204
+ * @throws Error if called after `destroy()`.
205
+ */
206
+ setTraces(traces) {
207
+ this.assertActive("setTraces");
208
+ this.traces = traces.map((t) => this.toRuntimeTrace(t));
209
+ if (!this.initialized)
210
+ return;
211
+ const scene = this.compileScene();
212
+ this.renderer.setLayers(scene);
213
+ const xType = this.resolveAxisType("x");
214
+ const yType = this.resolveAxisType("y");
215
+ this.overlay.setAxes(this.makeOverlayAxisSpec("x", xType, this.xDomainNum), this.makeOverlayAxisSpec("y", yType, this.yDomainNum));
216
+ this.overlay.setGrid(this.resolveOverlayGrid());
217
+ this.overlay.setAnnotations(this.makeOverlayAnnotations(xType, yType));
218
+ this.overlay.setLegend(this.isLegendVisible() ? this.makeLegendItems() : [], (i) => this.toggleTrace(i));
219
+ this.gridBuilt = false;
220
+ this.scheduleGridRebuild();
221
+ this.render();
222
+ }
223
+ /**
224
+ * Incrementally append points to one or more traces and redraw.
225
+ *
226
+ * Use `maxPoints` for sliding-window behavior.
227
+ *
228
+ * @throws Error if called after `destroy()`.
229
+ * @throws RangeError if a target `traceIndex` does not exist.
230
+ */
231
+ appendPoints(updates, options) {
232
+ this.assertActive("appendPoints");
233
+ const updateList = Array.isArray(updates) ? updates : [updates];
234
+ if (updateList.length === 0)
235
+ return;
236
+ const defaultMaxPoints = normalizeMaxPoints(options?.maxPoints);
237
+ for (const update of updateList) {
238
+ const trace = this.traces[update.traceIndex];
239
+ if (!trace) {
240
+ throw new RangeError(`Chart.appendPoints(): traceIndex ${update.traceIndex} is out of range.`);
241
+ }
242
+ if (trace.type === "heatmap") {
243
+ throw new Error(`Chart.appendPoints(): traceIndex ${update.traceIndex} is a heatmap trace; use setTraces().`);
244
+ }
245
+ const xIn = Array.from(update.x);
246
+ const yIn = Array.from(update.y);
247
+ const n = Math.min(xIn.length, yIn.length);
248
+ if (n <= 0)
249
+ continue;
250
+ const xOut = toMutableDatumArray(trace.x);
251
+ const yOut = toMutableDatumArray(trace.y);
252
+ for (let i = 0; i < n; i++) {
253
+ xOut.push(xIn[i]);
254
+ yOut.push(yIn[i]);
255
+ }
256
+ const maxPoints = normalizeMaxPoints(update.maxPoints) ?? defaultMaxPoints;
257
+ if (maxPoints !== undefined && xOut.length > maxPoints) {
258
+ const trimCount = xOut.length - maxPoints;
259
+ xOut.splice(0, trimCount);
260
+ yOut.splice(0, trimCount);
261
+ }
262
+ trace.x = xOut;
263
+ trace.y = yOut;
264
+ }
265
+ if (!this.initialized)
266
+ return;
267
+ const scene = this.compileScene();
268
+ this.renderer.setLayers(scene);
269
+ const xType = this.resolveAxisType("x");
270
+ const yType = this.resolveAxisType("y");
271
+ this.overlay.setAxes(this.makeOverlayAxisSpec("x", xType, this.xDomainNum), this.makeOverlayAxisSpec("y", yType, this.yDomainNum));
272
+ this.overlay.setGrid(this.resolveOverlayGrid());
273
+ this.overlay.setAnnotations(this.makeOverlayAnnotations(xType, yType));
274
+ this.gridBuilt = false;
275
+ this.scheduleGridRebuild();
276
+ this.render();
277
+ }
278
+ /**
279
+ * Export the current chart view as a PNG image.
280
+ */
281
+ async exportPng(options = {}) {
282
+ this.assertActive("exportPng");
283
+ if (this.initPromise) {
284
+ await this.initPromise;
285
+ }
286
+ const pixelRatio = normalizeExportPixelRatio(options.pixelRatio);
287
+ const exportWidth = Math.max(1, Math.round(this.width * pixelRatio));
288
+ const exportHeight = Math.max(1, Math.round(this.height * pixelRatio));
289
+ const exportCanvas = this.createExportCanvas(exportWidth, exportHeight);
290
+ const ctx = this.getExport2dContext(exportCanvas);
291
+ ctx.imageSmoothingEnabled = true;
292
+ ctx.imageSmoothingQuality = "high";
293
+ const background = resolveString(options.background, this.theme.colors.background);
294
+ ctx.fillStyle = background;
295
+ ctx.fillRect(0, 0, exportWidth, exportHeight);
296
+ // Base WebGPU render layer. Some browsers do not reliably support drawing
297
+ // a WebGPU canvas directly into a 2D canvas context.
298
+ await this.drawCanvasLayerToContext(ctx, exportWidth, exportHeight, pixelRatio);
299
+ if (options.includeGrid ?? true) {
300
+ await this.drawSvgLayerToContext(ctx, this.svgGrid, exportWidth, exportHeight);
301
+ }
302
+ if (options.includeOverlay ?? true) {
303
+ await this.drawSvgLayerToContext(ctx, this.svg, exportWidth, exportHeight);
304
+ }
305
+ return canvasToPngBlob(exportCanvas);
306
+ }
307
+ /**
308
+ * Export the current chart view as an SVG document.
309
+ *
310
+ * The rendered plot layer is embedded as a PNG image to preserve the WebGPU output,
311
+ * while grid/overlay layers remain SVG.
312
+ */
313
+ async exportSvg(options = {}) {
314
+ this.assertActive("exportSvg");
315
+ if (this.initPromise) {
316
+ await this.initPromise;
317
+ }
318
+ const pixelRatio = normalizeExportPixelRatio(options.pixelRatio);
319
+ const background = resolveString(options.background, this.theme.colors.background);
320
+ const includePlot = options.includePlot ?? true;
321
+ const includeGrid = options.includeGrid ?? true;
322
+ const includeOverlay = options.includeOverlay ?? true;
323
+ const parts = [
324
+ `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${this.width}" height="${this.height}" viewBox="0 0 ${this.width} ${this.height}" preserveAspectRatio="none">`,
325
+ `<rect x="0" y="0" width="${this.width}" height="${this.height}" fill="${escapeXmlAttribute(background)}"/>`
326
+ ];
327
+ if (includePlot) {
328
+ const imageHref = await this.captureCanvasLayerDataUrl(pixelRatio);
329
+ const escaped = escapeXmlAttribute(imageHref);
330
+ parts.push(`<image x="0" y="0" width="${this.width}" height="${this.height}" preserveAspectRatio="none" href="${escaped}" xlink:href="${escaped}"/>`);
331
+ }
332
+ if (includeGrid) {
333
+ parts.push(this.serializeSvgLayerForExport(this.svgGrid));
334
+ }
335
+ if (includeOverlay) {
336
+ parts.push(this.serializeSvgLayerForExport(this.svg));
337
+ }
338
+ parts.push("</svg>");
339
+ return new Blob([parts.join("")], { type: "image/svg+xml;charset=utf-8" });
340
+ }
341
+ /**
342
+ * Export chart points as CSV rows.
343
+ */
344
+ exportCsvPoints(options = {}) {
345
+ this.assertActive("exportCsvPoints");
346
+ const includeHeader = options.includeHeader ?? true;
347
+ const includeHidden = options.includeHidden ?? false;
348
+ const rows = [];
349
+ if (includeHeader) {
350
+ rows.push("traceIndex,traceName,traceType,pointIndex,x,y,z");
351
+ }
352
+ for (let traceIndex = 0; traceIndex < this.traces.length; traceIndex++) {
353
+ const trace = this.traces[traceIndex];
354
+ if (!trace)
355
+ continue;
356
+ if (!includeHidden && (trace.visible ?? true) !== true)
357
+ continue;
358
+ const traceName = trace.name ?? "";
359
+ if (trace.type === "heatmap") {
360
+ const xVals = Array.from(trace.x);
361
+ const yVals = Array.from(trace.y);
362
+ const zRows = Array.from(trace.z, (row) => Array.from(row));
363
+ let pointIndex = 0;
364
+ for (let rowIndex = 0; rowIndex < zRows.length; rowIndex++) {
365
+ const yDatum = yVals[rowIndex];
366
+ if (yDatum === undefined)
367
+ continue;
368
+ const zRow = zRows[rowIndex];
369
+ const colCount = Math.min(xVals.length, zRow.length);
370
+ for (let colIndex = 0; colIndex < colCount; colIndex++) {
371
+ const xDatum = xVals[colIndex];
372
+ if (xDatum === undefined)
373
+ continue;
374
+ const zValue = zRow[colIndex];
375
+ rows.push(toCsvRow([
376
+ String(traceIndex),
377
+ traceName,
378
+ trace.type,
379
+ String(pointIndex),
380
+ fmtDatum(xDatum),
381
+ fmtDatum(yDatum),
382
+ Number.isFinite(zValue) ? String(zValue) : ""
383
+ ]));
384
+ pointIndex += 1;
385
+ }
386
+ }
387
+ continue;
388
+ }
389
+ const xs = Array.from(trace.x);
390
+ const ys = Array.from(trace.y);
391
+ const n = Math.min(xs.length, ys.length);
392
+ for (let pointIndex = 0; pointIndex < n; pointIndex++) {
393
+ rows.push(toCsvRow([
394
+ String(traceIndex),
395
+ traceName,
396
+ trace.type,
397
+ String(pointIndex),
398
+ fmtDatum(xs[pointIndex]),
399
+ fmtDatum(ys[pointIndex]),
400
+ ""
401
+ ]));
402
+ }
403
+ }
404
+ return new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8" });
405
+ }
406
+ /**
407
+ * Replace the chart layout and redraw.
408
+ *
409
+ * @param layout New layout object.
410
+ * @throws Error if called after `destroy()`.
411
+ */
412
+ setLayout(layout) {
413
+ this.assertActive("setLayout");
414
+ this.layout = layout;
415
+ this.padding = this.resolveLayoutPadding(this.layout, this.basePadding);
416
+ this.applyAriaAttributes();
417
+ if (!this.initialized)
418
+ return;
419
+ this.overlay.setSize(this.width, this.height, this.padding);
420
+ const scene = this.compileScene();
421
+ this.renderer.setLayers(scene);
422
+ const xType = this.resolveAxisType("x");
423
+ const yType = this.resolveAxisType("y");
424
+ this.overlay.setAxes(this.makeOverlayAxisSpec("x", xType, this.xDomainNum), this.makeOverlayAxisSpec("y", yType, this.yDomainNum));
425
+ this.overlay.setGrid(this.resolveOverlayGrid());
426
+ this.overlay.setAnnotations(this.makeOverlayAnnotations(xType, yType));
427
+ this.overlay.setLegend(this.isLegendVisible() ? this.makeLegendItems() : [], (i) => this.toggleTrace(i));
428
+ this.gridBuilt = false;
429
+ this.scheduleGridRebuild();
430
+ this.render();
431
+ }
432
+ // Internal controls not part of the public API contract.
433
+ setLOD(enabled) {
434
+ this.renderer.setLOD(enabled);
435
+ this.render();
436
+ }
437
+ setPerformanceMode(mode) {
438
+ this.assertActive("setPerformanceMode");
439
+ this.performanceMode = mode;
440
+ if (mode === "quality") {
441
+ this.hoverThrottleMs = 8;
442
+ this.hoverRpx = 9;
443
+ this.renderer.setLOD(false);
444
+ }
445
+ else if (mode === "max-fps") {
446
+ this.hoverThrottleMs = 42;
447
+ this.hoverRpx = 6;
448
+ this.renderer.setLOD(true);
449
+ }
450
+ else {
451
+ this.hoverThrottleMs = 16;
452
+ this.hoverRpx = 8;
453
+ this.renderer.setLOD(true);
454
+ }
455
+ if (this.initialized)
456
+ this.render();
457
+ }
458
+ /**
459
+ * Read runtime performance stats for rendering and picking.
460
+ */
461
+ getPerformanceStats() {
462
+ const rendererStats = this.renderer.getStats();
463
+ const fpsBaseMs = rendererStats.avgRenderMs > 0 ? rendererStats.avgRenderMs : rendererStats.lastRenderMs;
464
+ return {
465
+ fps: fpsBaseMs > 0 ? 1000 / fpsBaseMs : 0,
466
+ sampledPoints: rendererStats.effectiveSampledPoints,
467
+ renderMs: {
468
+ last: rendererStats.lastRenderMs,
469
+ avg: rendererStats.avgRenderMs
470
+ },
471
+ pickMs: {
472
+ last: rendererStats.lastPickMs,
473
+ avg: rendererStats.avgPickMs
474
+ },
475
+ frameCount: rendererStats.frameCount
476
+ };
477
+ }
478
+ toggleTrace(index) {
479
+ const t = this.traces[index];
480
+ if (!t)
481
+ return;
482
+ // Toggle between true and legendonly (keeps legend visible)
483
+ const previousVisible = (t.visible ?? true);
484
+ t.visible = previousVisible === true ? "legendonly" : true;
485
+ const visible = (t.visible ?? true);
486
+ this.onLegendToggleHook?.({
487
+ traceIndex: index,
488
+ previousVisible,
489
+ visible,
490
+ trace: { ...t }
491
+ });
492
+ const scene = this.compileScene();
493
+ this.renderer.setLayers(scene);
494
+ this.rebuildGridIndex();
495
+ const xType = this.resolveAxisType("x");
496
+ const yType = this.resolveAxisType("y");
497
+ this.overlay.setAxes(this.makeOverlayAxisSpec("x", xType, this.xDomainNum), this.makeOverlayAxisSpec("y", yType, this.yDomainNum));
498
+ this.overlay.setGrid(this.resolveOverlayGrid());
499
+ this.overlay.setAnnotations(this.makeOverlayAnnotations(xType, yType));
500
+ this.overlay.setLegend(this.isLegendVisible() ? this.makeLegendItems() : [], (i) => this.toggleTrace(i));
501
+ this.gridBuilt = false;
502
+ this.scheduleGridRebuild();
503
+ this.render();
504
+ }
505
+ /**
506
+ * Resize the chart viewport in CSS pixels and redraw.
507
+ *
508
+ * @param width New outer width in CSS pixels.
509
+ * @param height New outer height in CSS pixels.
510
+ * @throws Error if called after `destroy()`.
511
+ */
512
+ setSize(width, height) {
513
+ this.assertActive("setSize");
514
+ this.width = width;
515
+ this.height = height;
516
+ this.container.style.width = `${width}px`;
517
+ this.container.style.height = `${height}px`;
518
+ this.canvas.style.width = `${width}px`;
519
+ this.canvas.style.height = `${height}px`;
520
+ this.svgGrid.setAttribute("width", String(width));
521
+ this.svgGrid.setAttribute("height", String(height));
522
+ this.svgGrid.setAttribute("viewBox", `0 0 ${width} ${height}`);
523
+ this.svgGrid.setAttribute("preserveAspectRatio", "none");
524
+ this.svg.setAttribute("width", String(width));
525
+ this.svg.setAttribute("height", String(height));
526
+ this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
527
+ this.svg.setAttribute("preserveAspectRatio", "none");
528
+ if (!this.initialized)
529
+ return;
530
+ this.overlay.setSize(width, height, this.padding);
531
+ this.render();
532
+ this.gridBuilt = false;
533
+ this.scheduleGridRebuild();
534
+ if (this.aspectLockEnabled) {
535
+ this.setAspectLock(true);
536
+ }
537
+ }
538
+ panBy(dxCss, dyCss) {
539
+ this.assertActive("panBy");
540
+ if (!this.initialized)
541
+ return;
542
+ this.overlay.panBy(dxCss, dyCss);
543
+ }
544
+ zoomBy(factor, centerPlot) {
545
+ this.assertActive("zoomBy");
546
+ if (!this.initialized)
547
+ return;
548
+ this.overlay.zoomBy(factor, centerPlot);
549
+ }
550
+ resetView() {
551
+ this.assertActive("resetView");
552
+ if (!this.initialized)
553
+ return;
554
+ this.overlay.resetZoom();
555
+ }
556
+ fitToData() {
557
+ this.assertActive("fitToData");
558
+ const xAxis = this.getAxis("x");
559
+ const yAxis = this.getAxis("y");
560
+ const nextX = stripAxisBounds(xAxis);
561
+ const nextY = stripAxisBounds(yAxis);
562
+ const layoutWithDataBounds = this.setAxisInLayout(this.setAxisInLayout(this.layout, "x", nextX), "y", nextY);
563
+ this.setLayout(layoutWithDataBounds);
564
+ this.resetView();
565
+ }
566
+ autoscaleY() {
567
+ this.assertActive("autoscaleY");
568
+ const yAxis = this.getAxis("y");
569
+ const yType = this.resolveAxisType("y");
570
+ const xType = this.resolveAxisType("x");
571
+ const [visibleX0, visibleX1] = this.getVisibleAxisRangeNum("x");
572
+ const [xMin, xMax] = visibleX0 <= visibleX1 ? [visibleX0, visibleX1] : [visibleX1, visibleX0];
573
+ let min = Number.POSITIVE_INFINITY;
574
+ let max = Number.NEGATIVE_INFINITY;
575
+ for (const trace of this.traces) {
576
+ const vis = trace.visible ?? true;
577
+ if (vis !== true)
578
+ continue;
579
+ if (trace.type === "heatmap") {
580
+ const xs = Array.from(trace.x);
581
+ const hasVisibleX = xs.some((xDatum) => {
582
+ const x = toNumber(xDatum, xType);
583
+ return Number.isFinite(x) && x >= xMin && x <= xMax;
584
+ });
585
+ if (!hasVisibleX)
586
+ continue;
587
+ for (const yDatum of Array.from(trace.y)) {
588
+ const y = toNumber(yDatum, yType);
589
+ if (!Number.isFinite(y) || (yType === "log" && y <= 0))
590
+ continue;
591
+ if (y < min)
592
+ min = y;
593
+ if (y > max)
594
+ max = y;
595
+ }
596
+ continue;
597
+ }
598
+ const n = Math.min(trace.x.length, trace.y.length);
599
+ for (let i = 0; i < n; i++) {
600
+ const x = toNumber(trace.x[i], xType);
601
+ if (!Number.isFinite(x) || x < xMin || x > xMax)
602
+ continue;
603
+ const y = toNumber(trace.y[i], yType);
604
+ if (!Number.isFinite(y) || (yType === "log" && y <= 0))
605
+ continue;
606
+ if (y < min)
607
+ min = y;
608
+ if (y > max)
609
+ max = y;
610
+ }
611
+ }
612
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
613
+ const domain = this.computeAxisDomain(this.traces, "y", yAxis, yType);
614
+ min = domain[0];
615
+ max = domain[1];
616
+ }
617
+ if (min === max) {
618
+ min -= 0.5;
619
+ max += 0.5;
620
+ }
621
+ else {
622
+ const pad = (max - min) * 0.02;
623
+ min -= pad;
624
+ max += pad;
625
+ }
626
+ const nextAxis = {
627
+ ...(yAxis ?? {}),
628
+ autorange: false,
629
+ domain: [fromAxisNumber(min, yType), fromAxisNumber(max, yType)]
630
+ };
631
+ delete nextAxis.range;
632
+ const nextLayout = this.setAxisInLayout(this.layout, "y", nextAxis);
633
+ this.setLayout(nextLayout);
634
+ }
635
+ setAspectLock(enabled) {
636
+ this.assertActive("setAspectLock");
637
+ this.aspectLockEnabled = enabled;
638
+ if (!enabled)
639
+ return;
640
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
641
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
642
+ const xType = this.resolveAxisType("x");
643
+ const yType = this.resolveAxisType("y");
644
+ const xSpan = axisSpan(this.xDomainNum, xType);
645
+ if (!Number.isFinite(xSpan) || xSpan <= 0)
646
+ return;
647
+ const targetYSpan = xSpan * (plotH / plotW);
648
+ if (!Number.isFinite(targetYSpan) || targetYSpan <= 0)
649
+ return;
650
+ const [y0, y1] = this.yDomainNum;
651
+ const nextDomain = lockAxisSpan([y0, y1], targetYSpan, yType);
652
+ const yAxis = this.getAxis("y");
653
+ const nextAxis = {
654
+ ...(yAxis ?? {}),
655
+ autorange: false,
656
+ domain: [fromAxisNumber(nextDomain[0], yType), fromAxisNumber(nextDomain[1], yType)]
657
+ };
658
+ delete nextAxis.range;
659
+ this.setLayout(this.setAxisInLayout(this.layout, "y", nextAxis));
660
+ }
661
+ /**
662
+ * Release GPU/DOM resources and make the chart unusable.
663
+ *
664
+ * Idempotent: calling this multiple times is safe.
665
+ */
666
+ destroy() {
667
+ if (this.destroyed)
668
+ return;
669
+ this.destroyed = true;
670
+ if (this.hoverRaf) {
671
+ cancelAnimationFrame(this.hoverRaf);
672
+ this.hoverRaf = 0;
673
+ }
674
+ if (this.gridRebuildTimer !== null) {
675
+ clearTimeout(this.gridRebuildTimer);
676
+ this.gridRebuildTimer = null;
677
+ }
678
+ this.gridRebuildPending = false;
679
+ this.overlay?.setHoverGuides(null);
680
+ this.renderer.setHoverHighlight(null);
681
+ this.renderer.destroy();
682
+ this.markerNormByTrace.clear();
683
+ this.heatmapValueByTrace.clear();
684
+ this.heatmapHoverSizeByTrace.clear();
685
+ this.markerNormLayers = [];
686
+ this.idRanges = [];
687
+ this.xSorted = [];
688
+ this.ySorted = [];
689
+ this.traceData = [];
690
+ this.gridMap.clear();
691
+ this.gridX = new Float32Array(0);
692
+ this.gridY = new Float32Array(0);
693
+ this.gridTrace = new Uint32Array(0);
694
+ this.gridPoint = new Uint32Array(0);
695
+ this.gridBuilt = false;
696
+ this.initialized = false;
697
+ this.container.removeEventListener("keydown", this.handleContainerKeyDown);
698
+ this.cleanupToolbar();
699
+ if (document.fullscreenElement === this.container) {
700
+ void document.exitFullscreen().catch(() => {
701
+ // ignore teardown fullscreen errors
702
+ });
703
+ }
704
+ if (this.root.contains(this.container)) {
705
+ this.root.removeChild(this.container);
706
+ }
707
+ else {
708
+ this.root.innerHTML = "";
709
+ }
710
+ }
711
+ assertActive(method) {
712
+ if (this.destroyed) {
713
+ throw new Error(`Chart.${method}() called after destroy().`);
714
+ }
715
+ }
716
+ toRuntimeTrace(trace) {
717
+ return {
718
+ ...trace,
719
+ visible: trace.visible ?? true,
720
+ x: Array.from(trace.x),
721
+ y: Array.from(trace.y)
722
+ };
723
+ }
724
+ resolveLayoutPadding(layout, base) {
725
+ const margin = layout.margin;
726
+ if (!margin)
727
+ return { ...base };
728
+ return {
729
+ l: coerceMargin(margin.left, base.l),
730
+ r: coerceMargin(margin.right, base.r),
731
+ t: coerceMargin(margin.top, base.t),
732
+ b: coerceMargin(margin.bottom, base.b)
733
+ };
734
+ }
735
+ isLegendVisible() {
736
+ return this.layout.legend?.show ?? true;
737
+ }
738
+ getAxis(which) {
739
+ if (which === "x")
740
+ return this.layout.xaxis ?? this.layout.axes?.x;
741
+ return this.layout.yaxis ?? this.layout.axes?.y;
742
+ }
743
+ setAxisInLayout(layout, which, axis) {
744
+ const next = { ...layout };
745
+ if (which === "x") {
746
+ if (layout.axes?.x !== undefined || (layout.axes && layout.xaxis === undefined)) {
747
+ next.axes = { ...(layout.axes ?? {}), x: axis };
748
+ }
749
+ else {
750
+ next.xaxis = axis;
751
+ }
752
+ return next;
753
+ }
754
+ if (layout.axes?.y !== undefined || (layout.axes && layout.yaxis === undefined)) {
755
+ next.axes = { ...(layout.axes ?? {}), y: axis };
756
+ }
757
+ else {
758
+ next.yaxis = axis;
759
+ }
760
+ return next;
761
+ }
762
+ getVisibleAxisRangeNum(which) {
763
+ const type = this.resolveAxisType(which);
764
+ const domain = which === "x" ? this.xDomainNum : this.yDomainNum;
765
+ const plotSize = which === "x"
766
+ ? Math.max(1, this.width - this.padding.l - this.padding.r)
767
+ : Math.max(1, this.height - this.padding.t - this.padding.b);
768
+ const translate = which === "x" ? this.zoom.x : this.zoom.y;
769
+ const k = Math.max(1e-6, this.zoom.k);
770
+ const n0 = (0 - translate) / (plotSize * k);
771
+ const n1 = (plotSize - translate) / (plotSize * k);
772
+ return [
773
+ fromNormalizedDomain(n0, domain, type),
774
+ fromNormalizedDomain(n1, domain, type)
775
+ ];
776
+ }
777
+ getHoverMode() {
778
+ const mode = this.layout.hovermode;
779
+ return mode === "x" || mode === "y" || mode === "none" || mode === "closest"
780
+ ? mode
781
+ : "closest";
782
+ }
783
+ createExportCanvas(width, height) {
784
+ const canvas = document.createElement("canvas");
785
+ canvas.width = width;
786
+ canvas.height = height;
787
+ return canvas;
788
+ }
789
+ getExport2dContext(canvas) {
790
+ const ctx = canvas.getContext("2d");
791
+ if (!ctx)
792
+ throw new Error("Chart.exportPng(): 2D canvas context is not available.");
793
+ return ctx;
794
+ }
795
+ async drawSvgLayerToContext(ctx, svg, exportWidth, exportHeight) {
796
+ const dataUrl = serializeSvgToDataUrl(svg, this.width, this.height);
797
+ const image = await loadImageFromUrl(dataUrl);
798
+ ctx.drawImage(image, 0, 0, exportWidth, exportHeight);
799
+ }
800
+ serializeSvgLayerForExport(svg) {
801
+ return serializeSvgMarkup(svg, this.width, this.height);
802
+ }
803
+ async captureCanvasLayerDataUrl(pixelRatio) {
804
+ const exportWidth = Math.max(1, Math.round(this.width * pixelRatio));
805
+ const exportHeight = Math.max(1, Math.round(this.height * pixelRatio));
806
+ const exportCanvas = this.createExportCanvas(exportWidth, exportHeight);
807
+ const ctx = this.getExport2dContext(exportCanvas);
808
+ await this.drawCanvasLayerToContext(ctx, exportWidth, exportHeight, pixelRatio);
809
+ return canvasToPngDataUrl(exportCanvas);
810
+ }
811
+ async drawCanvasLayerToContext(ctx, exportWidth, exportHeight, exportDpr) {
812
+ const capture = this.renderer?.captureFrameImageData;
813
+ if (typeof capture === "function") {
814
+ try {
815
+ const imageData = await capture.call(this.renderer, {
816
+ width: this.width,
817
+ height: this.height,
818
+ dpr: exportDpr,
819
+ padding: this.padding,
820
+ zoom: this.zoom
821
+ });
822
+ const gpuCanvas = this.createExportCanvas(imageData.width, imageData.height);
823
+ this.getExport2dContext(gpuCanvas).putImageData(imageData, 0, 0);
824
+ ctx.drawImage(gpuCanvas, 0, 0, exportWidth, exportHeight);
825
+ return;
826
+ }
827
+ catch {
828
+ // Fall through to canvas snapshot fallbacks.
829
+ }
830
+ }
831
+ if (typeof createImageBitmap === "function") {
832
+ try {
833
+ const bitmap = await createImageBitmap(this.canvas);
834
+ try {
835
+ ctx.drawImage(bitmap, 0, 0, exportWidth, exportHeight);
836
+ return;
837
+ }
838
+ finally {
839
+ bitmap.close();
840
+ }
841
+ }
842
+ catch {
843
+ // Fall through to blob/object URL snapshot path.
844
+ }
845
+ }
846
+ try {
847
+ const blob = await canvasToPngBlob(this.canvas);
848
+ const objectUrl = URL.createObjectURL(blob);
849
+ try {
850
+ const image = await loadImageFromUrl(objectUrl);
851
+ ctx.drawImage(image, 0, 0, exportWidth, exportHeight);
852
+ return;
853
+ }
854
+ finally {
855
+ URL.revokeObjectURL(objectUrl);
856
+ }
857
+ }
858
+ catch {
859
+ // Fall through to direct drawImage as a last resort.
860
+ }
861
+ ctx.drawImage(this.canvas, 0, 0, exportWidth, exportHeight);
862
+ }
863
+ resolveAxisType(which) {
864
+ const axis = this.getAxis(which);
865
+ if (axis?.type)
866
+ return axis.type;
867
+ // Infer time axes from Date-valued data when no explicit type is provided.
868
+ for (const trace of this.traces) {
869
+ const arr = which === "x" ? trace.x : trace.y;
870
+ const n = arr.length;
871
+ if (n === 0)
872
+ continue;
873
+ const first = arr[0];
874
+ if (first instanceof Date)
875
+ return "time";
876
+ const last = arr[n - 1];
877
+ if (last instanceof Date)
878
+ return "time";
879
+ const probe = Math.min(n, 8);
880
+ for (let i = 1; i < probe; i++) {
881
+ if (arr[i] instanceof Date)
882
+ return "time";
883
+ }
884
+ }
885
+ return "linear";
886
+ }
887
+ makeOverlayAxisSpec(which, type, domain) {
888
+ const axis = this.getAxis(which);
889
+ const tickValues = axis?.tickValues?.map((v) => toAxisDatum(v, type));
890
+ return {
891
+ type,
892
+ domain,
893
+ title: axis?.title,
894
+ tickValues,
895
+ tickFormat: axis?.tickFormat,
896
+ precision: axis?.precision,
897
+ timeFormat: axis?.timeFormat,
898
+ style: {
899
+ fontFamily: this.theme.axis.fontFamily,
900
+ fontSizePx: this.theme.axis.fontSizePx
901
+ }
902
+ };
903
+ }
904
+ makeOverlayAnnotations(xType, yType) {
905
+ const annotations = this.layout.annotations;
906
+ if (!annotations || annotations.length === 0)
907
+ return [];
908
+ const out = [];
909
+ for (const a of annotations) {
910
+ if (a.type === "line") {
911
+ out.push({
912
+ ...a,
913
+ x0: toAxisDatum(a.x0, xType),
914
+ y0: toAxisDatum(a.y0, yType),
915
+ x1: toAxisDatum(a.x1, xType),
916
+ y1: toAxisDatum(a.y1, yType)
917
+ });
918
+ continue;
919
+ }
920
+ if (a.type === "region") {
921
+ out.push({
922
+ ...a,
923
+ x0: toAxisDatum(a.x0, xType),
924
+ y0: toAxisDatum(a.y0, yType),
925
+ x1: toAxisDatum(a.x1, xType),
926
+ y1: toAxisDatum(a.y1, yType)
927
+ });
928
+ continue;
929
+ }
930
+ out.push({
931
+ ...a,
932
+ x: toAxisDatum(a.x, xType),
933
+ y: toAxisDatum(a.y, yType)
934
+ });
935
+ }
936
+ return out;
937
+ }
938
+ resolveOverlayGrid() {
939
+ const grid = this.layout.grid;
940
+ return {
941
+ show: grid?.show ?? this.theme.grid.show,
942
+ color: grid?.color ?? this.theme.grid.color,
943
+ axisColor: grid?.axisColor ?? this.theme.axis.color,
944
+ textColor: grid?.textColor ?? this.theme.axis.textColor,
945
+ opacity: grid?.opacity ?? this.theme.grid.opacity,
946
+ strokeWidth: grid?.strokeWidth ?? this.theme.grid.strokeWidth
947
+ };
948
+ }
949
+ paletteColor(index) {
950
+ const palette = this.theme.colors.palette;
951
+ if (palette.length === 0) {
952
+ return DEFAULT_PALETTE[index % DEFAULT_PALETTE.length];
953
+ }
954
+ return palette[index % palette.length];
955
+ }
956
+ render() {
957
+ if (!this.initialized)
958
+ return;
959
+ if (this.destroyed)
960
+ return;
961
+ this.renderer.render({
962
+ width: this.width,
963
+ height: this.height,
964
+ dpr: this.dpr,
965
+ padding: this.padding,
966
+ zoom: this.zoom
967
+ });
968
+ }
969
+ // ----------------------------
970
+ // Hover handling
971
+ // ----------------------------
972
+ onHover(e) {
973
+ if (this.destroyed)
974
+ return;
975
+ const now = performance.now();
976
+ if (now - this.lastHoverTs < this.hoverThrottleMs)
977
+ return;
978
+ this.lastHoverTs = now;
979
+ const hovermode = this.getHoverMode();
980
+ if (!e.inside) {
981
+ this.overlay?.setHoverGuides(null);
982
+ this.renderer.setHoverHighlight(null);
983
+ this.hideTooltip();
984
+ this.requestRender();
985
+ this.emitHoverHook(e, hovermode, null);
986
+ return;
987
+ }
988
+ if (hovermode === "none") {
989
+ this.overlay.setHoverGuides({ mode: "none", xPlot: e.xPlot, yPlot: e.yPlot, inside: true });
990
+ this.renderer.setHoverHighlight(null);
991
+ this.showCursorTooltip(e);
992
+ this.requestRender();
993
+ this.emitHoverHook(e, hovermode, null);
994
+ return;
995
+ }
996
+ // Choose pick mode
997
+ const xNum = toNumber(e.xData, this.resolveAxisType("x"));
998
+ const yNum = toNumber(e.yData, this.resolveAxisType("y"));
999
+ let hit = null;
1000
+ if (hovermode === "x") {
1001
+ hit = this.pickSnapX(xNum, e.xSvg);
1002
+ }
1003
+ else if (hovermode === "y") {
1004
+ hit = this.pickSnapY(yNum, e.ySvg);
1005
+ }
1006
+ else {
1007
+ // closest (CPU grid first)
1008
+ hit = this.cpuPickClosest(e.xSvg, e.ySvg);
1009
+ }
1010
+ // Snap guides to picked point if present, else cursor
1011
+ if (hit) {
1012
+ const { xPlot, yPlot } = this.screenToPlot(hit.screenX, hit.screenY);
1013
+ this.overlay.setHoverGuides({ mode: hovermode, xPlot, yPlot, inside: true });
1014
+ }
1015
+ else {
1016
+ this.overlay.setHoverGuides({ mode: hovermode, xPlot: e.xPlot, yPlot: e.yPlot, inside: true });
1017
+ }
1018
+ // GPU override for closest mode (more accurate)
1019
+ if ((hovermode === "closest") && (this.pickingMode === "gpu" || this.pickingMode === "both")) {
1020
+ this.gpuPickOverride(e, hit).catch(() => {
1021
+ // ignore pick errors
1022
+ });
1023
+ }
1024
+ if (!hit) {
1025
+ this.renderer.setHoverHighlight(null);
1026
+ this.hideTooltip();
1027
+ this.requestRender();
1028
+ this.emitHoverHook(e, hovermode, null);
1029
+ return;
1030
+ }
1031
+ this.applyHover(hit);
1032
+ this.emitHoverHook(e, hovermode, hit);
1033
+ }
1034
+ async gpuPickOverride(e, cpuHit) {
1035
+ if (this.destroyed)
1036
+ return;
1037
+ const { pickX, pickY } = this.normalizePickCss(e.xSvg, e.ySvg);
1038
+ const id = await this.renderer.pick({
1039
+ width: this.width,
1040
+ height: this.height,
1041
+ dpr: this.dpr,
1042
+ padding: this.padding,
1043
+ zoom: this.zoom
1044
+ }, pickX, pickY);
1045
+ if (this.destroyed)
1046
+ return;
1047
+ const gpuHit = this.idToHit(id);
1048
+ if (!gpuHit) {
1049
+ // keep CPU hit if present
1050
+ return;
1051
+ }
1052
+ // If CPU and GPU disagree, prefer GPU
1053
+ if (!cpuHit || cpuHit.traceIndex !== gpuHit.traceIndex || cpuHit.pointIndex !== gpuHit.pointIndex) {
1054
+ this.applyHover(gpuHit);
1055
+ this.emitHoverHook(e, "closest", gpuHit);
1056
+ }
1057
+ }
1058
+ async handleClick(e) {
1059
+ if (this.destroyed)
1060
+ return;
1061
+ if (!this.onClickHook)
1062
+ return;
1063
+ if (!e.inside) {
1064
+ this.emitClickHook(e, null);
1065
+ return;
1066
+ }
1067
+ const hovermode = this.getHoverMode();
1068
+ if (hovermode === "none") {
1069
+ this.emitClickHook(e, null);
1070
+ return;
1071
+ }
1072
+ const xNum = toNumber(e.xData, this.resolveAxisType("x"));
1073
+ const yNum = toNumber(e.yData, this.resolveAxisType("y"));
1074
+ let hit = null;
1075
+ if (hovermode === "x") {
1076
+ hit = this.pickSnapX(xNum, e.xSvg);
1077
+ }
1078
+ else if (hovermode === "y") {
1079
+ hit = this.pickSnapY(yNum, e.ySvg);
1080
+ }
1081
+ else {
1082
+ hit = this.cpuPickClosest(e.xSvg, e.ySvg);
1083
+ if (this.pickingMode === "gpu" || this.pickingMode === "both") {
1084
+ try {
1085
+ const { pickX, pickY } = this.normalizePickCss(e.xSvg, e.ySvg);
1086
+ const id = await this.renderer.pick({
1087
+ width: this.width,
1088
+ height: this.height,
1089
+ dpr: this.dpr,
1090
+ padding: this.padding,
1091
+ zoom: this.zoom
1092
+ }, pickX, pickY);
1093
+ if (this.destroyed)
1094
+ return;
1095
+ hit = this.idToHit(id) ?? hit;
1096
+ }
1097
+ catch {
1098
+ // Keep CPU result on pick failures.
1099
+ }
1100
+ }
1101
+ }
1102
+ this.emitClickHook(e, hit);
1103
+ }
1104
+ normalizeHoverToCss(x, y) {
1105
+ // If x/y are already CSS px, they should be within [0..width/height].
1106
+ // If they are device px on a DPR=2 display, they'll be within [0..width*dpr].
1107
+ const looksLikeDevicePx = (x > this.width + 1 || y > this.height + 1) &&
1108
+ (x <= this.width * this.dpr + 2) &&
1109
+ (y <= this.height * this.dpr + 2);
1110
+ if (looksLikeDevicePx) {
1111
+ return { xCss: x / this.dpr, yCss: y / this.dpr };
1112
+ }
1113
+ return { xCss: x, yCss: y };
1114
+ }
1115
+ normalizePickCss(x, y) {
1116
+ const { xCss, yCss } = this.normalizeHoverToCss(x, y);
1117
+ const maxX = Math.max(0, this.width - Number.EPSILON);
1118
+ const maxY = Math.max(0, this.height - Number.EPSILON);
1119
+ return {
1120
+ pickX: Math.min(maxX, Math.max(0, xCss)),
1121
+ pickY: Math.min(maxY, Math.max(0, yCss))
1122
+ };
1123
+ }
1124
+ toChartPoint(hit) {
1125
+ if (!hit)
1126
+ return null;
1127
+ return {
1128
+ traceIndex: hit.traceIndex,
1129
+ pointIndex: hit.pointIndex,
1130
+ x: hit.x,
1131
+ y: hit.y,
1132
+ screenX: hit.screenX,
1133
+ screenY: hit.screenY
1134
+ };
1135
+ }
1136
+ emitHoverHook(e, mode, hit) {
1137
+ if (!this.onHoverHook)
1138
+ return;
1139
+ this.onHoverHook({
1140
+ mode,
1141
+ inside: e.inside,
1142
+ cursor: {
1143
+ screenX: e.xSvg,
1144
+ screenY: e.ySvg,
1145
+ xData: e.xData,
1146
+ yData: e.yData
1147
+ },
1148
+ point: this.toChartPoint(hit)
1149
+ });
1150
+ }
1151
+ emitClickHook(e, hit) {
1152
+ if (!this.onClickHook)
1153
+ return;
1154
+ this.onClickHook({
1155
+ inside: e.inside,
1156
+ cursor: {
1157
+ screenX: e.xSvg,
1158
+ screenY: e.ySvg,
1159
+ xData: e.xData,
1160
+ yData: e.yData
1161
+ },
1162
+ point: this.toChartPoint(hit)
1163
+ });
1164
+ }
1165
+ handleSelection(e) {
1166
+ if (!this.onSelectHook)
1167
+ return;
1168
+ const x0 = Math.min(e.x0Svg, e.x1Svg);
1169
+ const x1 = Math.max(e.x0Svg, e.x1Svg);
1170
+ const y0 = Math.min(e.y0Svg, e.y1Svg);
1171
+ const y1 = Math.max(e.y0Svg, e.y1Svg);
1172
+ const mode = e.mode ?? "box";
1173
+ const isLasso = mode === "lasso" && ("lassoSvg" in e);
1174
+ const lassoPoly = isLasso ? e.lassoSvg : undefined;
1175
+ const points = [];
1176
+ let totalPoints = 0;
1177
+ for (const layer of this.markerNormLayers) {
1178
+ const traceIndex = layer.traceIndex;
1179
+ const coords = layer.points01;
1180
+ const count = Math.floor(coords.length / 2);
1181
+ const pointIndices = [];
1182
+ for (let i = 0; i < count; i++) {
1183
+ const xn = coords[i * 2 + 0];
1184
+ const yn = coords[i * 2 + 1];
1185
+ if (!Number.isFinite(xn) || !Number.isFinite(yn))
1186
+ continue;
1187
+ const { screenX, screenY } = this.toScreenFromNorm(xn, yn);
1188
+ const insideBox = screenX >= x0 && screenX <= x1 && screenY >= y0 && screenY <= y1;
1189
+ const selected = mode === "lasso"
1190
+ ? (insideBox && lassoPoly ? pointInPolygon(screenX, screenY, lassoPoly) : false)
1191
+ : insideBox;
1192
+ if (selected) {
1193
+ pointIndices.push(i);
1194
+ }
1195
+ }
1196
+ if (pointIndices.length > 0) {
1197
+ points.push({ traceIndex, pointIndices });
1198
+ totalPoints += pointIndices.length;
1199
+ }
1200
+ }
1201
+ this.onSelectHook({
1202
+ mode,
1203
+ box: {
1204
+ x0: e.x0Svg,
1205
+ y0: e.y0Svg,
1206
+ x1: e.x1Svg,
1207
+ y1: e.y1Svg,
1208
+ x0Data: e.x0Data,
1209
+ y0Data: e.y0Data,
1210
+ x1Data: e.x1Data,
1211
+ y1Data: e.y1Data
1212
+ },
1213
+ lasso: isLasso
1214
+ ? {
1215
+ svg: e.lassoSvg,
1216
+ plot: e.lassoPlot,
1217
+ data: e.lassoData
1218
+ }
1219
+ : undefined,
1220
+ points,
1221
+ totalPoints
1222
+ });
1223
+ }
1224
+ // Back-compat for existing internal tests/callers.
1225
+ handleBoxSelect(e) {
1226
+ this.handleSelection(e);
1227
+ }
1228
+ applyHover(hit) {
1229
+ const trace = this.traces[hit.traceIndex];
1230
+ if (!trace)
1231
+ return;
1232
+ this.showTooltip(this.makeTooltipContext(trace, hit));
1233
+ const norm = this.getNormPoint(hit.traceIndex, hit.pointIndex);
1234
+ if (norm) {
1235
+ const baseColor = this.getTraceColor(trace, hit.traceIndex);
1236
+ const inner = cssColorToRgba(baseColor, 0.95);
1237
+ const outline = [0, 0, 0, 0.55];
1238
+ this.renderer.setHoverHighlight({
1239
+ point01: [norm.xn, norm.yn],
1240
+ sizePx: this.getTraceHoverSizePx(trace, hit.traceIndex),
1241
+ innerRgba: inner,
1242
+ outlineRgba: outline
1243
+ });
1244
+ }
1245
+ this.requestRender();
1246
+ }
1247
+ requestRender() {
1248
+ if (this.destroyed)
1249
+ return;
1250
+ if (this.hoverRaf)
1251
+ return;
1252
+ this.hoverRaf = requestAnimationFrame(() => {
1253
+ this.hoverRaf = 0;
1254
+ if (this.destroyed)
1255
+ return;
1256
+ this.render();
1257
+ });
1258
+ }
1259
+ screenToPlot(screenX, screenY) {
1260
+ return { xPlot: screenX - this.padding.l, yPlot: screenY - this.padding.t };
1261
+ }
1262
+ // ----------------------------
1263
+ // Picking: closest (CPU grid) + snap x/y
1264
+ // ----------------------------
1265
+ cpuPickClosest(xCss, yCss) {
1266
+ if (!this.gridBuilt)
1267
+ return this.cpuPickFallbackScan(xCss, yCss);
1268
+ // Check both scale AND translation changes
1269
+ const dk = Math.abs(this.zoom.k - this.lastGridZoomK) / Math.max(1e-6, this.lastGridZoomK);
1270
+ if (dk >= this.gridMinScaleRelDelta)
1271
+ return this.cpuPickFallbackScan(xCss, yCss);
1272
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
1273
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
1274
+ const relDeltaX = Math.abs(this.zoom.x - this.lastGridZoomX) / plotW;
1275
+ const relDeltaY = Math.abs(this.zoom.y - this.lastGridZoomY) / plotH;
1276
+ if (relDeltaX > this.gridMinTransRelDelta || relDeltaY > this.gridMinTransRelDelta) {
1277
+ return this.cpuPickFallbackScan(xCss, yCss);
1278
+ }
1279
+ // Convert pointer into grid base space (pan compensation)
1280
+ const dxPan = this.zoom.x - this.lastGridZoomX;
1281
+ const dyPan = this.zoom.y - this.lastGridZoomY;
1282
+ const xBase = xCss - dxPan;
1283
+ const yBase = yCss - dyPan;
1284
+ const r2 = this.hoverRpx * this.hoverRpx;
1285
+ const cx = Math.floor(xBase / this.gridCellPx);
1286
+ const cy = Math.floor(yBase / this.gridCellPx);
1287
+ const dc = Math.ceil(this.hoverRpx / this.gridCellPx);
1288
+ let bestGi = -1;
1289
+ let bestD2 = Number.POSITIVE_INFINITY;
1290
+ for (let oy = -dc; oy <= dc; oy++) {
1291
+ for (let ox = -dc; ox <= dc; ox++) {
1292
+ const key = this.gridKey(cx + ox, cy + oy);
1293
+ const bucket = this.gridMap.get(key);
1294
+ if (!bucket)
1295
+ continue;
1296
+ for (let bi = 0; bi < bucket.length; bi++) {
1297
+ const gi = bucket[bi];
1298
+ const px = this.gridX[gi];
1299
+ const py = this.gridY[gi];
1300
+ const dx = px - xBase;
1301
+ const dy = py - yBase;
1302
+ const d2 = dx * dx + dy * dy;
1303
+ if (d2 < r2 && d2 < bestD2) {
1304
+ bestD2 = d2;
1305
+ bestGi = gi;
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+ if (bestGi < 0)
1311
+ return null;
1312
+ const tIdx = this.gridTrace[bestGi];
1313
+ const pIdx = this.gridPoint[bestGi];
1314
+ const td = this.traceData[tIdx];
1315
+ if (!td)
1316
+ return null;
1317
+ const norm = this.getNormPoint(tIdx, pIdx);
1318
+ if (!norm)
1319
+ return null;
1320
+ const { screenX, screenY } = this.toScreenFromNorm(norm.xn, norm.yn);
1321
+ return { traceIndex: tIdx, pointIndex: pIdx, x: td.xs[pIdx], y: td.ys[pIdx], screenX, screenY };
1322
+ }
1323
+ cpuPickFallbackScan(xCss, yCss) {
1324
+ const cap = 40_000;
1325
+ const r2 = this.hoverRpx * this.hoverRpx;
1326
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
1327
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
1328
+ const ox = this.padding.l;
1329
+ const oy = this.padding.t;
1330
+ const k = this.zoom.k;
1331
+ const tx = this.zoom.x;
1332
+ const ty = this.zoom.y;
1333
+ let best = null;
1334
+ let bestD2 = Number.POSITIVE_INFINITY;
1335
+ let scanned = 0;
1336
+ for (const L of this.markerNormLayers) {
1337
+ const tIdx = L.traceIndex;
1338
+ const pts = L.points01;
1339
+ const count = pts.length / 2;
1340
+ const td = this.traceData[tIdx];
1341
+ if (!td)
1342
+ continue;
1343
+ for (let i = 0; i < count; i++) {
1344
+ scanned++;
1345
+ if (scanned > cap)
1346
+ return best;
1347
+ const xn = pts[i * 2 + 0];
1348
+ const yn = pts[i * 2 + 1];
1349
+ const px = ox + (xn * plotW) * k + tx;
1350
+ const py = oy + (yn * plotH) * k + ty;
1351
+ const dx = px - xCss;
1352
+ const dy = py - yCss;
1353
+ const d2 = dx * dx + dy * dy;
1354
+ if (d2 < r2 && d2 < bestD2) {
1355
+ bestD2 = d2;
1356
+ best = { traceIndex: tIdx, pointIndex: i, x: td.xs[i], y: td.ys[i], screenX: px, screenY: py };
1357
+ }
1358
+ }
1359
+ }
1360
+ return best;
1361
+ }
1362
+ pickSnapX(cursorXNum, cursorScreenX) {
1363
+ let best = null;
1364
+ let bestDx = Number.POSITIVE_INFINITY;
1365
+ for (const s of this.xSorted) {
1366
+ const tIdx = s.traceIndex;
1367
+ const td = this.traceData[tIdx];
1368
+ if (!td)
1369
+ continue;
1370
+ const j = lowerBoundIdx(s.order, s.xsNum, cursorXNum);
1371
+ const cand = [j - 1, j, j + 1];
1372
+ for (const cj of cand) {
1373
+ if (cj < 0 || cj >= s.order.length)
1374
+ continue;
1375
+ const pIdx = s.order[cj];
1376
+ const norm = this.getNormPoint(tIdx, pIdx);
1377
+ if (!norm)
1378
+ continue;
1379
+ const { screenX, screenY } = this.toScreenFromNorm(norm.xn, norm.yn);
1380
+ const dx = Math.abs(screenX - cursorScreenX);
1381
+ if (dx < bestDx) {
1382
+ bestDx = dx;
1383
+ best = { traceIndex: tIdx, pointIndex: pIdx, x: td.xs[pIdx], y: td.ys[pIdx], screenX, screenY };
1384
+ }
1385
+ }
1386
+ }
1387
+ return best;
1388
+ }
1389
+ pickSnapY(cursorYNum, cursorScreenY) {
1390
+ let best = null;
1391
+ let bestDy = Number.POSITIVE_INFINITY;
1392
+ for (const s of this.ySorted) {
1393
+ const tIdx = s.traceIndex;
1394
+ const td = this.traceData[tIdx];
1395
+ if (!td)
1396
+ continue;
1397
+ const j = lowerBoundIdx(s.order, s.ysNum, cursorYNum);
1398
+ const cand = [j - 1, j, j + 1];
1399
+ for (const cj of cand) {
1400
+ if (cj < 0 || cj >= s.order.length)
1401
+ continue;
1402
+ const pIdx = s.order[cj];
1403
+ const norm = this.getNormPoint(tIdx, pIdx);
1404
+ if (!norm)
1405
+ continue;
1406
+ const { screenX, screenY } = this.toScreenFromNorm(norm.xn, norm.yn);
1407
+ const dy = Math.abs(screenY - cursorScreenY);
1408
+ if (dy < bestDy) {
1409
+ bestDy = dy;
1410
+ best = { traceIndex: tIdx, pointIndex: pIdx, x: td.xs[pIdx], y: td.ys[pIdx], screenX, screenY };
1411
+ }
1412
+ }
1413
+ }
1414
+ return best;
1415
+ }
1416
+ // ----------------------------
1417
+ // Scene compilation
1418
+ // ----------------------------
1419
+ compileScene() {
1420
+ const traces = this.traces.map((trace, traceIndex) => ({ trace, traceIndex }));
1421
+ const xType = this.resolveAxisType("x");
1422
+ const yType = this.resolveAxisType("y");
1423
+ // Cache raw trace data by source trace index to keep ID->trace mapping stable.
1424
+ this.traceData = new Array(this.traces.length).fill(null);
1425
+ this.heatmapValueByTrace.clear();
1426
+ this.heatmapHoverSizeByTrace.clear();
1427
+ for (const { trace, traceIndex } of traces) {
1428
+ if (trace.type === "heatmap") {
1429
+ const xVals = Array.from(trace.x);
1430
+ const yVals = Array.from(trace.y);
1431
+ const rows = this.toHeatmapRows(trace.z);
1432
+ const ny = Math.min(yVals.length, rows.length);
1433
+ const xs = [];
1434
+ const ys = [];
1435
+ const zs = [];
1436
+ for (let yi = 0; yi < ny; yi++) {
1437
+ const row = rows[yi];
1438
+ const nx = Math.min(xVals.length, row.length);
1439
+ for (let xi = 0; xi < nx; xi++) {
1440
+ const z = Number(row[xi]);
1441
+ if (!Number.isFinite(z))
1442
+ continue;
1443
+ xs.push(xVals[xi]);
1444
+ ys.push(yVals[yi]);
1445
+ zs.push(z);
1446
+ }
1447
+ }
1448
+ this.traceData[traceIndex] = { xs, ys, name: trace.name ?? `Trace ${traceIndex + 1}` };
1449
+ this.heatmapValueByTrace.set(traceIndex, new Float64Array(zs));
1450
+ continue;
1451
+ }
1452
+ const n = Math.min(trace.x.length, trace.y.length);
1453
+ const xs = new Array(n);
1454
+ const ys = new Array(n);
1455
+ for (let i = 0; i < n; i++) {
1456
+ xs[i] = trace.x[i];
1457
+ ys[i] = trace.y[i];
1458
+ }
1459
+ this.traceData[traceIndex] = { xs, ys, name: trace.name ?? `Trace ${traceIndex + 1}` };
1460
+ }
1461
+ // compute domains from traces (include legendonly by default to keep axes stable)
1462
+ this.xDomainNum = this.computeAxisDomain(this.traces, "x", this.getAxis("x"), xType);
1463
+ this.yDomainNum = this.computeAxisDomain(this.traces, "y", this.getAxis("y"), yType);
1464
+ // clear caches
1465
+ this.markerNormByTrace.clear();
1466
+ this.markerNormLayers = [];
1467
+ this.idRanges = [];
1468
+ const markers = [];
1469
+ const lines = [];
1470
+ let nextBaseId = 0;
1471
+ // Build layers
1472
+ traces.forEach(({ trace, traceIndex }) => {
1473
+ const vis = trace.visible ?? true;
1474
+ const renderable = vis === true;
1475
+ // still keep legendonly in legend; just skip render
1476
+ if (!renderable)
1477
+ return;
1478
+ if (trace.type === "bar") {
1479
+ const points01 = this.normalizeInterleaved(trace.x, trace.y, xType, yType, this.xDomainNum, this.yDomainNum);
1480
+ const count = points01.length / 2;
1481
+ if (count <= 0)
1482
+ return;
1483
+ const widthPx = Math.max(1, trace.bar?.widthPx ?? DEFAULT_BAR_WIDTH_PX);
1484
+ const baseId = nextBaseId;
1485
+ nextBaseId += count;
1486
+ this.idRanges.push({ baseId, count, traceIndex });
1487
+ this.markerNormByTrace.set(traceIndex, points01);
1488
+ this.markerNormLayers.push({ traceIndex, points01 });
1489
+ markers.push({
1490
+ points01,
1491
+ // Keep pick/hover support for bars without rendering marker sprites.
1492
+ pointSizePx: Math.max(2, widthPx),
1493
+ rgba: [0, 0, 0, 0],
1494
+ baseId
1495
+ });
1496
+ const baseYn = this.normalizeBarBaseY(trace, yType, this.yDomainNum);
1497
+ const barPoints = [];
1498
+ for (let i = 0; i < count; i++) {
1499
+ const xn = points01[i * 2 + 0];
1500
+ const yn = points01[i * 2 + 1];
1501
+ if (Number.isFinite(xn) && Number.isFinite(yn) && Number.isFinite(baseYn)) {
1502
+ barPoints.push(xn, baseYn, xn, yn, Number.NaN, Number.NaN);
1503
+ }
1504
+ else {
1505
+ barPoints.push(Number.NaN, Number.NaN);
1506
+ }
1507
+ }
1508
+ const baseColor = this.getTraceColor(trace, traceIndex);
1509
+ const c = parseColor(baseColor) ?? [0.12, 0.55, 0.95];
1510
+ const a = clamp01(trace.bar?.opacity ?? trace.marker?.opacity ?? 0.65);
1511
+ lines.push({
1512
+ points01: new Float32Array(barPoints),
1513
+ rgba: [c[0], c[1], c[2], a],
1514
+ widthPx,
1515
+ dash: "solid"
1516
+ });
1517
+ return;
1518
+ }
1519
+ if (trace.type === "heatmap") {
1520
+ const xVals = Array.from(trace.x);
1521
+ const yVals = Array.from(trace.y);
1522
+ const rows = this.toHeatmapRows(trace.z);
1523
+ const ny = Math.min(yVals.length, rows.length);
1524
+ if (xVals.length === 0 || ny === 0)
1525
+ return;
1526
+ const xCenters01 = this.normalizeAxisValues(xVals, xType, this.xDomainNum, false);
1527
+ const yCenters01 = this.normalizeAxisValues(yVals, yType, this.yDomainNum, true);
1528
+ const xEdges01 = this.computeAxisEdges(xCenters01);
1529
+ const yEdges01 = this.computeAxisEdges(yCenters01);
1530
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
1531
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
1532
+ const fillOpacity = clamp01(trace.heatmap?.opacity ?? DEFAULT_HEATMAP_OPACITY);
1533
+ const colors = this.resolveHeatmapScale(trace);
1534
+ const zRange = this.resolveHeatmapZRange(trace, rows);
1535
+ const markerPoints = [];
1536
+ const widthSamples = [];
1537
+ const heightSamples = [];
1538
+ for (let yi = 0; yi < ny; yi++) {
1539
+ const row = rows[yi];
1540
+ const nx = Math.min(xVals.length, row.length);
1541
+ for (let xi = 0; xi < nx; xi++) {
1542
+ const z = Number(row[xi]);
1543
+ const xc = xCenters01[xi];
1544
+ const y0 = yEdges01[yi];
1545
+ const y1 = yEdges01[yi + 1];
1546
+ const x0 = xEdges01[xi];
1547
+ const x1 = xEdges01[xi + 1];
1548
+ if (!Number.isFinite(z) || !Number.isFinite(xc) || !Number.isFinite(y0) || !Number.isFinite(y1) || !Number.isFinite(x0) || !Number.isFinite(x1))
1549
+ continue;
1550
+ markerPoints.push(xc, (y0 + y1) * 0.5);
1551
+ const widthPx = Math.max(1, Math.min(48, Math.abs(x1 - x0) * plotW * 0.98));
1552
+ const heightPx = Math.max(1, Math.min(48, Math.abs(y1 - y0) * plotH));
1553
+ widthSamples.push(widthPx);
1554
+ heightSamples.push(heightPx);
1555
+ const c = this.interpolateHeatmapColor(z, zRange[0], zRange[1], colors);
1556
+ lines.push({
1557
+ points01: new Float32Array([xc, y0, xc, y1]),
1558
+ rgba: [c[0], c[1], c[2], fillOpacity],
1559
+ widthPx,
1560
+ dash: "solid"
1561
+ });
1562
+ }
1563
+ }
1564
+ const count = markerPoints.length / 2;
1565
+ if (count <= 0)
1566
+ return;
1567
+ const baseId = nextBaseId;
1568
+ nextBaseId += count;
1569
+ const markerSize = this.estimateHeatmapHoverSize(widthSamples, heightSamples);
1570
+ this.idRanges.push({ baseId, count, traceIndex });
1571
+ this.markerNormByTrace.set(traceIndex, new Float32Array(markerPoints));
1572
+ this.markerNormLayers.push({ traceIndex, points01: new Float32Array(markerPoints) });
1573
+ this.heatmapHoverSizeByTrace.set(traceIndex, markerSize);
1574
+ markers.push({
1575
+ points01: new Float32Array(markerPoints),
1576
+ pointSizePx: markerSize,
1577
+ rgba: [0, 0, 0, 0],
1578
+ baseId
1579
+ });
1580
+ return;
1581
+ }
1582
+ if (trace.type === "area") {
1583
+ const points01 = this.normalizeInterleaved(trace.x, trace.y, xType, yType, this.xDomainNum, this.yDomainNum);
1584
+ const count = points01.length / 2;
1585
+ if (count <= 0)
1586
+ return;
1587
+ const mode = trace.mode ?? "lines";
1588
+ const showMarkers = mode === "markers" || mode === "lines+markers";
1589
+ const showBoundary = mode === "lines" || mode === "lines+markers";
1590
+ const baseColor = this.getTraceColor(trace, traceIndex);
1591
+ const markerRgb = parseColor(trace.marker?.color ?? baseColor) ?? [0.12, 0.55, 0.95];
1592
+ const markerAlpha = clamp01(showMarkers ? (trace.marker?.opacity ?? 0.35) : 0);
1593
+ const baseId = nextBaseId;
1594
+ nextBaseId += count;
1595
+ this.idRanges.push({ baseId, count, traceIndex });
1596
+ this.markerNormByTrace.set(traceIndex, points01);
1597
+ this.markerNormLayers.push({ traceIndex, points01 });
1598
+ markers.push({
1599
+ points01,
1600
+ pointSizePx: trace.marker?.sizePx ?? 2,
1601
+ rgba: [markerRgb[0], markerRgb[1], markerRgb[2], markerAlpha],
1602
+ baseId
1603
+ });
1604
+ const baseYn = this.normalizeAreaBaseY(trace, yType, this.yDomainNum);
1605
+ const fillPoints = [];
1606
+ for (let i = 0; i < count; i++) {
1607
+ const xn = points01[i * 2 + 0];
1608
+ const yn = points01[i * 2 + 1];
1609
+ if (Number.isFinite(xn) && Number.isFinite(yn) && Number.isFinite(baseYn)) {
1610
+ fillPoints.push(xn, baseYn, xn, yn, Number.NaN, Number.NaN);
1611
+ }
1612
+ else {
1613
+ fillPoints.push(Number.NaN, Number.NaN);
1614
+ }
1615
+ }
1616
+ const fillRgb = parseColor(trace.area?.color ?? baseColor) ?? [0.12, 0.55, 0.95];
1617
+ lines.push({
1618
+ points01: new Float32Array(fillPoints),
1619
+ rgba: [fillRgb[0], fillRgb[1], fillRgb[2], clamp01(trace.area?.opacity ?? DEFAULT_AREA_OPACITY)],
1620
+ widthPx: this.computeAreaFillWidthPx(points01),
1621
+ dash: "solid"
1622
+ });
1623
+ if (showBoundary) {
1624
+ const smoothingMode = (trace.line?.smoothing ?? "none");
1625
+ const linePoints01 = this.smoothLinePoints(points01, smoothingMode);
1626
+ const c = parseColor(trace.line?.color ?? baseColor) ?? [0.12, 0.12, 0.12];
1627
+ const a = clamp01(trace.line?.opacity ?? 0.8);
1628
+ lines.push({
1629
+ points01: linePoints01,
1630
+ rgba: [c[0], c[1], c[2], a],
1631
+ widthPx: trace.line?.widthPx ?? 1.5,
1632
+ dash: trace.line?.dash ?? "solid"
1633
+ });
1634
+ }
1635
+ return;
1636
+ }
1637
+ const mode = trace.mode ?? "markers";
1638
+ const points01 = this.normalizeInterleaved(trace.x, trace.y, xType, yType, this.xDomainNum, this.yDomainNum);
1639
+ const baseColor = this.getTraceColor(trace, traceIndex);
1640
+ if (mode === "markers" || mode === "lines+markers") {
1641
+ const c = parseColor(baseColor) ?? [0.12, 0.55, 0.95];
1642
+ const a = clamp01(trace.marker?.opacity ?? 0.35);
1643
+ const count = points01.length / 2;
1644
+ const baseId = nextBaseId;
1645
+ nextBaseId += count;
1646
+ this.idRanges.push({ baseId, count, traceIndex });
1647
+ this.markerNormByTrace.set(traceIndex, points01);
1648
+ this.markerNormLayers.push({ traceIndex, points01 });
1649
+ markers.push({
1650
+ points01,
1651
+ pointSizePx: trace.marker?.sizePx ?? 2,
1652
+ rgba: [c[0], c[1], c[2], a],
1653
+ baseId
1654
+ });
1655
+ }
1656
+ if (mode === "lines" || mode === "lines+markers") {
1657
+ const smoothingMode = (trace.line?.smoothing ?? "none");
1658
+ const linePoints01 = this.smoothLinePoints(points01, smoothingMode);
1659
+ const c = parseColor(trace.line?.color ?? baseColor) ?? [0.12, 0.12, 0.12];
1660
+ const a = clamp01(trace.line?.opacity ?? 0.55);
1661
+ lines.push({
1662
+ points01: linePoints01,
1663
+ rgba: [c[0], c[1], c[2], a],
1664
+ widthPx: trace.line?.widthPx ?? 1,
1665
+ dash: trace.line?.dash ?? "solid"
1666
+ });
1667
+ }
1668
+ });
1669
+ // build sorted indices for hovermode x/y (small traces only)
1670
+ this.xSorted = [];
1671
+ this.ySorted = [];
1672
+ const hovermode = this.getHoverMode();
1673
+ if (hovermode === "x" || hovermode === "y") {
1674
+ const SORT_LIMIT = 300_000;
1675
+ for (const L of this.markerNormLayers) {
1676
+ const tIdx = L.traceIndex;
1677
+ const td = this.traceData[tIdx];
1678
+ if (!td)
1679
+ continue;
1680
+ const n = td.xs.length;
1681
+ if (n > SORT_LIMIT)
1682
+ continue;
1683
+ const xsNum = new Float64Array(n);
1684
+ const ysNum = new Float64Array(n);
1685
+ for (let i = 0; i < n; i++) {
1686
+ xsNum[i] = toNumber(td.xs[i], xType);
1687
+ ysNum[i] = toNumber(td.ys[i], yType);
1688
+ }
1689
+ const orderX = sortedOrder(xsNum);
1690
+ const orderY = sortedOrder(ysNum);
1691
+ this.xSorted.push({ traceIndex: tIdx, order: orderX, xsNum });
1692
+ this.ySorted.push({ traceIndex: tIdx, order: orderY, ysNum });
1693
+ }
1694
+ }
1695
+ return { markers, lines };
1696
+ }
1697
+ computeAxisDomain(traces, which, axis, type) {
1698
+ if (axis?.domain) {
1699
+ return [toNumber(axis.domain[0], type), toNumber(axis.domain[1], type)];
1700
+ }
1701
+ if (axis?.range) {
1702
+ return [toNumber(axis.range[0], type), toNumber(axis.range[1], type)];
1703
+ }
1704
+ let min = Number.POSITIVE_INFINITY;
1705
+ let max = Number.NEGATIVE_INFINITY;
1706
+ for (const t of traces) {
1707
+ const vis = t.visible ?? true;
1708
+ if (vis === false)
1709
+ continue;
1710
+ const arr = which === "x" ? t.x : t.y;
1711
+ const n = arr.length;
1712
+ for (let i = 0; i < n; i++) {
1713
+ const v = toNumber(arr[i], type);
1714
+ if (!Number.isFinite(v))
1715
+ continue;
1716
+ if (type === "log" && v <= 0)
1717
+ continue;
1718
+ if (v < min)
1719
+ min = v;
1720
+ if (v > max)
1721
+ max = v;
1722
+ }
1723
+ if (which === "y" && t.type === "bar") {
1724
+ const baseDatum = t.bar?.base;
1725
+ const baseValue = baseDatum !== undefined
1726
+ ? toNumber(baseDatum, type)
1727
+ : (type === "log" ? Number.NaN : 0);
1728
+ if (Number.isFinite(baseValue) && (type !== "log" || baseValue > 0)) {
1729
+ if (baseValue < min)
1730
+ min = baseValue;
1731
+ if (baseValue > max)
1732
+ max = baseValue;
1733
+ }
1734
+ }
1735
+ if (which === "y" && t.type === "area") {
1736
+ const baseDatum = t.area?.base;
1737
+ const baseValue = baseDatum !== undefined
1738
+ ? toNumber(baseDatum, type)
1739
+ : (type === "log" ? Number.NaN : 0);
1740
+ if (Number.isFinite(baseValue) && (type !== "log" || baseValue > 0)) {
1741
+ if (baseValue < min)
1742
+ min = baseValue;
1743
+ if (baseValue > max)
1744
+ max = baseValue;
1745
+ }
1746
+ }
1747
+ }
1748
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) {
1749
+ if (!Number.isFinite(min))
1750
+ return this.applyAxisBounds([0, 1], axis, type);
1751
+ return this.applyAxisBounds([min - 0.5, max + 0.5], axis, type);
1752
+ }
1753
+ const pad = (max - min) * 0.02;
1754
+ const bounded = this.applyAxisBounds([min - pad, max + pad], axis, type);
1755
+ return bounded;
1756
+ }
1757
+ applyAxisBounds(domain, axis, type) {
1758
+ let [d0, d1] = domain;
1759
+ let minBound = axis?.min !== undefined ? toNumber(axis.min, type) : undefined;
1760
+ let maxBound = axis?.max !== undefined ? toNumber(axis.max, type) : undefined;
1761
+ if (type === "log") {
1762
+ if (minBound !== undefined && minBound <= 0)
1763
+ minBound = undefined;
1764
+ if (maxBound !== undefined && maxBound <= 0)
1765
+ maxBound = undefined;
1766
+ }
1767
+ if (minBound !== undefined && maxBound !== undefined && minBound > maxBound) {
1768
+ const t = minBound;
1769
+ minBound = maxBound;
1770
+ maxBound = t;
1771
+ }
1772
+ if (minBound !== undefined)
1773
+ d0 = Math.max(d0, minBound);
1774
+ if (maxBound !== undefined)
1775
+ d1 = Math.min(d1, maxBound);
1776
+ if (d0 < d1)
1777
+ return [d0, d1];
1778
+ if (minBound !== undefined && maxBound !== undefined && minBound < maxBound) {
1779
+ return [minBound, maxBound];
1780
+ }
1781
+ if (minBound !== undefined && maxBound === undefined) {
1782
+ return [minBound, minBound + 1];
1783
+ }
1784
+ if (maxBound !== undefined && minBound === undefined) {
1785
+ return [maxBound - 1, maxBound];
1786
+ }
1787
+ const mid = Number.isFinite(d0) ? d0 : 0;
1788
+ return [mid - 0.5, mid + 0.5];
1789
+ }
1790
+ normalizeInterleaved(xs, ys, xType, yType, xDom, yDom) {
1791
+ const n = Math.min(xs.length, ys.length);
1792
+ const out = new Float32Array(n * 2);
1793
+ const [x0, x1] = xDom;
1794
+ const [y0, y1] = yDom;
1795
+ const lx0 = xType === "log" ? Math.log10(x0) : x0;
1796
+ const lx1 = xType === "log" ? Math.log10(x1) : x1;
1797
+ const ly0 = yType === "log" ? Math.log10(y0) : y0;
1798
+ const ly1 = yType === "log" ? Math.log10(y1) : y1;
1799
+ const invX = 1 / (lx1 - lx0);
1800
+ const invY = 1 / (ly1 - ly0);
1801
+ for (let i = 0; i < n; i++) {
1802
+ let xv = toNumber(xs[i], xType);
1803
+ let yv = toNumber(ys[i], yType);
1804
+ if (xType === "log")
1805
+ xv = xv > 0 ? Math.log10(xv) : NaN;
1806
+ if (yType === "log")
1807
+ yv = yv > 0 ? Math.log10(yv) : NaN;
1808
+ const xn = Number.isFinite(xv) ? (xv - lx0) * invX : NaN;
1809
+ // Overlay y-axis uses range [plotH, 0], so flip normalized y for renderer parity.
1810
+ const yn = Number.isFinite(yv) ? 1 - ((yv - ly0) * invY) : NaN;
1811
+ // Keep off-domain points outside [0,1] so zoom/pan can bring them into view.
1812
+ out[i * 2 + 0] = xn;
1813
+ out[i * 2 + 1] = yn;
1814
+ }
1815
+ return out;
1816
+ }
1817
+ smoothLinePoints(points01, mode) {
1818
+ if (mode === "none")
1819
+ return points01;
1820
+ const count = Math.floor(points01.length / 2);
1821
+ if (count < 3)
1822
+ return points01;
1823
+ const subdivisions = 4;
1824
+ const out = [];
1825
+ const getX = (idx) => points01[idx * 2 + 0];
1826
+ const getY = (idx) => points01[idx * 2 + 1];
1827
+ const pushPoint = (x, y) => {
1828
+ out.push(x, y);
1829
+ };
1830
+ const appendRun = (start, end) => {
1831
+ const runLen = end - start;
1832
+ if (runLen <= 0)
1833
+ return;
1834
+ if (runLen < 3) {
1835
+ for (let i = start; i < end; i++) {
1836
+ pushPoint(getX(i), getY(i));
1837
+ }
1838
+ return;
1839
+ }
1840
+ pushPoint(getX(start), getY(start));
1841
+ for (let i = start; i < end - 1; i++) {
1842
+ const i0 = Math.max(start, i - 1);
1843
+ const i1 = i;
1844
+ const i2 = i + 1;
1845
+ const i3 = Math.min(end - 1, i + 2);
1846
+ const p0x = getX(i0);
1847
+ const p1x = getX(i1);
1848
+ const p2x = getX(i2);
1849
+ const p3x = getX(i3);
1850
+ const p0y = getY(i0);
1851
+ const p1y = getY(i1);
1852
+ const p2y = getY(i2);
1853
+ const p3y = getY(i3);
1854
+ for (let step = 1; step <= subdivisions; step++) {
1855
+ const t = step / subdivisions;
1856
+ const x = catmullRom(p0x, p1x, p2x, p3x, t);
1857
+ const y = catmullRom(p0y, p1y, p2y, p3y, t);
1858
+ pushPoint(x, y);
1859
+ }
1860
+ }
1861
+ };
1862
+ let runStart = -1;
1863
+ for (let i = 0; i <= count; i++) {
1864
+ const valid = i < count && Number.isFinite(getX(i)) && Number.isFinite(getY(i));
1865
+ if (valid) {
1866
+ if (runStart < 0)
1867
+ runStart = i;
1868
+ continue;
1869
+ }
1870
+ if (runStart >= 0) {
1871
+ appendRun(runStart, i);
1872
+ runStart = -1;
1873
+ }
1874
+ if (i < count) {
1875
+ pushPoint(Number.NaN, Number.NaN);
1876
+ }
1877
+ }
1878
+ return out.length > 0 ? new Float32Array(out) : points01;
1879
+ }
1880
+ getTraceColor(trace, traceIndex) {
1881
+ if (trace.type === "bar") {
1882
+ return trace.bar?.color ?? trace.marker?.color ?? this.paletteColor(traceIndex);
1883
+ }
1884
+ if (trace.type === "heatmap") {
1885
+ return this.getHeatmapLegendColor(trace) ?? this.paletteColor(traceIndex);
1886
+ }
1887
+ if (trace.type === "area") {
1888
+ return trace.area?.color ?? trace.line?.color ?? trace.marker?.color ?? this.paletteColor(traceIndex);
1889
+ }
1890
+ return trace.marker?.color ?? trace.line?.color ?? this.paletteColor(traceIndex);
1891
+ }
1892
+ getTraceHoverSizePx(trace, traceIndex) {
1893
+ if (trace.type === "bar") {
1894
+ return Math.max(8, (trace.bar?.widthPx ?? DEFAULT_BAR_WIDTH_PX) + 2);
1895
+ }
1896
+ if (trace.type === "heatmap") {
1897
+ return this.heatmapHoverSizeByTrace.get(traceIndex) ?? 10;
1898
+ }
1899
+ if (trace.type === "area") {
1900
+ return (trace.marker?.sizePx ?? 2) + 5;
1901
+ }
1902
+ return (trace.marker?.sizePx ?? 2) + 5;
1903
+ }
1904
+ normalizeBarBaseY(trace, yType, yDom) {
1905
+ const defaultBase = yType === "log" ? yDom[0] : 0;
1906
+ let yv = toNumber(trace.bar?.base ?? defaultBase, yType);
1907
+ if (yType === "log")
1908
+ yv = yv > 0 ? Math.log10(yv) : Number.NaN;
1909
+ const [y0, y1] = yDom;
1910
+ const ly0 = yType === "log" ? Math.log10(y0) : y0;
1911
+ const ly1 = yType === "log" ? Math.log10(y1) : y1;
1912
+ const invY = 1 / (ly1 - ly0);
1913
+ return Number.isFinite(yv) ? 1 - ((yv - ly0) * invY) : Number.NaN;
1914
+ }
1915
+ normalizeAreaBaseY(trace, yType, yDom) {
1916
+ const defaultBase = yType === "log" ? yDom[0] : 0;
1917
+ let yv = toNumber(trace.area?.base ?? defaultBase, yType);
1918
+ if (yType === "log")
1919
+ yv = yv > 0 ? Math.log10(yv) : Number.NaN;
1920
+ const [y0, y1] = yDom;
1921
+ const ly0 = yType === "log" ? Math.log10(y0) : y0;
1922
+ const ly1 = yType === "log" ? Math.log10(y1) : y1;
1923
+ const invY = 1 / (ly1 - ly0);
1924
+ return Number.isFinite(yv) ? 1 - ((yv - ly0) * invY) : Number.NaN;
1925
+ }
1926
+ computeAreaFillWidthPx(points01) {
1927
+ const deltas = [];
1928
+ let prevX = Number.NaN;
1929
+ for (let i = 0; i < points01.length; i += 2) {
1930
+ const x = points01[i];
1931
+ if (!Number.isFinite(x)) {
1932
+ prevX = Number.NaN;
1933
+ continue;
1934
+ }
1935
+ if (Number.isFinite(prevX)) {
1936
+ const dx = Math.abs(x - prevX);
1937
+ if (dx > 0)
1938
+ deltas.push(dx);
1939
+ }
1940
+ prevX = x;
1941
+ }
1942
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
1943
+ if (deltas.length === 0)
1944
+ return Math.max(2, Math.min(24, plotW / 24));
1945
+ deltas.sort((a, b) => a - b);
1946
+ const mid = deltas[Math.floor(deltas.length / 2)];
1947
+ const widthPx = mid * plotW * 0.98;
1948
+ return Math.max(1, Math.min(24, widthPx));
1949
+ }
1950
+ toHeatmapRows(z) {
1951
+ const rows = Array.from(z, (row) => Array.from(row, (v) => Number(v)));
1952
+ return rows;
1953
+ }
1954
+ normalizeAxisValues(values, type, dom, flipY) {
1955
+ const out = new Float32Array(values.length);
1956
+ const [d0, d1] = dom;
1957
+ const l0 = type === "log" ? Math.log10(d0) : d0;
1958
+ const l1 = type === "log" ? Math.log10(d1) : d1;
1959
+ const inv = 1 / (l1 - l0);
1960
+ for (let i = 0; i < values.length; i++) {
1961
+ let v = toNumber(values[i], type);
1962
+ if (type === "log")
1963
+ v = v > 0 ? Math.log10(v) : Number.NaN;
1964
+ const n = Number.isFinite(v) ? (v - l0) * inv : Number.NaN;
1965
+ out[i] = flipY ? (1 - n) : n;
1966
+ }
1967
+ return out;
1968
+ }
1969
+ computeAxisEdges(centers) {
1970
+ const n = centers.length;
1971
+ const edges = new Float32Array(n + 1);
1972
+ if (n === 0)
1973
+ return edges;
1974
+ if (n === 1) {
1975
+ const c = centers[0];
1976
+ edges[0] = c - 0.5;
1977
+ edges[1] = c + 0.5;
1978
+ return edges;
1979
+ }
1980
+ for (let i = 1; i < n; i++) {
1981
+ const a = centers[i - 1];
1982
+ const b = centers[i];
1983
+ edges[i] = (a + b) * 0.5;
1984
+ }
1985
+ edges[0] = centers[0] - (edges[1] - centers[0]);
1986
+ edges[n] = centers[n - 1] + (centers[n - 1] - edges[n - 1]);
1987
+ return edges;
1988
+ }
1989
+ resolveHeatmapScale(trace) {
1990
+ const source = trace.heatmap?.colorscale?.length ? trace.heatmap.colorscale : DEFAULT_HEATMAP_COLORSCALE;
1991
+ const out = [];
1992
+ for (const c of source) {
1993
+ const parsed = parseColor(c);
1994
+ if (parsed)
1995
+ out.push(parsed);
1996
+ }
1997
+ return out.length > 0 ? out : [[0.12, 0.55, 0.95]];
1998
+ }
1999
+ resolveHeatmapZRange(trace, rows) {
2000
+ let zMin = Number.isFinite(trace.heatmap?.zmin) ? Number(trace.heatmap?.zmin) : Number.POSITIVE_INFINITY;
2001
+ let zMax = Number.isFinite(trace.heatmap?.zmax) ? Number(trace.heatmap?.zmax) : Number.NEGATIVE_INFINITY;
2002
+ if (!Number.isFinite(zMin) || !Number.isFinite(zMax)) {
2003
+ zMin = Number.POSITIVE_INFINITY;
2004
+ zMax = Number.NEGATIVE_INFINITY;
2005
+ for (const row of rows) {
2006
+ for (const value of row) {
2007
+ if (!Number.isFinite(value))
2008
+ continue;
2009
+ if (value < zMin)
2010
+ zMin = value;
2011
+ if (value > zMax)
2012
+ zMax = value;
2013
+ }
2014
+ }
2015
+ }
2016
+ if (!Number.isFinite(zMin) || !Number.isFinite(zMax))
2017
+ return [0, 1];
2018
+ if (zMin === zMax)
2019
+ return [zMin - 0.5, zMax + 0.5];
2020
+ return [zMin, zMax];
2021
+ }
2022
+ interpolateHeatmapColor(z, zMin, zMax, scale) {
2023
+ if (scale.length === 1)
2024
+ return scale[0];
2025
+ if (!Number.isFinite(z) || !Number.isFinite(zMin) || !Number.isFinite(zMax) || zMax <= zMin) {
2026
+ return scale[0];
2027
+ }
2028
+ const tRaw = (z - zMin) / (zMax - zMin);
2029
+ const t = clamp01(tRaw);
2030
+ const scaled = t * (scale.length - 1);
2031
+ const idx = Math.min(scale.length - 2, Math.max(0, Math.floor(scaled)));
2032
+ const localT = scaled - idx;
2033
+ const a = scale[idx];
2034
+ const b = scale[idx + 1];
2035
+ return [
2036
+ a[0] + (b[0] - a[0]) * localT,
2037
+ a[1] + (b[1] - a[1]) * localT,
2038
+ a[2] + (b[2] - a[2]) * localT
2039
+ ];
2040
+ }
2041
+ estimateHeatmapHoverSize(widthPx, heightPx) {
2042
+ if (widthPx.length === 0 || heightPx.length === 0)
2043
+ return 10;
2044
+ const median = (values) => {
2045
+ const v = values.slice().sort((a, b) => a - b);
2046
+ return v[Math.floor(v.length / 2)];
2047
+ };
2048
+ const m = Math.max(median(widthPx), median(heightPx));
2049
+ return Math.max(6, Math.min(36, m + 2));
2050
+ }
2051
+ getHeatmapLegendColor(trace) {
2052
+ const colors = trace.heatmap?.colorscale;
2053
+ if (!colors || colors.length === 0)
2054
+ return undefined;
2055
+ return colors[Math.floor(colors.length / 2)];
2056
+ }
2057
+ makeLegendItems() {
2058
+ return this.traces.map((t, i) => {
2059
+ const name = t.name ?? `Trace ${i + 1}`;
2060
+ const color = this.getTraceColor(t, i);
2061
+ const visible = (t.visible ?? true) === true;
2062
+ return { name, color, visible };
2063
+ });
2064
+ }
2065
+ // ----------------------------
2066
+ // Grid index build (base space) - IMPROVED
2067
+ // ----------------------------
2068
+ scheduleGridRebuild() {
2069
+ if (this.destroyed)
2070
+ return;
2071
+ if (!this.shouldRebuildGrid())
2072
+ return;
2073
+ const now = performance.now();
2074
+ if (this.gridRebuildPending)
2075
+ return;
2076
+ const elapsed = now - this.gridLastBuildTs;
2077
+ const delay = elapsed >= this.gridMinBuildIntervalMs ? 0 : (this.gridMinBuildIntervalMs - elapsed);
2078
+ this.gridRebuildPending = true;
2079
+ this.gridRebuildTimer = window.setTimeout(() => {
2080
+ this.gridRebuildPending = false;
2081
+ this.gridRebuildTimer = null;
2082
+ if (!this.shouldRebuildGrid())
2083
+ return;
2084
+ this.rebuildGridIndex();
2085
+ }, delay);
2086
+ }
2087
+ shouldRebuildGrid() {
2088
+ if (!this.gridBuilt)
2089
+ return true;
2090
+ // Check scale change
2091
+ const dk = Math.abs(this.zoom.k - this.lastGridZoomK) / Math.max(1e-6, this.lastGridZoomK);
2092
+ if (dk >= this.gridMinScaleRelDelta)
2093
+ return true;
2094
+ // NEW: Check translation change (relative to plot size)
2095
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
2096
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
2097
+ const relDeltaX = Math.abs(this.zoom.x - this.lastGridZoomX) / plotW;
2098
+ const relDeltaY = Math.abs(this.zoom.y - this.lastGridZoomY) / plotH;
2099
+ if (relDeltaX > this.gridMinTransRelDelta || relDeltaY > this.gridMinTransRelDelta) {
2100
+ return true;
2101
+ }
2102
+ return false;
2103
+ }
2104
+ rebuildGridIndex() {
2105
+ const t0 = performance.now();
2106
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
2107
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
2108
+ const ox = this.padding.l;
2109
+ const oy = this.padding.t;
2110
+ const k = this.zoom.k;
2111
+ const tx = this.zoom.x;
2112
+ const ty = this.zoom.y;
2113
+ let total = 0;
2114
+ for (const L of this.markerNormLayers)
2115
+ total += L.points01.length / 2;
2116
+ this.gridX = new Float32Array(total);
2117
+ this.gridY = new Float32Array(total);
2118
+ this.gridTrace = new Uint32Array(total);
2119
+ this.gridPoint = new Uint32Array(total);
2120
+ this.gridMap.clear();
2121
+ let gi = 0;
2122
+ for (const L of this.markerNormLayers) {
2123
+ const tIdx = L.traceIndex;
2124
+ const pts = L.points01;
2125
+ const count = pts.length / 2;
2126
+ for (let i = 0; i < count; i++) {
2127
+ const xn = pts[i * 2 + 0];
2128
+ const yn = pts[i * 2 + 1];
2129
+ if (!Number.isFinite(xn) || !Number.isFinite(yn))
2130
+ continue;
2131
+ const px = ox + (xn * plotW) * k + tx;
2132
+ const py = oy + (yn * plotH) * k + ty;
2133
+ if (px < ox - 50 || px > ox + plotW + 50 || py < oy - 50 || py > oy + plotH + 50)
2134
+ continue;
2135
+ this.gridX[gi] = px;
2136
+ this.gridY[gi] = py;
2137
+ this.gridTrace[gi] = tIdx;
2138
+ this.gridPoint[gi] = i;
2139
+ const cx = Math.floor(px / this.gridCellPx);
2140
+ const cy = Math.floor(py / this.gridCellPx);
2141
+ const key = this.gridKey(cx, cy);
2142
+ let bucket = this.gridMap.get(key);
2143
+ if (!bucket) {
2144
+ bucket = [];
2145
+ this.gridMap.set(key, bucket);
2146
+ }
2147
+ bucket.push(gi);
2148
+ gi++;
2149
+ }
2150
+ }
2151
+ if (gi !== total) {
2152
+ this.gridX = this.gridX.slice(0, gi);
2153
+ this.gridY = this.gridY.slice(0, gi);
2154
+ this.gridTrace = this.gridTrace.slice(0, gi);
2155
+ this.gridPoint = this.gridPoint.slice(0, gi);
2156
+ }
2157
+ this.gridBuilt = true;
2158
+ this.gridLastBuildTs = performance.now();
2159
+ this.lastGridZoomK = this.zoom.k;
2160
+ this.lastGridZoomX = this.zoom.x;
2161
+ this.lastGridZoomY = this.zoom.y;
2162
+ // Performance tracking
2163
+ if (this.enablePerfMonitoring) {
2164
+ const elapsed = performance.now() - t0;
2165
+ this.perfStats.lastGridBuildMs = elapsed;
2166
+ this.perfStats.gridBuildCount++;
2167
+ this.perfStats.avgGridBuildMs =
2168
+ (this.perfStats.avgGridBuildMs * (this.perfStats.gridBuildCount - 1) + elapsed) /
2169
+ this.perfStats.gridBuildCount;
2170
+ }
2171
+ }
2172
+ gridKey(cx, cy) {
2173
+ return (BigInt(cx) << 32n) ^ (BigInt(cy) & 0xffffffffn);
2174
+ }
2175
+ // ----------------------------
2176
+ // GPU id -> PickResult
2177
+ // ----------------------------
2178
+ idToHit(id) {
2179
+ if (!id)
2180
+ return null;
2181
+ const gid = id - 1;
2182
+ for (const r of this.idRanges) {
2183
+ if (gid >= r.baseId && gid < r.baseId + r.count) {
2184
+ const pointIndex = gid - r.baseId;
2185
+ const tIdx = r.traceIndex;
2186
+ const td = this.traceData[tIdx];
2187
+ if (!td)
2188
+ return null;
2189
+ const norm = this.getNormPoint(tIdx, pointIndex);
2190
+ if (!norm)
2191
+ return null;
2192
+ const { screenX, screenY } = this.toScreenFromNorm(norm.xn, norm.yn);
2193
+ return { traceIndex: tIdx, pointIndex, x: td.xs[pointIndex], y: td.ys[pointIndex], screenX, screenY };
2194
+ }
2195
+ }
2196
+ return null;
2197
+ }
2198
+ // ----------------------------
2199
+ // Screen transforms from normalized points
2200
+ // ----------------------------
2201
+ toScreenFromNorm(xn, yn) {
2202
+ const plotW = Math.max(1, this.width - this.padding.l - this.padding.r);
2203
+ const plotH = Math.max(1, this.height - this.padding.t - this.padding.b);
2204
+ const ox = this.padding.l;
2205
+ const oy = this.padding.t;
2206
+ const k = this.zoom.k;
2207
+ const tx = this.zoom.x;
2208
+ const ty = this.zoom.y;
2209
+ return {
2210
+ screenX: ox + (xn * plotW) * k + tx,
2211
+ screenY: oy + (yn * plotH) * k + ty
2212
+ };
2213
+ }
2214
+ getNormPoint(traceIndex, pointIndex) {
2215
+ const pts = this.markerNormByTrace.get(traceIndex);
2216
+ if (!pts)
2217
+ return null;
2218
+ const i = pointIndex * 2;
2219
+ if (i + 1 >= pts.length)
2220
+ return null;
2221
+ return { xn: pts[i], yn: pts[i + 1] };
2222
+ }
2223
+ // ----------------------------
2224
+ // Tooltip
2225
+ // ----------------------------
2226
+ formatHover(trace, hit) {
2227
+ const zValue = trace.type === "heatmap"
2228
+ ? this.heatmapValueByTrace.get(hit.traceIndex)?.[hit.pointIndex]
2229
+ : undefined;
2230
+ const tpl = trace.hovertemplate;
2231
+ if (!tpl) {
2232
+ if (trace.type === "heatmap") {
2233
+ return `${trace.name ?? "Trace"} i=${hit.pointIndex} x=${fmtDatum(hit.x)} y=${fmtDatum(hit.y)} z=${fmtNumber(zValue)}`;
2234
+ }
2235
+ return `${trace.name ?? "Trace"} i=${hit.pointIndex} x=${fmtDatum(hit.x)} y=${fmtDatum(hit.y)}`;
2236
+ }
2237
+ return tpl
2238
+ .replaceAll("%{x}", escapeHtml(fmtDatum(hit.x)))
2239
+ .replaceAll("%{y}", escapeHtml(fmtDatum(hit.y)))
2240
+ .replaceAll("%{z}", escapeHtml(fmtNumber(zValue)))
2241
+ .replaceAll("%{pointIndex}", String(hit.pointIndex))
2242
+ .replaceAll("%{trace.name}", escapeHtml(String(trace.name ?? "")));
2243
+ }
2244
+ makeTooltipContext(trace, hit) {
2245
+ const z = trace.type === "heatmap"
2246
+ ? this.heatmapValueByTrace.get(hit.traceIndex)?.[hit.pointIndex]
2247
+ : undefined;
2248
+ return {
2249
+ traceIndex: hit.traceIndex,
2250
+ pointIndex: hit.pointIndex,
2251
+ trace,
2252
+ x: hit.x,
2253
+ y: hit.y,
2254
+ z,
2255
+ screenX: hit.screenX,
2256
+ screenY: hit.screenY,
2257
+ defaultLabel: this.formatHover(trace, hit)
2258
+ };
2259
+ }
2260
+ showTooltip(context) {
2261
+ if (this.tooltipRenderer) {
2262
+ const rendered = this.tooltipRenderer(context);
2263
+ if (rendered === null) {
2264
+ this.hideTooltip();
2265
+ return;
2266
+ }
2267
+ const hasDomNode = typeof Node !== "undefined";
2268
+ if (hasDomNode && rendered instanceof Node) {
2269
+ this.tooltip.replaceChildren(rendered);
2270
+ }
2271
+ else {
2272
+ this.tooltip.innerHTML = String(rendered);
2273
+ }
2274
+ }
2275
+ else if (this.tooltipFormatter) {
2276
+ this.tooltip.textContent = String(this.tooltipFormatter(context));
2277
+ }
2278
+ else if (context.trace.hovertemplate) {
2279
+ this.tooltip.innerHTML = context.defaultLabel;
2280
+ }
2281
+ else {
2282
+ this.tooltip.textContent = context.defaultLabel;
2283
+ }
2284
+ this.tooltip.setAttribute("aria-hidden", "false");
2285
+ this.tooltip.style.transform = `translate(${context.screenX + 12}px, ${context.screenY + 12}px)`;
2286
+ }
2287
+ showCursorTooltip(e) {
2288
+ const x = fmtDatum(e.xData);
2289
+ const y = fmtDatum(e.yData);
2290
+ this.tooltip.textContent = `x=${x} y=${y}`;
2291
+ this.tooltip.setAttribute("aria-hidden", "false");
2292
+ this.tooltip.style.transform = `translate(${e.xSvg + 12}px, ${e.ySvg + 12}px)`;
2293
+ }
2294
+ hideTooltip() {
2295
+ this.tooltip.setAttribute("aria-hidden", "true");
2296
+ this.tooltip.style.transform = "translate(-9999px,-9999px)";
2297
+ }
2298
+ onContainerKeyDown(event) {
2299
+ if (!this.a11y.keyboardNavigation)
2300
+ return;
2301
+ if (!this.initialized)
2302
+ return;
2303
+ if (event.defaultPrevented)
2304
+ return;
2305
+ const target = event.target;
2306
+ if (isTextEntryElement(target))
2307
+ return;
2308
+ const panStep = event.shiftKey ? 120 : 40;
2309
+ const zoomStep = event.shiftKey ? 1.24 : 1.12;
2310
+ switch (event.key) {
2311
+ case "ArrowLeft":
2312
+ event.preventDefault();
2313
+ this.overlay.panBy(-panStep, 0);
2314
+ return;
2315
+ case "ArrowRight":
2316
+ event.preventDefault();
2317
+ this.overlay.panBy(panStep, 0);
2318
+ return;
2319
+ case "ArrowUp":
2320
+ event.preventDefault();
2321
+ this.overlay.panBy(0, -panStep);
2322
+ return;
2323
+ case "ArrowDown":
2324
+ event.preventDefault();
2325
+ this.overlay.panBy(0, panStep);
2326
+ return;
2327
+ case "+":
2328
+ case "=":
2329
+ case "NumpadAdd":
2330
+ event.preventDefault();
2331
+ this.overlay.zoomBy(zoomStep);
2332
+ return;
2333
+ case "-":
2334
+ case "_":
2335
+ case "NumpadSubtract":
2336
+ event.preventDefault();
2337
+ this.overlay.zoomBy(1 / zoomStep);
2338
+ return;
2339
+ case "0":
2340
+ case "Numpad0":
2341
+ event.preventDefault();
2342
+ this.overlay.resetZoom();
2343
+ return;
2344
+ case "f":
2345
+ case "F":
2346
+ event.preventDefault();
2347
+ this.fitToData();
2348
+ return;
2349
+ case "y":
2350
+ case "Y":
2351
+ event.preventDefault();
2352
+ this.autoscaleY();
2353
+ return;
2354
+ case "l":
2355
+ case "L":
2356
+ event.preventDefault();
2357
+ this.setAspectLock(!this.aspectLockEnabled);
2358
+ return;
2359
+ default:
2360
+ return;
2361
+ }
2362
+ }
2363
+ mountToolbar() {
2364
+ if (!this.toolbar.show)
2365
+ return;
2366
+ const enableExport = this.toolbar.export && this.toolbar.exportFormats.length > 0;
2367
+ const enableFullscreen = this.toolbar.fullscreen;
2368
+ if (!enableExport && !enableFullscreen)
2369
+ return;
2370
+ const toolbar = document.createElement("div");
2371
+ this.toolbarEl = toolbar;
2372
+ toolbar.className = "chart-toolbar";
2373
+ Object.assign(toolbar.style, {
2374
+ position: "absolute",
2375
+ display: "inline-flex",
2376
+ alignItems: "center",
2377
+ gap: "6px",
2378
+ padding: "6px",
2379
+ borderRadius: "10px",
2380
+ border: `1px solid ${this.theme.axis.color}`,
2381
+ background: this.a11y.highContrast ? "#000000" : "rgba(255,255,255,0.9)",
2382
+ boxShadow: this.a11y.highContrast ? "none" : "0 6px 16px rgba(15,23,42,0.12)",
2383
+ pointerEvents: "auto",
2384
+ zIndex: "1100"
2385
+ });
2386
+ switch (this.toolbar.position) {
2387
+ case "top-left":
2388
+ toolbar.style.top = "10px";
2389
+ toolbar.style.left = "10px";
2390
+ break;
2391
+ case "bottom-right":
2392
+ toolbar.style.right = "10px";
2393
+ toolbar.style.bottom = "10px";
2394
+ break;
2395
+ case "bottom-left":
2396
+ toolbar.style.left = "10px";
2397
+ toolbar.style.bottom = "10px";
2398
+ break;
2399
+ case "top-right":
2400
+ default:
2401
+ toolbar.style.top = "10px";
2402
+ toolbar.style.right = "10px";
2403
+ break;
2404
+ }
2405
+ if (enableFullscreen) {
2406
+ const button = this.createToolbarButton("Full", "Enter full screen");
2407
+ this.toolbarFullscreenButton = button;
2408
+ button.addEventListener("click", this.handleToolbarFullscreenClick);
2409
+ toolbar.appendChild(button);
2410
+ }
2411
+ if (enableExport) {
2412
+ const wrap = document.createElement("div");
2413
+ this.toolbarExportWrap = wrap;
2414
+ Object.assign(wrap.style, {
2415
+ position: "relative",
2416
+ display: "inline-flex",
2417
+ alignItems: "center"
2418
+ });
2419
+ const trigger = this.createToolbarButton("Export", "Export chart");
2420
+ this.toolbarExportButton = trigger;
2421
+ trigger.setAttribute("aria-haspopup", "menu");
2422
+ trigger.setAttribute("aria-expanded", "false");
2423
+ trigger.addEventListener("click", this.handleToolbarExportToggle);
2424
+ wrap.appendChild(trigger);
2425
+ const menu = document.createElement("div");
2426
+ this.toolbarExportMenu = menu;
2427
+ menu.setAttribute("role", "menu");
2428
+ menu.setAttribute("aria-label", "Export options");
2429
+ Object.assign(menu.style, {
2430
+ position: "absolute",
2431
+ top: "calc(100% + 6px)",
2432
+ right: "0",
2433
+ minWidth: "100px",
2434
+ display: "none",
2435
+ flexDirection: "column",
2436
+ gap: "4px",
2437
+ padding: "6px",
2438
+ borderRadius: "8px",
2439
+ border: `1px solid ${this.theme.axis.color}`,
2440
+ background: this.a11y.highContrast ? "#000000" : "#ffffff",
2441
+ boxShadow: this.a11y.highContrast ? "none" : "0 10px 22px rgba(15,23,42,0.16)"
2442
+ });
2443
+ for (const format of this.toolbar.exportFormats) {
2444
+ const item = this.createToolbarButton(format.toUpperCase(), `Export ${format.toUpperCase()}`);
2445
+ item.type = "button";
2446
+ item.setAttribute("role", "menuitem");
2447
+ item.dataset.vxExportFormat = format;
2448
+ item.style.width = "100%";
2449
+ item.style.justifyContent = "flex-start";
2450
+ item.style.padding = "4px 8px";
2451
+ item.style.borderRadius = "6px";
2452
+ item.style.fontSize = "11px";
2453
+ menu.appendChild(item);
2454
+ }
2455
+ menu.addEventListener("click", this.handleToolbarExportMenuClick);
2456
+ wrap.appendChild(menu);
2457
+ toolbar.appendChild(wrap);
2458
+ }
2459
+ this.container.appendChild(toolbar);
2460
+ document.addEventListener("pointerdown", this.handleToolbarDocumentPointerDown);
2461
+ document.addEventListener("keydown", this.handleToolbarDocumentKeyDown);
2462
+ document.addEventListener("fullscreenchange", this.handleToolbarFullscreenChange);
2463
+ window.addEventListener("resize", this.handleToolbarWindowResize);
2464
+ this.setToolbarExportMenuOpen(false);
2465
+ this.syncToolbarFullscreenButton(document.fullscreenElement === this.container);
2466
+ }
2467
+ createToolbarButton(label, title) {
2468
+ const button = document.createElement("button");
2469
+ button.type = "button";
2470
+ button.textContent = label;
2471
+ button.title = title;
2472
+ Object.assign(button.style, {
2473
+ appearance: "none",
2474
+ border: `1px solid ${this.theme.axis.color}`,
2475
+ background: this.a11y.highContrast ? "#000000" : "#ffffff",
2476
+ color: this.theme.colors.text,
2477
+ borderRadius: "8px",
2478
+ fontFamily: this.theme.fonts.family,
2479
+ fontSize: "12px",
2480
+ fontWeight: "600",
2481
+ lineHeight: "1",
2482
+ minHeight: "28px",
2483
+ padding: "6px 9px",
2484
+ cursor: "pointer",
2485
+ display: "inline-flex",
2486
+ alignItems: "center",
2487
+ justifyContent: "center"
2488
+ });
2489
+ return button;
2490
+ }
2491
+ setToolbarExportMenuOpen(open) {
2492
+ this.toolbarExportOpen = open;
2493
+ if (this.toolbarExportMenu) {
2494
+ this.toolbarExportMenu.style.display = open ? "flex" : "none";
2495
+ }
2496
+ if (this.toolbarExportButton) {
2497
+ this.toolbarExportButton.setAttribute("aria-expanded", String(open));
2498
+ }
2499
+ }
2500
+ onToolbarExportToggle(event) {
2501
+ event.preventDefault();
2502
+ event.stopPropagation();
2503
+ if (this.toolbarExportBusy || !this.toolbarExportMenu)
2504
+ return;
2505
+ this.setToolbarExportMenuOpen(!this.toolbarExportOpen);
2506
+ }
2507
+ async onToolbarExportMenuClick(event) {
2508
+ if (this.toolbarExportBusy)
2509
+ return;
2510
+ const target = event.target instanceof Element ? event.target : null;
2511
+ const button = target?.closest("button[data-vx-export-format]");
2512
+ if (!button)
2513
+ return;
2514
+ const format = button.dataset.vxExportFormat;
2515
+ if (!isToolbarExportFormat(format))
2516
+ return;
2517
+ this.toolbarExportBusy = true;
2518
+ if (this.toolbarExportButton)
2519
+ this.toolbarExportButton.disabled = true;
2520
+ this.toolbarExportMenu?.querySelectorAll("button[data-vx-export-format]").forEach((node) => {
2521
+ node.disabled = true;
2522
+ });
2523
+ try {
2524
+ const timestamp = Date.now();
2525
+ if (format === "png") {
2526
+ const blob = await this.exportPng({ pixelRatio: this.toolbar.exportPixelRatio });
2527
+ this.downloadToolbarBlob(blob, `${sanitizeFilenamePart(this.toolbar.exportFilename)}-${timestamp}.png`);
2528
+ }
2529
+ else if (format === "svg") {
2530
+ const blob = await this.exportSvg({ pixelRatio: this.toolbar.exportPixelRatio });
2531
+ this.downloadToolbarBlob(blob, `${sanitizeFilenamePart(this.toolbar.exportFilename)}-${timestamp}.svg`);
2532
+ }
2533
+ else {
2534
+ const blob = this.exportCsvPoints();
2535
+ this.downloadToolbarBlob(blob, `${sanitizeFilenamePart(this.toolbar.exportFilename)}-points-${timestamp}.csv`);
2536
+ }
2537
+ }
2538
+ catch (error) {
2539
+ console.error("[vertexa-chart] Toolbar export failed.", error);
2540
+ }
2541
+ finally {
2542
+ this.toolbarExportBusy = false;
2543
+ if (this.toolbarExportButton)
2544
+ this.toolbarExportButton.disabled = false;
2545
+ this.toolbarExportMenu?.querySelectorAll("button[data-vx-export-format]").forEach((node) => {
2546
+ node.disabled = false;
2547
+ });
2548
+ this.setToolbarExportMenuOpen(false);
2549
+ }
2550
+ }
2551
+ downloadToolbarBlob(blob, filename) {
2552
+ const url = URL.createObjectURL(blob);
2553
+ const link = document.createElement("a");
2554
+ link.href = url;
2555
+ link.download = filename;
2556
+ document.body.appendChild(link);
2557
+ link.click();
2558
+ link.remove();
2559
+ URL.revokeObjectURL(url);
2560
+ }
2561
+ async onToolbarFullscreenClick() {
2562
+ if (!this.toolbarFullscreenButton)
2563
+ return;
2564
+ try {
2565
+ if (document.fullscreenElement === this.container) {
2566
+ await document.exitFullscreen();
2567
+ return;
2568
+ }
2569
+ if (!this.toolbarPreFullscreenSize) {
2570
+ this.toolbarPreFullscreenSize = { width: this.width, height: this.height };
2571
+ }
2572
+ if (document.fullscreenElement && document.fullscreenElement !== this.container) {
2573
+ await document.exitFullscreen();
2574
+ }
2575
+ await this.container.requestFullscreen();
2576
+ }
2577
+ catch (error) {
2578
+ this.toolbarPreFullscreenSize = null;
2579
+ console.error("[vertexa-chart] Fullscreen toggle failed.", error);
2580
+ }
2581
+ }
2582
+ onToolbarDocumentPointerDown(event) {
2583
+ if (!this.toolbarExportOpen || !this.toolbarExportWrap)
2584
+ return;
2585
+ const target = event.target;
2586
+ if (target instanceof Node && this.toolbarExportWrap.contains(target))
2587
+ return;
2588
+ this.setToolbarExportMenuOpen(false);
2589
+ }
2590
+ onToolbarDocumentKeyDown(event) {
2591
+ if (event.key === "Escape") {
2592
+ this.setToolbarExportMenuOpen(false);
2593
+ }
2594
+ }
2595
+ onToolbarFullscreenChange() {
2596
+ const active = document.fullscreenElement === this.container;
2597
+ this.syncToolbarFullscreenButton(active);
2598
+ if (active) {
2599
+ if (!this.toolbarPreFullscreenSize) {
2600
+ this.toolbarPreFullscreenSize = { width: this.width, height: this.height };
2601
+ }
2602
+ this.resizeToFullscreenViewport();
2603
+ return;
2604
+ }
2605
+ const previousSize = this.toolbarPreFullscreenSize;
2606
+ this.toolbarPreFullscreenSize = null;
2607
+ if (!previousSize)
2608
+ return;
2609
+ if (previousSize.width !== this.width || previousSize.height !== this.height) {
2610
+ this.setSize(previousSize.width, previousSize.height);
2611
+ }
2612
+ }
2613
+ onToolbarWindowResize() {
2614
+ if (document.fullscreenElement !== this.container)
2615
+ return;
2616
+ this.resizeToFullscreenViewport();
2617
+ }
2618
+ resizeToFullscreenViewport() {
2619
+ const width = Math.max(320, window.innerWidth);
2620
+ const height = Math.max(240, window.innerHeight);
2621
+ if (width === this.width && height === this.height)
2622
+ return;
2623
+ this.setSize(width, height);
2624
+ }
2625
+ syncToolbarFullscreenButton(active) {
2626
+ if (!this.toolbarFullscreenButton)
2627
+ return;
2628
+ this.toolbarFullscreenButton.textContent = active ? "Exit" : "Full";
2629
+ this.toolbarFullscreenButton.title = active ? "Exit full screen" : "Enter full screen";
2630
+ this.toolbarFullscreenButton.setAttribute("aria-label", active ? "Exit full screen" : "Enter full screen");
2631
+ this.toolbarFullscreenButton.setAttribute("aria-pressed", String(active));
2632
+ this.toolbarFullscreenButton.style.borderColor = active ? this.theme.colors.axis : this.theme.axis.color;
2633
+ this.toolbarFullscreenButton.style.background = active ? this.theme.colors.axis : (this.a11y.highContrast ? "#000000" : "#ffffff");
2634
+ this.toolbarFullscreenButton.style.color = active ? this.theme.colors.background : this.theme.colors.text;
2635
+ }
2636
+ cleanupToolbar() {
2637
+ this.toolbarExportOpen = false;
2638
+ this.toolbarExportBusy = false;
2639
+ this.toolbarPreFullscreenSize = null;
2640
+ document.removeEventListener("pointerdown", this.handleToolbarDocumentPointerDown);
2641
+ document.removeEventListener("keydown", this.handleToolbarDocumentKeyDown);
2642
+ document.removeEventListener("fullscreenchange", this.handleToolbarFullscreenChange);
2643
+ window.removeEventListener("resize", this.handleToolbarWindowResize);
2644
+ this.toolbarExportButton?.removeEventListener("click", this.handleToolbarExportToggle);
2645
+ this.toolbarExportMenu?.removeEventListener("click", this.handleToolbarExportMenuClick);
2646
+ this.toolbarFullscreenButton?.removeEventListener("click", this.handleToolbarFullscreenClick);
2647
+ this.toolbarEl = null;
2648
+ this.toolbarExportWrap = null;
2649
+ this.toolbarExportMenu = null;
2650
+ this.toolbarExportButton = null;
2651
+ this.toolbarFullscreenButton = null;
2652
+ }
2653
+ applyAriaAttributes() {
2654
+ const label = this.a11y.label || this.layout.title || "Interactive chart";
2655
+ this.container.setAttribute("role", "region");
2656
+ this.container.setAttribute("aria-roledescription", "interactive chart");
2657
+ this.container.setAttribute("aria-label", label);
2658
+ if (this.a11y.description) {
2659
+ this.container.setAttribute("aria-description", this.a11y.description);
2660
+ }
2661
+ else {
2662
+ this.container.removeAttribute("aria-description");
2663
+ }
2664
+ this.svg.setAttribute("role", "img");
2665
+ this.svg.setAttribute("aria-label", `${label} plot overlay`);
2666
+ }
2667
+ // ----------------------------
2668
+ // DOM
2669
+ // ----------------------------
2670
+ mountDom() {
2671
+ this.root.innerHTML = "";
2672
+ const container = document.createElement("div");
2673
+ this.container = container;
2674
+ container.className = "chart-container";
2675
+ Object.assign(container.style, {
2676
+ position: "relative",
2677
+ width: `${this.width}px`,
2678
+ height: `${this.height}px`,
2679
+ overflow: "hidden",
2680
+ background: this.theme.colors.background,
2681
+ color: this.theme.colors.text,
2682
+ fontFamily: this.theme.fonts.family,
2683
+ fontSize: `${this.theme.fonts.sizePx}px`
2684
+ });
2685
+ container.tabIndex = this.a11y.keyboardNavigation ? 0 : -1;
2686
+ if (this.a11y.keyboardNavigation) {
2687
+ container.setAttribute("aria-keyshortcuts", "ArrowLeft ArrowRight ArrowUp ArrowDown + - 0 F Y L");
2688
+ container.addEventListener("keydown", this.handleContainerKeyDown);
2689
+ }
2690
+ this.canvas = document.createElement("canvas");
2691
+ this.canvas.className = "chart-canvas";
2692
+ this.canvas.setAttribute("aria-hidden", "true");
2693
+ Object.assign(this.canvas.style, {
2694
+ position: "absolute",
2695
+ left: "0",
2696
+ top: "0",
2697
+ width: "100%",
2698
+ height: "100%",
2699
+ display: "block"
2700
+ });
2701
+ this.svgGrid = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2702
+ this.svgGrid.setAttribute("width", String(this.width));
2703
+ this.svgGrid.setAttribute("height", String(this.height));
2704
+ this.svgGrid.setAttribute("viewBox", `0 0 ${this.width} ${this.height}`);
2705
+ this.svgGrid.setAttribute("preserveAspectRatio", "none");
2706
+ this.svgGrid.setAttribute("aria-hidden", "true");
2707
+ this.svgGrid.classList.add("chart-grid");
2708
+ Object.assign(this.svgGrid.style, {
2709
+ position: "absolute",
2710
+ left: "0",
2711
+ top: "0",
2712
+ width: "100%",
2713
+ height: "100%",
2714
+ pointerEvents: "none",
2715
+ display: "block"
2716
+ });
2717
+ this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2718
+ this.svg.setAttribute("width", String(this.width));
2719
+ this.svg.setAttribute("height", String(this.height));
2720
+ this.svg.setAttribute("viewBox", `0 0 ${this.width} ${this.height}`);
2721
+ this.svg.setAttribute("preserveAspectRatio", "none");
2722
+ this.svg.classList.add("chart-overlay");
2723
+ Object.assign(this.svg.style, {
2724
+ position: "absolute",
2725
+ left: "0",
2726
+ top: "0",
2727
+ width: "100%",
2728
+ height: "100%",
2729
+ pointerEvents: "auto",
2730
+ display: "block"
2731
+ });
2732
+ this.tooltip = document.createElement("div");
2733
+ this.tooltip.className = "chart-tooltip";
2734
+ this.tooltip.setAttribute("role", "status");
2735
+ this.tooltip.setAttribute("aria-live", "polite");
2736
+ this.tooltip.setAttribute("aria-atomic", "true");
2737
+ this.tooltip.setAttribute("aria-hidden", "true");
2738
+ Object.assign(this.tooltip.style, {
2739
+ position: "absolute",
2740
+ left: "0px",
2741
+ top: "0px",
2742
+ transform: "translate(-9999px,-9999px)",
2743
+ pointerEvents: "none",
2744
+ background: this.theme.tooltip.background,
2745
+ color: this.theme.tooltip.textColor,
2746
+ padding: `${this.theme.tooltip.paddingY}px ${this.theme.tooltip.paddingX}px`,
2747
+ borderRadius: `${this.theme.tooltip.borderRadiusPx}px`,
2748
+ fontFamily: this.theme.tooltip.fontFamily,
2749
+ fontSize: `${this.theme.tooltip.fontSizePx}px`,
2750
+ whiteSpace: "nowrap",
2751
+ boxShadow: this.theme.tooltip.boxShadow,
2752
+ zIndex: "1000"
2753
+ });
2754
+ container.appendChild(this.svgGrid);
2755
+ container.appendChild(this.canvas);
2756
+ container.appendChild(this.svg);
2757
+ container.appendChild(this.tooltip);
2758
+ this.mountToolbar();
2759
+ this.applyAriaAttributes();
2760
+ this.root.appendChild(container);
2761
+ }
2762
+ }
2763
+ // ----------------------------
2764
+ // Helpers
2765
+ // ----------------------------
2766
+ function resolveChartA11y(a11y) {
2767
+ return {
2768
+ label: resolveOptionalString(a11y?.label),
2769
+ description: resolveOptionalString(a11y?.description),
2770
+ keyboardNavigation: a11y?.keyboardNavigation ?? true,
2771
+ highContrast: a11y?.highContrast ?? false
2772
+ };
2773
+ }
2774
+ function resolveChartTheme(theme, highContrast = false) {
2775
+ const defaults = highContrast
2776
+ ? {
2777
+ colors: {
2778
+ background: "#000000",
2779
+ text: "#ffffff",
2780
+ axis: "#ffffff",
2781
+ grid: "#8a8a8a",
2782
+ tooltipBackground: "#000000",
2783
+ tooltipText: "#ffffff",
2784
+ palette: HIGH_CONTRAST_PALETTE
2785
+ },
2786
+ axisText: "#ffffff",
2787
+ gridOpacity: 1,
2788
+ gridStrokeWidth: 1.2,
2789
+ tooltipShadow: "0 0 0 rgba(0,0,0,0)"
2790
+ }
2791
+ : {
2792
+ colors: {
2793
+ background: "#ffffff",
2794
+ text: "#111827",
2795
+ axis: "#9ca3af",
2796
+ grid: "#e5e7eb",
2797
+ tooltipBackground: "rgba(0,0,0,0.75)",
2798
+ tooltipText: "#ffffff",
2799
+ palette: DEFAULT_PALETTE
2800
+ },
2801
+ axisText: "#4b5563",
2802
+ gridOpacity: 1,
2803
+ gridStrokeWidth: 1,
2804
+ tooltipShadow: "0 8px 20px rgba(0,0,0,0.18)"
2805
+ };
2806
+ const colors = {
2807
+ background: resolveString(theme?.colors?.background, defaults.colors.background),
2808
+ text: resolveString(theme?.colors?.text, defaults.colors.text),
2809
+ axis: resolveString(theme?.colors?.axis, defaults.colors.axis),
2810
+ grid: resolveString(theme?.colors?.grid, defaults.colors.grid),
2811
+ tooltipBackground: resolveString(theme?.colors?.tooltipBackground, defaults.colors.tooltipBackground),
2812
+ tooltipText: resolveString(theme?.colors?.tooltipText, defaults.colors.tooltipText),
2813
+ palette: resolvePalette(theme?.colors?.palette, defaults.colors.palette)
2814
+ };
2815
+ const fonts = {
2816
+ family: resolveString(theme?.fonts?.family, DEFAULT_FONT_FAMILY),
2817
+ sizePx: clampToFinite(theme?.fonts?.sizePx, 1, 96, 12),
2818
+ axisFamily: resolveString(theme?.fonts?.axisFamily, resolveString(theme?.fonts?.family, DEFAULT_FONT_FAMILY)),
2819
+ axisSizePx: clampToFinite(theme?.fonts?.axisSizePx, 1, 96, clampToFinite(theme?.fonts?.sizePx, 1, 96, 12)),
2820
+ tooltipFamily: resolveString(theme?.fonts?.tooltipFamily, resolveString(theme?.fonts?.family, DEFAULT_FONT_FAMILY)),
2821
+ tooltipSizePx: clampToFinite(theme?.fonts?.tooltipSizePx, 1, 96, clampToFinite(theme?.fonts?.sizePx, 1, 96, 12))
2822
+ };
2823
+ const axis = {
2824
+ color: resolveString(theme?.axis?.color, colors.axis),
2825
+ textColor: resolveString(theme?.axis?.textColor, resolveString(theme?.colors?.text, defaults.axisText)),
2826
+ fontFamily: resolveString(theme?.axis?.fontFamily, fonts.axisFamily),
2827
+ fontSizePx: clampToFinite(theme?.axis?.fontSizePx, 1, 96, fonts.axisSizePx)
2828
+ };
2829
+ const grid = {
2830
+ show: theme?.grid?.show ?? true,
2831
+ color: resolveString(theme?.grid?.color, colors.grid),
2832
+ opacity: clampToFinite(theme?.grid?.opacity, 0, 1, defaults.gridOpacity),
2833
+ strokeWidth: clampToFinite(theme?.grid?.strokeWidth, 0, Number.POSITIVE_INFINITY, defaults.gridStrokeWidth)
2834
+ };
2835
+ const tooltip = {
2836
+ background: resolveString(theme?.tooltip?.background, colors.tooltipBackground),
2837
+ textColor: resolveString(theme?.tooltip?.textColor, colors.tooltipText),
2838
+ fontFamily: resolveString(theme?.tooltip?.fontFamily, fonts.tooltipFamily),
2839
+ fontSizePx: clampToFinite(theme?.tooltip?.fontSizePx, 1, 96, fonts.tooltipSizePx),
2840
+ borderRadiusPx: clampToFinite(theme?.tooltip?.borderRadiusPx, 0, 999, 8),
2841
+ paddingX: clampToFinite(theme?.tooltip?.paddingX, 0, 48, 8),
2842
+ paddingY: clampToFinite(theme?.tooltip?.paddingY, 0, 48, 6),
2843
+ boxShadow: resolveString(theme?.tooltip?.boxShadow, defaults.tooltipShadow)
2844
+ };
2845
+ return { colors, fonts, axis, grid, tooltip };
2846
+ }
2847
+ function resolveChartToolbar(toolbar) {
2848
+ const show = toolbar?.show ?? false;
2849
+ const position = resolveToolbarPosition(toolbar?.position);
2850
+ const fullscreen = toolbar?.fullscreen ?? true;
2851
+ const exportEnabled = toolbar?.export ?? true;
2852
+ const exportFormats = resolveToolbarExportFormats(toolbar?.exportFormats);
2853
+ const exportFilename = resolveString(toolbar?.exportFilename, "vertexa-chart");
2854
+ const exportPixelRatio = clampToFinite(toolbar?.exportPixelRatio, 0.25, 8, 2);
2855
+ return {
2856
+ show,
2857
+ position,
2858
+ fullscreen,
2859
+ export: exportEnabled,
2860
+ exportFormats,
2861
+ exportFilename,
2862
+ exportPixelRatio
2863
+ };
2864
+ }
2865
+ function resolveToolbarPosition(value) {
2866
+ if (value === "top-left" || value === "top-right" || value === "bottom-left" || value === "bottom-right") {
2867
+ return value;
2868
+ }
2869
+ return "top-right";
2870
+ }
2871
+ function resolveToolbarExportFormats(values) {
2872
+ if (!Array.isArray(values) || values.length === 0) {
2873
+ return DEFAULT_TOOLBAR_EXPORT_FORMATS.slice();
2874
+ }
2875
+ const deduped = [];
2876
+ for (const value of values) {
2877
+ if (!isToolbarExportFormat(value))
2878
+ continue;
2879
+ if (deduped.includes(value))
2880
+ continue;
2881
+ deduped.push(value);
2882
+ }
2883
+ return deduped.length > 0 ? deduped : DEFAULT_TOOLBAR_EXPORT_FORMATS.slice();
2884
+ }
2885
+ function isToolbarExportFormat(value) {
2886
+ return value === "png" || value === "svg" || value === "csv";
2887
+ }
2888
+ function sanitizeFilenamePart(value) {
2889
+ const trimmed = value.trim();
2890
+ if (!trimmed)
2891
+ return "vertexa-chart";
2892
+ const safe = trimmed.replaceAll(/[^a-zA-Z0-9._-]/g, "-").replaceAll(/-+/g, "-").replaceAll(/^-+|-+$/g, "");
2893
+ return safe.length > 0 ? safe : "vertexa-chart";
2894
+ }
2895
+ function resolvePalette(values, fallback = DEFAULT_PALETTE) {
2896
+ if (!Array.isArray(values) || values.length === 0) {
2897
+ return fallback.slice();
2898
+ }
2899
+ const palette = values
2900
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
2901
+ .filter((value) => value.length > 0);
2902
+ return palette.length > 0 ? palette : fallback.slice();
2903
+ }
2904
+ function resolveString(value, fallback) {
2905
+ if (typeof value !== "string")
2906
+ return fallback;
2907
+ const trimmed = value.trim();
2908
+ return trimmed.length > 0 ? trimmed : fallback;
2909
+ }
2910
+ function resolveOptionalString(value) {
2911
+ if (typeof value !== "string")
2912
+ return "";
2913
+ const trimmed = value.trim();
2914
+ return trimmed.length > 0 ? trimmed : "";
2915
+ }
2916
+ function clampToFinite(value, min, max, fallback) {
2917
+ if (typeof value !== "number" || !Number.isFinite(value))
2918
+ return fallback;
2919
+ if (value < min)
2920
+ return min;
2921
+ if (value > max)
2922
+ return max;
2923
+ return value;
2924
+ }
2925
+ function normalizeMaxPoints(value) {
2926
+ if (typeof value !== "number" || !Number.isFinite(value))
2927
+ return undefined;
2928
+ const floored = Math.floor(value);
2929
+ if (floored < 1)
2930
+ return undefined;
2931
+ return floored;
2932
+ }
2933
+ function toMutableDatumArray(values) {
2934
+ return Array.isArray(values) ? values : Array.from(values);
2935
+ }
2936
+ function normalizeExportPixelRatio(value) {
2937
+ if (typeof value !== "number" || !Number.isFinite(value))
2938
+ return 1;
2939
+ if (value < 0.25)
2940
+ return 0.25;
2941
+ if (value > 8)
2942
+ return 8;
2943
+ return value;
2944
+ }
2945
+ function serializeSvgMarkup(svg, width, height) {
2946
+ const clone = svg.cloneNode(true);
2947
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
2948
+ clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
2949
+ clone.setAttribute("width", String(width));
2950
+ clone.setAttribute("height", String(height));
2951
+ if (!clone.getAttribute("viewBox")) {
2952
+ clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
2953
+ }
2954
+ return new XMLSerializer().serializeToString(clone);
2955
+ }
2956
+ function serializeSvgToDataUrl(svg, width, height) {
2957
+ const markup = serializeSvgMarkup(svg, width, height);
2958
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
2959
+ }
2960
+ function loadImageFromUrl(url) {
2961
+ return new Promise((resolve, reject) => {
2962
+ const image = new Image();
2963
+ image.decoding = "async";
2964
+ image.onload = () => resolve(image);
2965
+ image.onerror = () => reject(new Error("Chart.exportPng(): failed to rasterize SVG layer."));
2966
+ image.src = url;
2967
+ });
2968
+ }
2969
+ function canvasToPngBlob(canvas) {
2970
+ return new Promise((resolve, reject) => {
2971
+ if (typeof canvas.toBlob !== "function") {
2972
+ reject(new Error("Chart.exportPng(): canvas.toBlob() is not available."));
2973
+ return;
2974
+ }
2975
+ canvas.toBlob((blob) => {
2976
+ if (!blob) {
2977
+ reject(new Error("Chart.exportPng(): failed to encode PNG."));
2978
+ return;
2979
+ }
2980
+ resolve(blob);
2981
+ }, "image/png");
2982
+ });
2983
+ }
2984
+ function canvasToPngDataUrl(canvas) {
2985
+ if (typeof canvas.toDataURL !== "function") {
2986
+ throw new Error("Chart.exportSvg(): canvas.toDataURL() is not available.");
2987
+ }
2988
+ try {
2989
+ return canvas.toDataURL("image/png");
2990
+ }
2991
+ catch {
2992
+ throw new Error("Chart.exportSvg(): failed to encode embedded plot image.");
2993
+ }
2994
+ }
2995
+ function toNumber(d, type) {
2996
+ if (type === "time")
2997
+ return d instanceof Date ? d.getTime() : Number(d);
2998
+ return d instanceof Date ? d.getTime() : Number(d);
2999
+ }
3000
+ function toAxisDatum(d, type) {
3001
+ if (type === "time") {
3002
+ const n = d instanceof Date ? d.getTime() : Number(d);
3003
+ return new Date(n);
3004
+ }
3005
+ return d instanceof Date ? d.getTime() : Number(d);
3006
+ }
3007
+ function fromAxisNumber(value, type) {
3008
+ if (type === "time")
3009
+ return new Date(value);
3010
+ return value;
3011
+ }
3012
+ function fromNormalizedDomain(n, domain, type) {
3013
+ const [d0, d1] = domain;
3014
+ if (type === "log") {
3015
+ const l0 = Math.log10(d0);
3016
+ const l1 = Math.log10(d1);
3017
+ return 10 ** (l0 + (l1 - l0) * n);
3018
+ }
3019
+ return d0 + (d1 - d0) * n;
3020
+ }
3021
+ function axisSpan(domain, type) {
3022
+ if (type === "log") {
3023
+ return Math.log10(domain[1]) - Math.log10(domain[0]);
3024
+ }
3025
+ return domain[1] - domain[0];
3026
+ }
3027
+ function lockAxisSpan(domain, targetSpan, type) {
3028
+ if (type === "log") {
3029
+ const ly0 = Math.log10(domain[0]);
3030
+ const ly1 = Math.log10(domain[1]);
3031
+ const center = (ly0 + ly1) * 0.5;
3032
+ const half = targetSpan * 0.5;
3033
+ return [10 ** (center - half), 10 ** (center + half)];
3034
+ }
3035
+ const center = (domain[0] + domain[1]) * 0.5;
3036
+ const half = targetSpan * 0.5;
3037
+ return [center - half, center + half];
3038
+ }
3039
+ function coerceMargin(value, fallback) {
3040
+ if (typeof value !== "number" || !Number.isFinite(value))
3041
+ return fallback;
3042
+ return Math.max(0, Math.round(value));
3043
+ }
3044
+ function stripAxisBounds(axis) {
3045
+ if (!axis)
3046
+ return undefined;
3047
+ const next = { ...axis };
3048
+ delete next.domain;
3049
+ delete next.range;
3050
+ delete next.min;
3051
+ delete next.max;
3052
+ next.autorange = true;
3053
+ return next;
3054
+ }
3055
+ function fmtDatum(d) {
3056
+ if (d instanceof Date)
3057
+ return d.toISOString();
3058
+ return String(d);
3059
+ }
3060
+ function fmtNumber(value) {
3061
+ if (typeof value !== "number" || !Number.isFinite(value))
3062
+ return "n/a";
3063
+ return String(value);
3064
+ }
3065
+ function toCsvRow(fields) {
3066
+ return fields.map(csvEscape).join(",");
3067
+ }
3068
+ function csvEscape(value) {
3069
+ if (!/[",\r\n]/.test(value))
3070
+ return value;
3071
+ return `"${value.replaceAll('"', '""')}"`;
3072
+ }
3073
+ function escapeXmlAttribute(value) {
3074
+ return value
3075
+ .replaceAll("&", "&amp;")
3076
+ .replaceAll('"', "&quot;")
3077
+ .replaceAll("<", "&lt;")
3078
+ .replaceAll(">", "&gt;")
3079
+ .replaceAll("'", "&apos;");
3080
+ }
3081
+ function isTextEntryElement(node) {
3082
+ if (!node)
3083
+ return false;
3084
+ const tag = node.tagName.toLowerCase();
3085
+ if (tag === "input" || tag === "textarea" || tag === "select")
3086
+ return true;
3087
+ const htmlNode = node;
3088
+ return Boolean(htmlNode.isContentEditable);
3089
+ }
3090
+ function clamp01(x) {
3091
+ return x < 0 ? 0 : x > 1 ? 1 : x;
3092
+ }
3093
+ function parseColor(s) {
3094
+ if (!s)
3095
+ return null;
3096
+ const str = s.trim().toLowerCase();
3097
+ if (str.startsWith("#")) {
3098
+ const hex = str.slice(1);
3099
+ if (hex.length === 3) {
3100
+ const r = parseInt(hex[0] + hex[0], 16);
3101
+ const g = parseInt(hex[1] + hex[1], 16);
3102
+ const b = parseInt(hex[2] + hex[2], 16);
3103
+ return [r / 255, g / 255, b / 255];
3104
+ }
3105
+ if (hex.length === 6) {
3106
+ const r = parseInt(hex.slice(0, 2), 16);
3107
+ const g = parseInt(hex.slice(2, 4), 16);
3108
+ const b = parseInt(hex.slice(4, 6), 16);
3109
+ return [r / 255, g / 255, b / 255];
3110
+ }
3111
+ return null;
3112
+ }
3113
+ const m = str.match(/^rgba?\((.+)\)$/);
3114
+ if (m) {
3115
+ const parts = m[1].split(",").map((p) => p.trim());
3116
+ if (parts.length >= 3) {
3117
+ const r = Number(parts[0]);
3118
+ const g = Number(parts[1]);
3119
+ const b = Number(parts[2]);
3120
+ if ([r, g, b].every(Number.isFinite))
3121
+ return [r / 255, g / 255, b / 255];
3122
+ }
3123
+ }
3124
+ return null;
3125
+ }
3126
+ function cssColorToRgba(color, alpha) {
3127
+ const c = parseColor(color) ?? [0.12, 0.55, 0.95];
3128
+ return [c[0], c[1], c[2], alpha];
3129
+ }
3130
+ function escapeHtml(value) {
3131
+ return value
3132
+ .replaceAll("&", "&amp;")
3133
+ .replaceAll("<", "&lt;")
3134
+ .replaceAll(">", "&gt;")
3135
+ .replaceAll("\"", "&quot;")
3136
+ .replaceAll("'", "&#39;");
3137
+ }
3138
+ function lowerBoundIdx(order, values, x) {
3139
+ let lo = 0, hi = order.length;
3140
+ while (lo < hi) {
3141
+ const mid = (lo + hi) >>> 1;
3142
+ const v = values[order[mid]];
3143
+ if (v < x)
3144
+ lo = mid + 1;
3145
+ else
3146
+ hi = mid;
3147
+ }
3148
+ return lo;
3149
+ }
3150
+ function sortedOrder(values) {
3151
+ const n = values.length;
3152
+ const arr = new Array(n);
3153
+ for (let i = 0; i < n; i++)
3154
+ arr[i] = i;
3155
+ arr.sort((a, b) => values[a] - values[b]);
3156
+ const out = new Uint32Array(n);
3157
+ for (let i = 0; i < n; i++)
3158
+ out[i] = arr[i];
3159
+ return out;
3160
+ }
3161
+ function pointInPolygon(x, y, polygon) {
3162
+ const n = polygon.length;
3163
+ if (n < 3)
3164
+ return false;
3165
+ let inside = false;
3166
+ for (let i = 0, j = n - 1; i < n; j = i++) {
3167
+ const xi = polygon[i].x;
3168
+ const yi = polygon[i].y;
3169
+ const xj = polygon[j].x;
3170
+ const yj = polygon[j].y;
3171
+ const intersects = ((yi > y) !== (yj > y))
3172
+ && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || Number.EPSILON) + xi);
3173
+ if (intersects)
3174
+ inside = !inside;
3175
+ }
3176
+ return inside;
3177
+ }
3178
+ function catmullRom(p0, p1, p2, p3, t) {
3179
+ const t2 = t * t;
3180
+ const t3 = t2 * t;
3181
+ return 0.5 * ((2 * p1) +
3182
+ (-p0 + p2) * t +
3183
+ (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
3184
+ (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
3185
+ }
3186
+ //# sourceMappingURL=Chart.js.map