@opendata-ai/openchart-vanilla 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
package/src/mount.ts ADDED
@@ -0,0 +1,1639 @@
1
+ /**
2
+ * Mount API: the main entry point for vanilla JS usage.
3
+ *
4
+ * createChart() takes a container, spec, and options, compiles the chart,
5
+ * renders it as SVG, sets up responsive resizing, tooltip interaction
6
+ * (mouse/touch/keyboard), keyboard navigation between data points,
7
+ * and returns a ChartInstance with update/resize/export/destroy methods.
8
+ */
9
+
10
+ import type {
11
+ Annotation,
12
+ AnnotationOffset,
13
+ ChartEventHandlers,
14
+ ChartLayout,
15
+ ChartSpec,
16
+ ChromeKey,
17
+ CompileOptions,
18
+ DarkMode,
19
+ ElementEdit,
20
+ GraphSpec,
21
+ MeasureTextFn,
22
+ RangeAnnotation,
23
+ RefLineAnnotation,
24
+ TextAnnotation,
25
+ ThemeConfig,
26
+ TooltipContent,
27
+ } from '@opendata-ai/openchart-core';
28
+ import { compileChart } from '@opendata-ai/openchart-engine';
29
+ import { exportCSV, exportPNG, exportSVG, type PNGExportOptions } from './export';
30
+ import { observeResize } from './resize-observer';
31
+ import { renderChartSVG } from './svg-renderer';
32
+ import { createTooltipManager, type TooltipManager } from './tooltip';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface MountOptions extends ChartEventHandlers {
39
+ /** Theme overrides. */
40
+ theme?: ThemeConfig;
41
+ /** Dark mode setting: "auto" (system pref), "force", or "off". */
42
+ darkMode?: DarkMode;
43
+ /** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */
44
+ onDataPointClick?: (data: Record<string, unknown>) => void;
45
+ /** Enable responsive resizing. Defaults to true. */
46
+ responsive?: boolean;
47
+ }
48
+
49
+ export interface ExportOptions extends PNGExportOptions {
50
+ // Extensible for future formats
51
+ }
52
+
53
+ export interface ChartInstance {
54
+ /** Re-compile and re-render with a new spec. */
55
+ update(spec: ChartSpec | GraphSpec): void;
56
+ /** Re-compile at current container dimensions. */
57
+ resize(): void;
58
+ /** Export the chart. */
59
+ export(format: 'svg'): string;
60
+ export(format: 'png', options?: ExportOptions): Promise<Blob>;
61
+ export(format: 'csv'): string;
62
+ export(format: 'svg' | 'png' | 'csv', options?: ExportOptions): string | Promise<Blob>;
63
+ /** Remove all DOM elements and disconnect observers. */
64
+ destroy(): void;
65
+ /** The current compiled layout (for hooks / debugging). */
66
+ readonly layout: ChartLayout;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Dark mode resolution
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function resolveDarkMode(mode?: DarkMode): boolean {
74
+ if (mode === 'force') return true;
75
+ if (mode === 'off' || mode === undefined) return false;
76
+ // "auto": check system preference
77
+ if (typeof window !== 'undefined' && window.matchMedia) {
78
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // measureText via hidden canvas
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function createMeasureText(): MeasureTextFn {
88
+ let canvas: HTMLCanvasElement | null = null;
89
+ let ctx: CanvasRenderingContext2D | null = null;
90
+
91
+ return (
92
+ text: string,
93
+ fontSize: number,
94
+ fontWeight?: number,
95
+ ): { width: number; height: number } => {
96
+ if (!canvas) {
97
+ canvas = document.createElement('canvas');
98
+ ctx = canvas.getContext('2d');
99
+ }
100
+ if (!ctx) {
101
+ // Fallback: heuristic estimation
102
+ return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
103
+ }
104
+
105
+ const weight = fontWeight ?? 400;
106
+ ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
107
+ const metrics = ctx.measureText(text);
108
+ return {
109
+ width: metrics.width,
110
+ height: fontSize * 1.2,
111
+ };
112
+ };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Tooltip event wiring
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Wire tooltip events on mark elements inside an SVG.
121
+ * Returns a cleanup function to remove all listeners.
122
+ */
123
+ function wireTooltipEvents(
124
+ svg: SVGElement,
125
+ tooltipDescriptors: Map<string, TooltipContent>,
126
+ tooltipManager: TooltipManager,
127
+ ): () => void {
128
+ const markElements = svg.querySelectorAll('[data-mark-id]');
129
+ const cleanups: Array<() => void> = [];
130
+
131
+ for (const el of markElements) {
132
+ const markId = el.getAttribute('data-mark-id');
133
+ if (!markId) continue;
134
+
135
+ const content = tooltipDescriptors.get(markId);
136
+ if (!content) continue;
137
+
138
+ // Mouse enter -> show tooltip
139
+ const handleMouseEnter = (e: Event) => {
140
+ const mouseEvent = e as MouseEvent;
141
+ const svgRect = svg.getBoundingClientRect();
142
+ const x = mouseEvent.clientX - svgRect.left;
143
+ const y = mouseEvent.clientY - svgRect.top;
144
+ tooltipManager.show(content, x, y);
145
+ };
146
+
147
+ // Mouse move -> reposition tooltip
148
+ const handleMouseMove = (e: Event) => {
149
+ const mouseEvent = e as MouseEvent;
150
+ const svgRect = svg.getBoundingClientRect();
151
+ const x = mouseEvent.clientX - svgRect.left;
152
+ const y = mouseEvent.clientY - svgRect.top;
153
+ tooltipManager.show(content, x, y);
154
+ };
155
+
156
+ // Mouse leave -> hide tooltip
157
+ const handleMouseLeave = () => {
158
+ tooltipManager.hide();
159
+ };
160
+
161
+ // Touch: tap to show
162
+ const handleTouchStart = (e: Event) => {
163
+ const touchEvent = e as TouchEvent;
164
+ if (touchEvent.touches.length > 0) {
165
+ const touch = touchEvent.touches[0];
166
+ const svgRect = svg.getBoundingClientRect();
167
+ const x = touch.clientX - svgRect.left;
168
+ const y = touch.clientY - svgRect.top;
169
+ tooltipManager.show(content, x, y);
170
+ }
171
+ };
172
+
173
+ el.addEventListener('mouseenter', handleMouseEnter);
174
+ el.addEventListener('mousemove', handleMouseMove);
175
+ el.addEventListener('mouseleave', handleMouseLeave);
176
+ el.addEventListener('touchstart', handleTouchStart);
177
+
178
+ cleanups.push(() => {
179
+ el.removeEventListener('mouseenter', handleMouseEnter);
180
+ el.removeEventListener('mousemove', handleMouseMove);
181
+ el.removeEventListener('mouseleave', handleMouseLeave);
182
+ el.removeEventListener('touchstart', handleTouchStart);
183
+ });
184
+ }
185
+
186
+ return () => {
187
+ for (const cleanup of cleanups) {
188
+ cleanup();
189
+ }
190
+ };
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Build a map from data-mark-id to { datum, series } so event handlers
199
+ * can look up the data row associated with a clicked/hovered mark element.
200
+ */
201
+ function buildMarkDataMap(
202
+ layout: ChartLayout,
203
+ ): Map<string, { datum: Record<string, unknown>; series?: string }> {
204
+ const map = new Map<string, { datum: Record<string, unknown>; series?: string }>();
205
+
206
+ for (let i = 0; i < layout.marks.length; i++) {
207
+ const mark = layout.marks[i];
208
+ switch (mark.type) {
209
+ case 'line':
210
+ map.set(`line-${mark.seriesKey ?? i}`, {
211
+ // For line marks, data is an array. Use the first row as representative.
212
+ datum: mark.data[0] ?? {},
213
+ series: mark.seriesKey,
214
+ });
215
+ break;
216
+ case 'area':
217
+ map.set(`area-${mark.seriesKey ?? i}`, {
218
+ datum: mark.data[0] ?? {},
219
+ series: mark.seriesKey,
220
+ });
221
+ break;
222
+ case 'rect':
223
+ map.set(`rect-${i}`, { datum: mark.data });
224
+ break;
225
+ case 'arc':
226
+ map.set(`arc-${i}`, { datum: mark.data });
227
+ break;
228
+ case 'point':
229
+ map.set(`point-${i}`, { datum: mark.data });
230
+ break;
231
+ }
232
+ }
233
+
234
+ return map;
235
+ }
236
+
237
+ /**
238
+ * Wire chart event handlers (onMarkClick, onMarkHover, onMarkLeave) to mark
239
+ * elements, onLegendToggle to legend entries, and onAnnotationClick to annotation
240
+ * elements inside an SVG.
241
+ *
242
+ * Returns a cleanup function to remove all listeners.
243
+ */
244
+ function wireChartEvents(
245
+ svg: SVGElement,
246
+ layout: ChartLayout,
247
+ specAnnotations: import('@opendata-ai/openchart-core').Annotation[],
248
+ handlers: ChartEventHandlers,
249
+ ): () => void {
250
+ const cleanups: Array<() => void> = [];
251
+ const markDataMap = buildMarkDataMap(layout);
252
+
253
+ // Wire mark click/hover/leave events
254
+ if (handlers.onMarkClick || handlers.onMarkHover || handlers.onMarkLeave) {
255
+ const markElements = svg.querySelectorAll('[data-mark-id]');
256
+
257
+ for (const el of markElements) {
258
+ const markId = el.getAttribute('data-mark-id');
259
+ if (!markId) continue;
260
+
261
+ const markInfo = markDataMap.get(markId);
262
+ if (!markInfo) continue;
263
+
264
+ const series = markInfo.series ?? el.getAttribute('data-series') ?? undefined;
265
+
266
+ if (handlers.onMarkClick) {
267
+ const handleClick = (e: Event) => {
268
+ const mouseEvent = e as MouseEvent;
269
+ const svgRect = svg.getBoundingClientRect();
270
+ handlers.onMarkClick!({
271
+ datum: markInfo.datum,
272
+ series,
273
+ position: {
274
+ x: mouseEvent.clientX - svgRect.left,
275
+ y: mouseEvent.clientY - svgRect.top,
276
+ },
277
+ event: mouseEvent,
278
+ });
279
+ };
280
+ el.addEventListener('click', handleClick);
281
+ cleanups.push(() => el.removeEventListener('click', handleClick));
282
+ }
283
+
284
+ if (handlers.onMarkHover) {
285
+ const handleEnter = (e: Event) => {
286
+ const mouseEvent = e as MouseEvent;
287
+ const svgRect = svg.getBoundingClientRect();
288
+ handlers.onMarkHover!({
289
+ datum: markInfo.datum,
290
+ series,
291
+ position: {
292
+ x: mouseEvent.clientX - svgRect.left,
293
+ y: mouseEvent.clientY - svgRect.top,
294
+ },
295
+ event: mouseEvent,
296
+ });
297
+ };
298
+ el.addEventListener('mouseenter', handleEnter);
299
+ cleanups.push(() => el.removeEventListener('mouseenter', handleEnter));
300
+ }
301
+
302
+ if (handlers.onMarkLeave) {
303
+ const handleLeave = () => {
304
+ handlers.onMarkLeave!();
305
+ };
306
+ el.addEventListener('mouseleave', handleLeave);
307
+ cleanups.push(() => el.removeEventListener('mouseleave', handleLeave));
308
+ }
309
+ }
310
+ }
311
+
312
+ // Wire annotation click events
313
+ if (handlers.onAnnotationClick) {
314
+ const annotationElements = svg.querySelectorAll('.viz-annotation');
315
+
316
+ for (let i = 0; i < annotationElements.length; i++) {
317
+ const el = annotationElements[i];
318
+ const specAnnotation = specAnnotations[i];
319
+ if (!specAnnotation) continue;
320
+
321
+ const handleClick = (e: Event) => {
322
+ const mouseEvent = e as MouseEvent;
323
+ handlers.onAnnotationClick!(specAnnotation, mouseEvent);
324
+ };
325
+
326
+ el.addEventListener('click', handleClick);
327
+ cleanups.push(() => el.removeEventListener('click', handleClick));
328
+ }
329
+ }
330
+
331
+ return () => {
332
+ for (const cleanup of cleanups) {
333
+ cleanup();
334
+ }
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Shared drag handler utility
340
+ // ---------------------------------------------------------------------------
341
+
342
+ interface DragConfig {
343
+ element: SVGElement;
344
+ svg: SVGSVGElement;
345
+ onMove: (dx: number, dy: number) => void;
346
+ onEnd: (dx: number, dy: number, moved: boolean) => void;
347
+ setDragging: (dragging: boolean) => void;
348
+ threshold?: number; // default: 3
349
+ }
350
+
351
+ /**
352
+ * Reusable drag handler for SVG elements.
353
+ * Handles mouse and touch events, viewBox scaling, threshold detection,
354
+ * click suppression after drag, and cursor state.
355
+ *
356
+ * Returns a cleanup function that removes all listeners.
357
+ */
358
+ function createDragHandler(config: DragConfig): () => void {
359
+ const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config;
360
+ const cleanups: Array<() => void> = [];
361
+
362
+ // Track active document listeners so cleanup can remove them mid-drag
363
+ let activeDocMouseMove: ((e: MouseEvent) => void) | null = null;
364
+ let activeDocMouseUp: ((e: MouseEvent) => void) | null = null;
365
+ let activeDocTouchMove: ((e: TouchEvent) => void) | null = null;
366
+ let activeDocTouchEnd: ((e: TouchEvent) => void) | null = null;
367
+ let activeDocTouchCancel: ((e: TouchEvent) => void) | null = null;
368
+
369
+ function getScale(): { scaleX: number; scaleY: number } {
370
+ const viewBox = svg.viewBox?.baseVal;
371
+ const svgRect = svg.getBoundingClientRect();
372
+ return {
373
+ scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1,
374
+ scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1,
375
+ };
376
+ }
377
+
378
+ function startDrag(startX: number, startY: number): void {
379
+ setDragging(true);
380
+ const { scaleX, scaleY } = getScale();
381
+
382
+ element.style.cursor = 'grabbing';
383
+ // Prevent text selection during drag
384
+ svg.style.userSelect = 'none';
385
+
386
+ const handleMove = (clientX: number, clientY: number) => {
387
+ const dx = (clientX - startX) * scaleX;
388
+ const dy = (clientY - startY) * scaleY;
389
+ onMove(dx, dy);
390
+ };
391
+
392
+ const cleanupDocListeners = () => {
393
+ if (activeDocMouseMove) {
394
+ document.removeEventListener('mousemove', activeDocMouseMove);
395
+ activeDocMouseMove = null;
396
+ }
397
+ if (activeDocMouseUp) {
398
+ document.removeEventListener('mouseup', activeDocMouseUp);
399
+ activeDocMouseUp = null;
400
+ }
401
+ if (activeDocTouchMove) {
402
+ document.removeEventListener('touchmove', activeDocTouchMove);
403
+ activeDocTouchMove = null;
404
+ }
405
+ if (activeDocTouchEnd) {
406
+ document.removeEventListener('touchend', activeDocTouchEnd);
407
+ activeDocTouchEnd = null;
408
+ }
409
+ if (activeDocTouchCancel) {
410
+ document.removeEventListener('touchcancel', activeDocTouchCancel);
411
+ activeDocTouchCancel = null;
412
+ }
413
+ };
414
+
415
+ const handleEnd = (clientX: number, clientY: number) => {
416
+ const dx = (clientX - startX) * scaleX;
417
+ const dy = (clientY - startY) * scaleY;
418
+ const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold;
419
+
420
+ onEnd(dx, dy, moved);
421
+
422
+ // Suppress click if drag actually moved
423
+ if (moved) {
424
+ element.addEventListener(
425
+ 'click',
426
+ (clickE) => {
427
+ clickE.stopPropagation();
428
+ },
429
+ { capture: true, once: true },
430
+ );
431
+ }
432
+
433
+ element.style.cursor = 'grab';
434
+ svg.style.userSelect = '';
435
+
436
+ cleanupDocListeners();
437
+ setDragging(false);
438
+ };
439
+
440
+ // Mouse listeners
441
+ const onMouseMove = (moveEvent: MouseEvent) => {
442
+ handleMove(moveEvent.clientX, moveEvent.clientY);
443
+ };
444
+ const onMouseUp = (upEvent: MouseEvent) => {
445
+ handleEnd(upEvent.clientX, upEvent.clientY);
446
+ };
447
+ document.addEventListener('mousemove', onMouseMove);
448
+ document.addEventListener('mouseup', onMouseUp);
449
+ activeDocMouseMove = onMouseMove;
450
+ activeDocMouseUp = onMouseUp;
451
+
452
+ // Touch listeners
453
+ const onTouchMove = (moveEvent: TouchEvent) => {
454
+ if (moveEvent.touches.length > 0) {
455
+ moveEvent.preventDefault();
456
+ handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY);
457
+ }
458
+ };
459
+ const onTouchEnd = (endEvent: TouchEvent) => {
460
+ const touch = endEvent.changedTouches[0];
461
+ if (touch) {
462
+ handleEnd(touch.clientX, touch.clientY);
463
+ } else {
464
+ handleEnd(startX, startY);
465
+ }
466
+ };
467
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
468
+ document.addEventListener('touchend', onTouchEnd);
469
+ document.addEventListener('touchcancel', onTouchEnd);
470
+ activeDocTouchMove = onTouchMove;
471
+ activeDocTouchEnd = onTouchEnd;
472
+ activeDocTouchCancel = onTouchEnd;
473
+ }
474
+
475
+ // Mouse down handler
476
+ const handleMouseDown = (e: Event) => {
477
+ const mouseEvent = e as MouseEvent;
478
+ mouseEvent.preventDefault();
479
+ startDrag(mouseEvent.clientX, mouseEvent.clientY);
480
+ };
481
+
482
+ // Touch start handler
483
+ const handleTouchStart = (e: Event) => {
484
+ const touchEvent = e as TouchEvent;
485
+ if (touchEvent.touches.length === 1) {
486
+ touchEvent.preventDefault();
487
+ startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY);
488
+ }
489
+ };
490
+
491
+ element.addEventListener('mousedown', handleMouseDown);
492
+ element.addEventListener('touchstart', handleTouchStart, { passive: false });
493
+ cleanups.push(() => {
494
+ element.removeEventListener('mousedown', handleMouseDown);
495
+ element.removeEventListener('touchstart', handleTouchStart);
496
+ });
497
+
498
+ return () => {
499
+ for (const cleanup of cleanups) {
500
+ cleanup();
501
+ }
502
+ // Clean up any active document listeners (mid-drag unmount)
503
+ if (activeDocMouseMove) {
504
+ document.removeEventListener('mousemove', activeDocMouseMove);
505
+ activeDocMouseMove = null;
506
+ }
507
+ if (activeDocMouseUp) {
508
+ document.removeEventListener('mouseup', activeDocMouseUp);
509
+ activeDocMouseUp = null;
510
+ }
511
+ if (activeDocTouchMove) {
512
+ document.removeEventListener('touchmove', activeDocTouchMove);
513
+ activeDocTouchMove = null;
514
+ }
515
+ if (activeDocTouchEnd) {
516
+ document.removeEventListener('touchend', activeDocTouchEnd);
517
+ activeDocTouchEnd = null;
518
+ }
519
+ if (activeDocTouchCancel) {
520
+ document.removeEventListener('touchcancel', activeDocTouchCancel);
521
+ activeDocTouchCancel = null;
522
+ }
523
+ // Restore user-select in case of mid-drag cleanup
524
+ svg.style.userSelect = '';
525
+ };
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Annotation drag editing
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /**
533
+ * Wire drag-to-reposition on text annotation labels.
534
+ * Only activates for text annotations (not range or refline).
535
+ * During drag, applies a CSS transform for real-time visual feedback and
536
+ * counter-adjusts straight connector endpoints so the data-point end stays fixed.
537
+ * On mouseup, fires the callback with the updated offset values.
538
+ *
539
+ * Returns a cleanup function to remove all listeners.
540
+ */
541
+ function wireAnnotationDrag(
542
+ svg: SVGElement,
543
+ specAnnotations: Annotation[],
544
+ onAnnotationEdit:
545
+ | ((annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void)
546
+ | undefined,
547
+ onEdit: ((edit: ElementEdit) => void) | undefined,
548
+ setDragging: (dragging: boolean) => void,
549
+ ): () => void {
550
+ const annotationElements = svg.querySelectorAll('.viz-annotation-text');
551
+ const cleanups: Array<() => void> = [];
552
+
553
+ for (const el of annotationElements) {
554
+ const indexStr = el.getAttribute('data-annotation-index');
555
+ if (indexStr === null) continue;
556
+
557
+ const index = Number(indexStr);
558
+ const specAnnotation = specAnnotations[index];
559
+ if (!specAnnotation || specAnnotation.type !== 'text') continue;
560
+
561
+ const textAnnotation = specAnnotation as TextAnnotation;
562
+ const annotationG = el as SVGGElement;
563
+
564
+ // Visual affordance: show grab cursor
565
+ annotationG.style.cursor = 'grab';
566
+
567
+ // Stash connector info for real-time updates during drag
568
+ const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
569
+ const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
570
+ const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
571
+
572
+ // For curved connectors, stash path/polygon elements to hide during drag
573
+ const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
574
+ const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
575
+ const hasCurvedConnector = curvedPath !== null;
576
+
577
+ const origDx = textAnnotation.offset?.dx ?? 0;
578
+ const origDy = textAnnotation.offset?.dy ?? 0;
579
+
580
+ const cleanup = createDragHandler({
581
+ element: annotationG,
582
+ svg: svg as unknown as SVGSVGElement,
583
+ onMove: (dx, dy) => {
584
+ // Move the entire annotation group
585
+ annotationG.setAttribute('transform', `translate(${dx}, ${dy})`);
586
+
587
+ // For straight connectors, counter-adjust the data-point end
588
+ if (connectorLine && !hasCurvedConnector) {
589
+ connectorLine.setAttribute('x2', String(origX2 - dx));
590
+ connectorLine.setAttribute('y2', String(origY2 - dy));
591
+ }
592
+
593
+ // Hide curved connector elements during drag
594
+ if (hasCurvedConnector) {
595
+ if (curvedPath) curvedPath.setAttribute('display', 'none');
596
+ if (arrowhead) arrowhead.setAttribute('display', 'none');
597
+ }
598
+ },
599
+ onEnd: (dx, dy, moved) => {
600
+ // Clean up visual state
601
+ annotationG.removeAttribute('transform');
602
+
603
+ // Restore straight connector to original values
604
+ if (connectorLine && !hasCurvedConnector) {
605
+ connectorLine.setAttribute('x2', String(origX2));
606
+ connectorLine.setAttribute('y2', String(origY2));
607
+ }
608
+
609
+ // Restore curved connector elements
610
+ if (hasCurvedConnector) {
611
+ if (curvedPath) curvedPath.removeAttribute('display');
612
+ if (arrowhead) arrowhead.removeAttribute('display');
613
+ }
614
+
615
+ if (moved) {
616
+ const newOffset: AnnotationOffset = {
617
+ dx: origDx + dx,
618
+ dy: origDy + dy,
619
+ };
620
+ // Fire legacy callback
621
+ onAnnotationEdit?.(textAnnotation, newOffset);
622
+ // Fire unified edit callback
623
+ onEdit?.({ type: 'annotation', annotation: textAnnotation, offset: newOffset });
624
+ }
625
+ },
626
+ setDragging,
627
+ });
628
+
629
+ cleanups.push(cleanup);
630
+ }
631
+
632
+ return () => {
633
+ for (const cleanup of cleanups) {
634
+ cleanup();
635
+ }
636
+ };
637
+ }
638
+
639
+ // ---------------------------------------------------------------------------
640
+ // Connector endpoint drag
641
+ // ---------------------------------------------------------------------------
642
+
643
+ /**
644
+ * Wire drag on connector endpoint handles for text annotations.
645
+ * Dynamically creates invisible handle circles at connector endpoints
646
+ * so they only exist when editing is active (not in every chart).
647
+ * During drag, updates the handle position and the connector line endpoints.
648
+ * On end, fires onEdit with the accumulated endpoint offset.
649
+ *
650
+ * Shows handles on hover over the parent annotation group.
651
+ * Returns a cleanup function that removes handles and all listeners.
652
+ */
653
+ function wireConnectorEndpointDrag(
654
+ svg: SVGElement,
655
+ specAnnotations: Annotation[],
656
+ onEdit: (edit: ElementEdit) => void,
657
+ setDragging: (dragging: boolean) => void,
658
+ ): () => void {
659
+ const SVG_NS = 'http://www.w3.org/2000/svg';
660
+ const cleanups: Array<() => void> = [];
661
+ const annotationGroups = svg.querySelectorAll('.viz-annotation-text');
662
+
663
+ for (const el of annotationGroups) {
664
+ const annotationG = el as SVGGElement;
665
+ const indexStr = annotationG.getAttribute('data-annotation-index');
666
+ if (indexStr === null) continue;
667
+
668
+ const index = Number(indexStr);
669
+ const specAnnotation = specAnnotations[index];
670
+ if (!specAnnotation || specAnnotation.type !== 'text') continue;
671
+
672
+ const textAnnotation = specAnnotation as TextAnnotation;
673
+
674
+ // Find connector line or curved connector to determine endpoints
675
+ const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
676
+ const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
677
+ if (!connectorLine && !curvedPath) continue;
678
+
679
+ // Determine connector endpoint positions from the connector element
680
+ let fromX: number, fromY: number, toX: number, toY: number;
681
+ if (connectorLine) {
682
+ fromX = Number(connectorLine.getAttribute('x1'));
683
+ fromY = Number(connectorLine.getAttribute('y1'));
684
+ toX = Number(connectorLine.getAttribute('x2'));
685
+ toY = Number(connectorLine.getAttribute('y2'));
686
+ } else {
687
+ // For curved connectors, get positions from the path data
688
+ // The path starts at M x y, so parse the first coordinates
689
+ const pathD = curvedPath!.getAttribute('d') ?? '';
690
+ const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
691
+ fromX = mMatch ? Number(mMatch[1]) : 0;
692
+ fromY = mMatch ? Number(mMatch[2]) : 0;
693
+ // For curved connectors, the arrow polygon has the target
694
+ const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
695
+ const points = arrowhead?.getAttribute('points') ?? '';
696
+ const firstPoint = points.split(' ')[0] ?? '0,0';
697
+ const [px, py] = firstPoint.split(',');
698
+ toX = Number(px);
699
+ toY = Number(py);
700
+ }
701
+
702
+ // Create handles dynamically
703
+ const endpoints: Array<{ name: 'from' | 'to'; cx: number; cy: number }> = [
704
+ { name: 'from', cx: fromX, cy: fromY },
705
+ { name: 'to', cx: toX, cy: toY },
706
+ ];
707
+
708
+ const createdHandles: SVGCircleElement[] = [];
709
+
710
+ for (const ep of endpoints) {
711
+ const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
712
+ handleEl.setAttribute('class', 'viz-connector-handle');
713
+ handleEl.setAttribute('data-endpoint', ep.name);
714
+ handleEl.setAttribute('cx', String(ep.cx));
715
+ handleEl.setAttribute('cy', String(ep.cy));
716
+ handleEl.setAttribute('r', '4');
717
+ handleEl.setAttribute('opacity', '0');
718
+ handleEl.setAttribute('fill', 'currentColor');
719
+ handleEl.setAttribute('stroke', 'currentColor');
720
+ annotationG.appendChild(handleEl);
721
+ createdHandles.push(handleEl);
722
+
723
+ const origCx = ep.cx;
724
+ const origCy = ep.cy;
725
+
726
+ // Prevent parent annotation drag from firing
727
+ const stopProp = (e: Event) => {
728
+ e.stopPropagation();
729
+ };
730
+ handleEl.addEventListener('mousedown', stopProp);
731
+ handleEl.addEventListener('touchstart', stopProp);
732
+ cleanups.push(() => {
733
+ handleEl.removeEventListener('mousedown', stopProp);
734
+ handleEl.removeEventListener('touchstart', stopProp);
735
+ });
736
+
737
+ const cleanup = createDragHandler({
738
+ element: handleEl,
739
+ svg: svg as unknown as SVGSVGElement,
740
+ onMove: (dx, dy) => {
741
+ handleEl.setAttribute('cx', String(origCx + dx));
742
+ handleEl.setAttribute('cy', String(origCy + dy));
743
+
744
+ if (connectorLine) {
745
+ if (ep.name === 'from') {
746
+ connectorLine.setAttribute('x1', String(origCx + dx));
747
+ connectorLine.setAttribute('y1', String(origCy + dy));
748
+ } else {
749
+ connectorLine.setAttribute('x2', String(origCx + dx));
750
+ connectorLine.setAttribute('y2', String(origCy + dy));
751
+ }
752
+ }
753
+ },
754
+ onEnd: (dx, dy, moved) => {
755
+ handleEl.setAttribute('cx', String(origCx));
756
+ handleEl.setAttribute('cy', String(origCy));
757
+
758
+ if (connectorLine) {
759
+ if (ep.name === 'from') {
760
+ connectorLine.setAttribute('x1', String(origCx));
761
+ connectorLine.setAttribute('y1', String(origCy));
762
+ } else {
763
+ connectorLine.setAttribute('x2', String(origCx));
764
+ connectorLine.setAttribute('y2', String(origCy));
765
+ }
766
+ }
767
+
768
+ if (moved) {
769
+ const existingOffset = textAnnotation.connectorOffset?.[ep.name];
770
+ const origEndDx = existingOffset?.dx ?? 0;
771
+ const origEndDy = existingOffset?.dy ?? 0;
772
+ onEdit({
773
+ type: 'annotation-connector',
774
+ annotation: textAnnotation,
775
+ endpoint: ep.name,
776
+ offset: { dx: origEndDx + dx, dy: origEndDy + dy },
777
+ });
778
+ }
779
+ },
780
+ setDragging,
781
+ });
782
+
783
+ cleanups.push(cleanup);
784
+ }
785
+
786
+ // Wire hover to show/hide handles
787
+ const showHandles = () => {
788
+ for (const h of createdHandles) {
789
+ h.setAttribute('opacity', '0.6');
790
+ }
791
+ };
792
+ const hideHandles = () => {
793
+ for (const h of createdHandles) {
794
+ h.setAttribute('opacity', '0');
795
+ }
796
+ };
797
+
798
+ annotationG.addEventListener('mouseenter', showHandles);
799
+ annotationG.addEventListener('mouseleave', hideHandles);
800
+ cleanups.push(() => {
801
+ annotationG.removeEventListener('mouseenter', showHandles);
802
+ annotationG.removeEventListener('mouseleave', hideHandles);
803
+ // Remove dynamically created handles
804
+ for (const h of createdHandles) {
805
+ h.remove();
806
+ }
807
+ });
808
+ }
809
+
810
+ return () => {
811
+ for (const cleanup of cleanups) {
812
+ cleanup();
813
+ }
814
+ };
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // Range/refline annotation label drag
819
+ // ---------------------------------------------------------------------------
820
+
821
+ /**
822
+ * Wire drag on range and refline annotation labels.
823
+ * On drag end, fires onEdit with the label offset.
824
+ * Returns a cleanup function.
825
+ */
826
+ function wireAnnotationLabelDrag(
827
+ svg: SVGElement,
828
+ specAnnotations: Annotation[],
829
+ onEdit: (edit: ElementEdit) => void,
830
+ setDragging: (dragging: boolean) => void,
831
+ ): () => void {
832
+ const cleanups: Array<() => void> = [];
833
+
834
+ // Target range and refline annotation labels
835
+ const selectors = [
836
+ '.viz-annotation-range .viz-annotation-label',
837
+ '.viz-annotation-refline .viz-annotation-label',
838
+ ];
839
+
840
+ for (const selector of selectors) {
841
+ const labels = svg.querySelectorAll(selector);
842
+
843
+ for (const label of labels) {
844
+ const annotationG = label.closest('.viz-annotation') as SVGGElement | null;
845
+ if (!annotationG) continue;
846
+
847
+ const indexStr = annotationG.getAttribute('data-annotation-index');
848
+ if (indexStr === null) continue;
849
+
850
+ const index = Number(indexStr);
851
+ const specAnnotation = specAnnotations[index];
852
+ if (!specAnnotation) continue;
853
+
854
+ const labelEl = label as SVGTextElement;
855
+ labelEl.style.cursor = 'grab';
856
+
857
+ const isRange = specAnnotation.type === 'range';
858
+ const existingLabelOffset = isRange
859
+ ? (specAnnotation as RangeAnnotation).labelOffset
860
+ : (specAnnotation as RefLineAnnotation).labelOffset;
861
+ const origLabelDx = existingLabelOffset?.dx ?? 0;
862
+ const origLabelDy = existingLabelOffset?.dy ?? 0;
863
+
864
+ const cleanup = createDragHandler({
865
+ element: labelEl,
866
+ svg: svg as unknown as SVGSVGElement,
867
+ onMove: (dx, dy) => {
868
+ (labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
869
+ `translate(${dx}px, ${dy}px)`;
870
+ },
871
+ onEnd: (dx, dy, moved) => {
872
+ (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
873
+
874
+ if (moved) {
875
+ if (isRange) {
876
+ onEdit({
877
+ type: 'range-label',
878
+ annotation: specAnnotation as RangeAnnotation,
879
+ labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
880
+ });
881
+ } else {
882
+ onEdit({
883
+ type: 'refline-label',
884
+ annotation: specAnnotation as RefLineAnnotation,
885
+ labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
886
+ });
887
+ }
888
+ }
889
+ },
890
+ setDragging,
891
+ });
892
+
893
+ cleanups.push(cleanup);
894
+ }
895
+ }
896
+
897
+ return () => {
898
+ for (const cleanup of cleanups) {
899
+ cleanup();
900
+ }
901
+ };
902
+ }
903
+
904
+ // ---------------------------------------------------------------------------
905
+ // Chrome text drag
906
+ // ---------------------------------------------------------------------------
907
+
908
+ /**
909
+ * Wire drag on chrome text elements (title, subtitle, source, byline, footer).
910
+ * On drag end, fires onEdit with the chrome key, text, and offset.
911
+ * Returns a cleanup function.
912
+ */
913
+ function wireChromeDrag(
914
+ svg: SVGElement,
915
+ spec: ChartSpec | GraphSpec,
916
+ onEdit: (edit: ElementEdit) => void,
917
+ setDragging: (dragging: boolean) => void,
918
+ ): () => void {
919
+ const chromeTexts = svg.querySelectorAll('.viz-chrome text[data-chrome-key]');
920
+ const cleanups: Array<() => void> = [];
921
+
922
+ // Read existing chrome offsets from the spec
923
+ const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
924
+
925
+ for (const el of chromeTexts) {
926
+ const textEl = el as SVGTextElement;
927
+ const key = textEl.getAttribute('data-chrome-key') as ChromeKey;
928
+ if (!key) continue;
929
+
930
+ // Read existing offset for this chrome element
931
+ const chromeEntry = chromeConfig?.[key];
932
+ const existingOffset =
933
+ typeof chromeEntry === 'object' && chromeEntry !== null ? chromeEntry.offset : undefined;
934
+ const origChromeDx = existingOffset?.dx ?? 0;
935
+ const origChromeDy = existingOffset?.dy ?? 0;
936
+
937
+ textEl.style.cursor = 'grab';
938
+
939
+ const cleanup = createDragHandler({
940
+ element: textEl,
941
+ svg: svg as unknown as SVGSVGElement,
942
+ onMove: (dx, dy) => {
943
+ (textEl as SVGElement & ElementCSSInlineStyle).style.transform =
944
+ `translate(${dx}px, ${dy}px)`;
945
+ },
946
+ onEnd: (dx, dy, moved) => {
947
+ (textEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
948
+
949
+ if (moved) {
950
+ onEdit({
951
+ type: 'chrome',
952
+ key,
953
+ text: textEl.textContent ?? '',
954
+ offset: { dx: origChromeDx + dx, dy: origChromeDy + dy },
955
+ });
956
+ }
957
+ },
958
+ setDragging,
959
+ });
960
+
961
+ cleanups.push(cleanup);
962
+ }
963
+
964
+ return () => {
965
+ for (const cleanup of cleanups) {
966
+ cleanup();
967
+ }
968
+ };
969
+ }
970
+
971
+ // ---------------------------------------------------------------------------
972
+ // Legend drag
973
+ // ---------------------------------------------------------------------------
974
+
975
+ /**
976
+ * Wire drag on the legend group.
977
+ * Click suppression prevents legend toggle from firing after a drag.
978
+ * On drag end, fires onEdit with the legend offset.
979
+ * Returns a cleanup function.
980
+ */
981
+ function wireLegendDrag(
982
+ svg: SVGElement,
983
+ spec: ChartSpec | GraphSpec,
984
+ onEdit: (edit: ElementEdit) => void,
985
+ setDragging: (dragging: boolean) => void,
986
+ ): () => void {
987
+ const legendG = svg.querySelector('.viz-legend') as SVGGElement | null;
988
+ if (!legendG) return () => {};
989
+
990
+ const cleanups: Array<() => void> = [];
991
+
992
+ // Read existing legend offset from the spec
993
+ const legendConfig = 'legend' in spec ? spec.legend : undefined;
994
+ const origLegendDx = legendConfig?.offset?.dx ?? 0;
995
+ const origLegendDy = legendConfig?.offset?.dy ?? 0;
996
+
997
+ // Set grab cursor on the legend background, not on entry elements
998
+ legendG.style.cursor = 'grab';
999
+
1000
+ const cleanup = createDragHandler({
1001
+ element: legendG,
1002
+ svg: svg as unknown as SVGSVGElement,
1003
+ onMove: (dx, dy) => {
1004
+ (legendG as SVGElement & ElementCSSInlineStyle).style.transform =
1005
+ `translate(${dx}px, ${dy}px)`;
1006
+ },
1007
+ onEnd: (dx, dy, moved) => {
1008
+ (legendG as SVGElement & ElementCSSInlineStyle).style.transform = '';
1009
+
1010
+ if (moved) {
1011
+ onEdit({ type: 'legend', offset: { dx: origLegendDx + dx, dy: origLegendDy + dy } });
1012
+ }
1013
+ },
1014
+ setDragging,
1015
+ });
1016
+
1017
+ cleanups.push(cleanup);
1018
+
1019
+ return () => {
1020
+ for (const cleanup of cleanups) {
1021
+ cleanup();
1022
+ }
1023
+ };
1024
+ }
1025
+
1026
+ // ---------------------------------------------------------------------------
1027
+ // Series label drag
1028
+ // ---------------------------------------------------------------------------
1029
+
1030
+ /**
1031
+ * Wire drag on series label elements (.viz-mark-label[data-series]).
1032
+ * On drag end, fires onEdit with the series name and offset.
1033
+ * Returns a cleanup function.
1034
+ */
1035
+ function wireSeriesLabelDrag(
1036
+ svg: SVGElement,
1037
+ spec: ChartSpec | GraphSpec,
1038
+ onEdit: (edit: ElementEdit) => void,
1039
+ setDragging: (dragging: boolean) => void,
1040
+ ): () => void {
1041
+ const labels = svg.querySelectorAll('.viz-mark-label');
1042
+ const cleanups: Array<() => void> = [];
1043
+
1044
+ // Read existing label offsets from the spec
1045
+ const labelsConfig = 'labels' in spec ? spec.labels : undefined;
1046
+
1047
+ for (const label of labels) {
1048
+ const labelEl = label as SVGTextElement;
1049
+ // Check label itself first, then fall back to the parent mark group's data-series
1050
+ const series =
1051
+ labelEl.getAttribute('data-series') ??
1052
+ labelEl.closest('[data-series]')?.getAttribute('data-series');
1053
+ if (!series) continue;
1054
+
1055
+ // Read existing offset for this series label
1056
+ const existingSeriesOffset = labelsConfig?.offsets?.[series];
1057
+ const origSeriesDx = existingSeriesOffset?.dx ?? 0;
1058
+ const origSeriesDy = existingSeriesOffset?.dy ?? 0;
1059
+
1060
+ labelEl.style.cursor = 'grab';
1061
+
1062
+ const cleanup = createDragHandler({
1063
+ element: labelEl,
1064
+ svg: svg as unknown as SVGSVGElement,
1065
+ onMove: (dx, dy) => {
1066
+ (labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
1067
+ `translate(${dx}px, ${dy}px)`;
1068
+ },
1069
+ onEnd: (dx, dy, moved) => {
1070
+ (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
1071
+
1072
+ if (moved) {
1073
+ onEdit({
1074
+ type: 'series-label',
1075
+ series,
1076
+ offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy },
1077
+ });
1078
+ }
1079
+ },
1080
+ setDragging,
1081
+ });
1082
+
1083
+ cleanups.push(cleanup);
1084
+ }
1085
+
1086
+ return () => {
1087
+ for (const cleanup of cleanups) {
1088
+ cleanup();
1089
+ }
1090
+ };
1091
+ }
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // Legend interactivity
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ /**
1098
+ * Wire click handlers on legend entries to toggle series visibility.
1099
+ * Optionally calls onLegendToggle when a series is toggled.
1100
+ * Returns a cleanup function.
1101
+ */
1102
+ function wireLegendInteraction(
1103
+ svg: SVGElement,
1104
+ _layout: ChartLayout,
1105
+ onLegendToggle?: (series: string, visible: boolean) => void,
1106
+ ): () => void {
1107
+ const legendEntries = svg.querySelectorAll('[data-legend-index]');
1108
+ const cleanups: Array<() => void> = [];
1109
+
1110
+ // Track which series are hidden
1111
+ const hiddenSeries = new Set<string>();
1112
+
1113
+ for (const entry of legendEntries) {
1114
+ const handleClick = () => {
1115
+ const label = entry.getAttribute('data-legend-label');
1116
+ if (!label) return;
1117
+
1118
+ if (hiddenSeries.has(label)) {
1119
+ hiddenSeries.delete(label);
1120
+ entry.setAttribute('opacity', '1');
1121
+ entry.setAttribute('aria-label', `${label}: visible`);
1122
+ onLegendToggle?.(label, true);
1123
+ } else {
1124
+ hiddenSeries.add(label);
1125
+ entry.setAttribute('opacity', '0.3');
1126
+ entry.setAttribute('aria-label', `${label}: hidden`);
1127
+ onLegendToggle?.(label, false);
1128
+ }
1129
+
1130
+ // Toggle visibility of marks with matching series.
1131
+ // Uses the data-series attribute set by the SVG renderer, which works
1132
+ // for all mark types (line, area, rect, arc, point).
1133
+ const marks = svg.querySelectorAll('.viz-mark');
1134
+ for (const mark of marks) {
1135
+ const seriesName = mark.getAttribute('data-series');
1136
+ if (!seriesName) continue;
1137
+
1138
+ if (hiddenSeries.has(seriesName)) {
1139
+ (mark as SVGElement).style.display = 'none';
1140
+ } else {
1141
+ (mark as SVGElement).style.display = '';
1142
+ }
1143
+ }
1144
+ };
1145
+
1146
+ entry.addEventListener('click', handleClick);
1147
+ cleanups.push(() => entry.removeEventListener('click', handleClick));
1148
+ }
1149
+
1150
+ return () => {
1151
+ for (const cleanup of cleanups) {
1152
+ cleanup();
1153
+ }
1154
+ };
1155
+ }
1156
+
1157
+ // ---------------------------------------------------------------------------
1158
+ // Keyboard navigation
1159
+ // ---------------------------------------------------------------------------
1160
+
1161
+ /**
1162
+ * Wire keyboard navigation on the SVG element.
1163
+ * Arrow keys move focus between mark elements. Enter/Space shows tooltip.
1164
+ * Escape hides tooltip. Returns a cleanup function.
1165
+ */
1166
+ function wireKeyboardNav(
1167
+ svg: SVGElement,
1168
+ container: HTMLElement,
1169
+ tooltipDescriptors: Map<string, TooltipContent>,
1170
+ tooltipManager: TooltipManager,
1171
+ layout: ChartLayout,
1172
+ ): () => void {
1173
+ // Make container focusable
1174
+ container.setAttribute('tabindex', '0');
1175
+ container.setAttribute('aria-roledescription', 'chart');
1176
+ container.setAttribute('aria-label', layout.a11y.altText);
1177
+
1178
+ // Collect navigable mark elements (those with tooltip content)
1179
+ const markElements: SVGElement[] = [];
1180
+ const allMarkEls = svg.querySelectorAll('[data-mark-id]');
1181
+ for (const el of allMarkEls) {
1182
+ const markId = el.getAttribute('data-mark-id');
1183
+ if (markId && tooltipDescriptors.has(markId)) {
1184
+ markElements.push(el as SVGElement);
1185
+ }
1186
+ }
1187
+
1188
+ let focusIndex = -1;
1189
+
1190
+ function highlightMark(index: number): void {
1191
+ // Remove previous highlight
1192
+ if (focusIndex >= 0 && focusIndex < markElements.length) {
1193
+ markElements[focusIndex].classList.remove('viz-mark-focused');
1194
+ markElements[focusIndex].removeAttribute('aria-selected');
1195
+ }
1196
+
1197
+ focusIndex = index;
1198
+
1199
+ if (focusIndex >= 0 && focusIndex < markElements.length) {
1200
+ const el = markElements[focusIndex];
1201
+ el.classList.add('viz-mark-focused');
1202
+ el.setAttribute('aria-selected', 'true');
1203
+ }
1204
+ }
1205
+
1206
+ function showTooltipForFocused(): void {
1207
+ if (focusIndex < 0 || focusIndex >= markElements.length) return;
1208
+
1209
+ const el = markElements[focusIndex];
1210
+ const markId = el.getAttribute('data-mark-id');
1211
+ if (!markId) return;
1212
+
1213
+ const content = tooltipDescriptors.get(markId);
1214
+ if (!content) return;
1215
+
1216
+ // Position tooltip near the mark element
1217
+ const bbox = el.getBoundingClientRect();
1218
+ const containerRect = container.getBoundingClientRect();
1219
+ const x = bbox.left + bbox.width / 2 - containerRect.left;
1220
+ const y = bbox.top - containerRect.top;
1221
+ tooltipManager.show(content, x, y);
1222
+ }
1223
+
1224
+ const handleKeyDown = (e: KeyboardEvent) => {
1225
+ if (markElements.length === 0) return;
1226
+
1227
+ switch (e.key) {
1228
+ case 'ArrowRight':
1229
+ case 'ArrowDown': {
1230
+ e.preventDefault();
1231
+ const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
1232
+ highlightMark(next);
1233
+ showTooltipForFocused();
1234
+ break;
1235
+ }
1236
+ case 'ArrowLeft':
1237
+ case 'ArrowUp': {
1238
+ e.preventDefault();
1239
+ const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
1240
+ highlightMark(prev);
1241
+ showTooltipForFocused();
1242
+ break;
1243
+ }
1244
+ case 'Enter':
1245
+ case ' ': {
1246
+ e.preventDefault();
1247
+ if (focusIndex >= 0) {
1248
+ showTooltipForFocused();
1249
+ } else if (markElements.length > 0) {
1250
+ highlightMark(0);
1251
+ showTooltipForFocused();
1252
+ }
1253
+ break;
1254
+ }
1255
+ case 'Escape': {
1256
+ e.preventDefault();
1257
+ tooltipManager.hide();
1258
+ highlightMark(-1);
1259
+ break;
1260
+ }
1261
+ }
1262
+ };
1263
+
1264
+ container.addEventListener('keydown', handleKeyDown);
1265
+
1266
+ return () => {
1267
+ container.removeEventListener('keydown', handleKeyDown);
1268
+ container.removeAttribute('tabindex');
1269
+ container.removeAttribute('aria-roledescription');
1270
+ container.removeAttribute('aria-label');
1271
+ };
1272
+ }
1273
+
1274
+ // ---------------------------------------------------------------------------
1275
+ // Hidden data table for screen readers
1276
+ // ---------------------------------------------------------------------------
1277
+
1278
+ /**
1279
+ * Create a visually-hidden data table from the chart's a11y fallback data.
1280
+ * Returns the table element (append to container) and a cleanup function.
1281
+ */
1282
+ function createScreenReaderTable(
1283
+ layout: ChartLayout,
1284
+ container: HTMLElement,
1285
+ ): HTMLTableElement | null {
1286
+ const data = layout.a11y.dataTableFallback;
1287
+ if (!data || data.length === 0) return null;
1288
+
1289
+ const table = document.createElement('table');
1290
+ table.className = 'viz-sr-only';
1291
+ table.setAttribute('role', 'table');
1292
+ table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
1293
+
1294
+ // First row is headers
1295
+ if (data.length > 0) {
1296
+ const thead = document.createElement('thead');
1297
+ const headerRow = document.createElement('tr');
1298
+ const headers = data[0] as unknown[];
1299
+ for (const header of headers) {
1300
+ const th = document.createElement('th');
1301
+ th.textContent = String(header ?? '');
1302
+ th.setAttribute('scope', 'col');
1303
+ headerRow.appendChild(th);
1304
+ }
1305
+ thead.appendChild(headerRow);
1306
+ table.appendChild(thead);
1307
+ }
1308
+
1309
+ // Remaining rows are data
1310
+ if (data.length > 1) {
1311
+ const tbody = document.createElement('tbody');
1312
+ for (let i = 1; i < data.length; i++) {
1313
+ const tr = document.createElement('tr');
1314
+ const cells = data[i] as unknown[];
1315
+ for (const cell of cells) {
1316
+ const td = document.createElement('td');
1317
+ td.textContent = String(cell ?? '');
1318
+ tr.appendChild(td);
1319
+ }
1320
+ tbody.appendChild(tr);
1321
+ }
1322
+ table.appendChild(tbody);
1323
+ }
1324
+
1325
+ container.appendChild(table);
1326
+ return table;
1327
+ }
1328
+
1329
+ // ---------------------------------------------------------------------------
1330
+ // Main API
1331
+ // ---------------------------------------------------------------------------
1332
+
1333
+ /**
1334
+ * Create a chart instance from a spec and mount it into a container.
1335
+ *
1336
+ * @param container - The DOM element to render into.
1337
+ * @param spec - The visualization spec.
1338
+ * @param options - Mount options (theme, darkMode, responsive, etc.).
1339
+ * @returns A ChartInstance with update/resize/export/destroy methods.
1340
+ */
1341
+ export function createChart(
1342
+ container: HTMLElement,
1343
+ spec: ChartSpec | GraphSpec,
1344
+ options?: MountOptions,
1345
+ ): ChartInstance {
1346
+ let currentSpec: ChartSpec | GraphSpec = spec;
1347
+ let currentLayout: ChartLayout;
1348
+ let svgElement: SVGElement | null = null;
1349
+ let tooltipManager: TooltipManager | null = null;
1350
+ let disconnectResize: (() => void) | null = null;
1351
+ let cleanupTooltipEvents: (() => void) | null = null;
1352
+ let cleanupKeyboardNav: (() => void) | null = null;
1353
+ let cleanupLegend: (() => void) | null = null;
1354
+ let cleanupChartEvents: (() => void) | null = null;
1355
+ let cleanupAnnotationDrag: (() => void) | null = null;
1356
+ let cleanupEditDrags: (() => void) | null = null;
1357
+ let srTable: HTMLTableElement | null = null;
1358
+ let destroyed = false;
1359
+ let isDragging = false;
1360
+ let pendingRender = false;
1361
+
1362
+ const measureText = createMeasureText();
1363
+
1364
+ function compile(): ChartLayout {
1365
+ const { width, height } = getContainerDimensions();
1366
+ const darkMode = resolveDarkMode(options?.darkMode);
1367
+
1368
+ const compileOpts: CompileOptions = {
1369
+ width,
1370
+ height,
1371
+ theme: options?.theme,
1372
+ darkMode,
1373
+ measureText,
1374
+ };
1375
+
1376
+ return compileChart(currentSpec, compileOpts);
1377
+ }
1378
+
1379
+ function getContainerDimensions(): { width: number; height: number } {
1380
+ const rect = container.getBoundingClientRect();
1381
+ return {
1382
+ width: Math.max(rect.width || 600, 100),
1383
+ height: Math.max(rect.height || 400, 100),
1384
+ };
1385
+ }
1386
+
1387
+ function render(): void {
1388
+ // Defer re-render if a drag is in progress to avoid destroying the dragged element
1389
+ if (isDragging) {
1390
+ pendingRender = true;
1391
+ return;
1392
+ }
1393
+
1394
+ // Clean up previous render
1395
+ if (cleanupTooltipEvents) {
1396
+ cleanupTooltipEvents();
1397
+ cleanupTooltipEvents = null;
1398
+ }
1399
+ if (cleanupKeyboardNav) {
1400
+ cleanupKeyboardNav();
1401
+ cleanupKeyboardNav = null;
1402
+ }
1403
+ if (cleanupLegend) {
1404
+ cleanupLegend();
1405
+ cleanupLegend = null;
1406
+ }
1407
+ if (cleanupChartEvents) {
1408
+ cleanupChartEvents();
1409
+ cleanupChartEvents = null;
1410
+ }
1411
+ if (cleanupAnnotationDrag) {
1412
+ cleanupAnnotationDrag();
1413
+ cleanupAnnotationDrag = null;
1414
+ }
1415
+ if (cleanupEditDrags) {
1416
+ cleanupEditDrags();
1417
+ cleanupEditDrags = null;
1418
+ }
1419
+ if (svgElement?.parentNode) {
1420
+ svgElement.parentNode.removeChild(svgElement);
1421
+ }
1422
+ if (tooltipManager) {
1423
+ tooltipManager.destroy();
1424
+ }
1425
+ if (srTable?.parentNode) {
1426
+ srTable.parentNode.removeChild(srTable);
1427
+ srTable = null;
1428
+ }
1429
+
1430
+ currentLayout = compile();
1431
+ svgElement = renderChartSVG(currentLayout, container);
1432
+ tooltipManager = createTooltipManager(container);
1433
+
1434
+ // Wire tooltip events on mark elements
1435
+ cleanupTooltipEvents = wireTooltipEvents(
1436
+ svgElement,
1437
+ currentLayout.tooltipDescriptors,
1438
+ tooltipManager,
1439
+ );
1440
+
1441
+ // Wire keyboard navigation
1442
+ cleanupKeyboardNav = wireKeyboardNav(
1443
+ svgElement,
1444
+ container,
1445
+ currentLayout.tooltipDescriptors,
1446
+ tooltipManager,
1447
+ currentLayout,
1448
+ );
1449
+
1450
+ // Wire legend interactivity
1451
+ cleanupLegend = wireLegendInteraction(svgElement, currentLayout, options?.onLegendToggle);
1452
+
1453
+ // Wire chart event handlers (mark click/hover/leave, annotation click)
1454
+ if (
1455
+ options?.onMarkClick ||
1456
+ options?.onMarkHover ||
1457
+ options?.onMarkLeave ||
1458
+ options?.onAnnotationClick
1459
+ ) {
1460
+ const specAnnotations: import('@opendata-ai/openchart-core').Annotation[] =
1461
+ 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
1462
+ ? currentSpec.annotations
1463
+ : [];
1464
+ cleanupChartEvents = wireChartEvents(svgElement, currentLayout, specAnnotations, options);
1465
+ }
1466
+
1467
+ // Shared setDragging callback for all drag handlers
1468
+ const setDragging = (dragging: boolean) => {
1469
+ isDragging = dragging;
1470
+ if (!dragging && pendingRender) {
1471
+ pendingRender = false;
1472
+ render();
1473
+ }
1474
+ };
1475
+
1476
+ // Shared annotation list for drag handlers (computed once)
1477
+ const dragAnnotations: Annotation[] =
1478
+ 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
1479
+ ? currentSpec.annotations
1480
+ : [];
1481
+
1482
+ // Wire annotation drag editing (activates when onAnnotationEdit or onEdit is provided)
1483
+ if (options?.onAnnotationEdit || options?.onEdit) {
1484
+ cleanupAnnotationDrag = wireAnnotationDrag(
1485
+ svgElement,
1486
+ dragAnnotations,
1487
+ options?.onAnnotationEdit,
1488
+ options?.onEdit,
1489
+ setDragging,
1490
+ );
1491
+ }
1492
+
1493
+ // Wire all edit drag handlers when onEdit is provided
1494
+ if (options?.onEdit) {
1495
+ const editCleanups: Array<() => void> = [];
1496
+
1497
+ // Connector endpoint drag
1498
+ editCleanups.push(
1499
+ wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
1500
+ );
1501
+
1502
+ // Range/refline annotation label drag
1503
+ editCleanups.push(
1504
+ wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
1505
+ );
1506
+
1507
+ // Chrome text drag
1508
+ editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
1509
+
1510
+ // Legend drag
1511
+ editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
1512
+
1513
+ // Series label drag
1514
+ editCleanups.push(wireSeriesLabelDrag(svgElement, currentSpec, options.onEdit, setDragging));
1515
+
1516
+ cleanupEditDrags = () => {
1517
+ for (const cleanup of editCleanups) {
1518
+ cleanup();
1519
+ }
1520
+ };
1521
+ }
1522
+
1523
+ // Create hidden data table for screen readers
1524
+ srTable = createScreenReaderTable(currentLayout, container);
1525
+
1526
+ // Apply container classes for CSS variable scoping and dark mode
1527
+ container.classList.add('viz-root');
1528
+ const isDark = resolveDarkMode(options?.darkMode);
1529
+ if (isDark) {
1530
+ container.classList.add('viz-dark');
1531
+ } else {
1532
+ container.classList.remove('viz-dark');
1533
+ }
1534
+ }
1535
+
1536
+ function update(newSpec: ChartSpec | GraphSpec): void {
1537
+ if (destroyed) return;
1538
+ currentSpec = newSpec;
1539
+ render();
1540
+ }
1541
+
1542
+ function resize(): void {
1543
+ if (destroyed) return;
1544
+ render();
1545
+ }
1546
+
1547
+ function doExport(format: 'svg'): string;
1548
+ function doExport(format: 'png', exportOptions?: ExportOptions): Promise<Blob>;
1549
+ function doExport(format: 'csv'): string;
1550
+ function doExport(
1551
+ format: 'svg' | 'png' | 'csv',
1552
+ exportOptions?: ExportOptions,
1553
+ ): string | Promise<Blob> {
1554
+ if (!svgElement) {
1555
+ throw new Error('Chart is not rendered yet');
1556
+ }
1557
+
1558
+ switch (format) {
1559
+ case 'svg':
1560
+ return exportSVG(svgElement);
1561
+ case 'png':
1562
+ return exportPNG(svgElement, exportOptions);
1563
+ case 'csv':
1564
+ return exportCSV(
1565
+ 'data' in currentSpec && Array.isArray(currentSpec.data) ? currentSpec.data : [],
1566
+ );
1567
+ default:
1568
+ throw new Error(`Unsupported export format: ${format}`);
1569
+ }
1570
+ }
1571
+
1572
+ function destroy(): void {
1573
+ if (destroyed) return;
1574
+ destroyed = true;
1575
+
1576
+ if (cleanupTooltipEvents) {
1577
+ cleanupTooltipEvents();
1578
+ cleanupTooltipEvents = null;
1579
+ }
1580
+ if (cleanupKeyboardNav) {
1581
+ cleanupKeyboardNav();
1582
+ cleanupKeyboardNav = null;
1583
+ }
1584
+ if (cleanupLegend) {
1585
+ cleanupLegend();
1586
+ cleanupLegend = null;
1587
+ }
1588
+ if (cleanupChartEvents) {
1589
+ cleanupChartEvents();
1590
+ cleanupChartEvents = null;
1591
+ }
1592
+ if (cleanupAnnotationDrag) {
1593
+ cleanupAnnotationDrag();
1594
+ cleanupAnnotationDrag = null;
1595
+ }
1596
+ if (cleanupEditDrags) {
1597
+ cleanupEditDrags();
1598
+ cleanupEditDrags = null;
1599
+ }
1600
+ if (disconnectResize) {
1601
+ disconnectResize();
1602
+ disconnectResize = null;
1603
+ }
1604
+ if (tooltipManager) {
1605
+ tooltipManager.destroy();
1606
+ tooltipManager = null;
1607
+ }
1608
+ if (svgElement?.parentNode) {
1609
+ svgElement.parentNode.removeChild(svgElement);
1610
+ svgElement = null;
1611
+ }
1612
+ if (srTable?.parentNode) {
1613
+ srTable.parentNode.removeChild(srTable);
1614
+ srTable = null;
1615
+ }
1616
+ container.classList.remove('viz-dark');
1617
+ container.classList.remove('viz-root');
1618
+ }
1619
+
1620
+ // Initial render
1621
+ render();
1622
+
1623
+ // Set up responsive resize
1624
+ if (options?.responsive !== false) {
1625
+ disconnectResize = observeResize(container, () => {
1626
+ resize();
1627
+ });
1628
+ }
1629
+
1630
+ return {
1631
+ update,
1632
+ resize,
1633
+ export: doExport,
1634
+ destroy,
1635
+ get layout() {
1636
+ return currentLayout;
1637
+ },
1638
+ };
1639
+ }