@opendata-ai/openchart-vanilla 6.28.6 → 7.0.2

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/src/mount.ts CHANGED
@@ -9,24 +9,18 @@
9
9
 
10
10
  import type {
11
11
  Annotation,
12
- AnnotationOffset,
13
12
  ChartEventHandlers,
14
13
  ChartLayout,
15
14
  ChartSpec,
16
- ChromeKey,
17
15
  CompileOptions,
18
16
  DarkMode,
19
- ElementEdit,
17
+ DataRow,
20
18
  ElementRef,
21
19
  GraphSpec,
22
20
  LayerSpec,
23
- RangeAnnotation,
24
- RefLineAnnotation,
25
- TextAnnotation,
26
21
  ThemeConfig,
27
- TooltipContent,
28
22
  } from '@opendata-ai/openchart-core';
29
- import { elementRef, getRepresentativeColor, isLayerSpec } from '@opendata-ai/openchart-core';
23
+ import { isLayerSpec } from '@opendata-ai/openchart-core';
30
24
  import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
31
25
  import { cancelAnimations, setupAnimationCleanup } from './animation';
32
26
  import {
@@ -38,6 +32,27 @@ import {
38
32
  type JPGExportOptions,
39
33
  type SVGExportOptions,
40
34
  } from './export';
35
+ import {
36
+ buildElementRef,
37
+ createScreenReaderTable,
38
+ findElementByRef,
39
+ getEditableElements,
40
+ getElementText,
41
+ isTextEditable,
42
+ refsEqual,
43
+ renderSelectionOverlay,
44
+ wireAnnotationDrag,
45
+ wireAnnotationLabelDrag,
46
+ wireChartEvents,
47
+ wireChromeDrag,
48
+ wireConnectorEndpointDrag,
49
+ wireKeyboardNav,
50
+ wireLegendDrag,
51
+ wireLegendInteraction,
52
+ wireSeriesLabelDrag,
53
+ wireTooltipEvents,
54
+ wireVoronoiTooltipEvents,
55
+ } from './interactions';
41
56
  import { createMeasureText } from './measure-text';
42
57
  import { observeResize } from './resize-observer';
43
58
  import { renderChartSVG } from './svg-renderer';
@@ -108,1405 +123,16 @@ export interface ChartInstance {
108
123
  function resolveDarkMode(mode?: DarkMode): boolean {
109
124
  if (mode === 'force') return true;
110
125
  if (mode === 'off' || mode === undefined) return false;
111
- // "auto": check system preference
112
126
  if (typeof window !== 'undefined' && window.matchMedia) {
113
127
  return window.matchMedia('(prefers-color-scheme: dark)').matches;
114
128
  }
115
129
  return false;
116
130
  }
117
131
 
118
- // ---------------------------------------------------------------------------
119
- // Tooltip event wiring
120
- // ---------------------------------------------------------------------------
121
-
122
- /**
123
- * Wire tooltip events on mark elements inside an SVG.
124
- * Returns a cleanup function to remove all listeners.
125
- */
126
- function wireTooltipEvents(
127
- svg: SVGElement,
128
- tooltipDescriptors: Map<string, TooltipContent>,
129
- tooltipManager: TooltipManager,
130
- ): () => void {
131
- const markElements = svg.querySelectorAll('[data-mark-id]');
132
- const cleanups: Array<() => void> = [];
133
-
134
- for (const el of markElements) {
135
- const markId = el.getAttribute('data-mark-id');
136
- if (!markId) continue;
137
-
138
- const content = tooltipDescriptors.get(markId);
139
- if (!content) continue;
140
-
141
- // Mouse enter -> show tooltip
142
- const handleMouseEnter = (e: Event) => {
143
- const mouseEvent = e as MouseEvent;
144
- const svgRect = svg.getBoundingClientRect();
145
- const x = mouseEvent.clientX - svgRect.left;
146
- const y = mouseEvent.clientY - svgRect.top;
147
- tooltipManager.show(content, x, y);
148
- };
149
-
150
- // Mouse move -> reposition tooltip
151
- const handleMouseMove = (e: Event) => {
152
- const mouseEvent = e as MouseEvent;
153
- const svgRect = svg.getBoundingClientRect();
154
- const x = mouseEvent.clientX - svgRect.left;
155
- const y = mouseEvent.clientY - svgRect.top;
156
- tooltipManager.show(content, x, y);
157
- };
158
-
159
- // Mouse leave -> hide tooltip
160
- const handleMouseLeave = () => {
161
- tooltipManager.hide();
162
- };
163
-
164
- // Touch: tap to show
165
- const handleTouchStart = (e: Event) => {
166
- const touchEvent = e as TouchEvent;
167
- if (touchEvent.touches.length > 0) {
168
- const touch = touchEvent.touches[0];
169
- const svgRect = svg.getBoundingClientRect();
170
- const x = touch.clientX - svgRect.left;
171
- const y = touch.clientY - svgRect.top;
172
- tooltipManager.show(content, x, y);
173
- }
174
- };
175
-
176
- el.addEventListener('mouseenter', handleMouseEnter);
177
- el.addEventListener('mousemove', handleMouseMove);
178
- el.addEventListener('mouseleave', handleMouseLeave);
179
- el.addEventListener('touchstart', handleTouchStart);
180
-
181
- cleanups.push(() => {
182
- el.removeEventListener('mouseenter', handleMouseEnter);
183
- el.removeEventListener('mousemove', handleMouseMove);
184
- el.removeEventListener('mouseleave', handleMouseLeave);
185
- el.removeEventListener('touchstart', handleTouchStart);
186
- });
187
- }
188
-
189
- return () => {
190
- for (const cleanup of cleanups) {
191
- cleanup();
192
- }
193
- };
194
- }
195
-
196
- // ---------------------------------------------------------------------------
197
- // Voronoi overlay tooltip wiring (nearest-point lookup for line/area charts)
198
- // ---------------------------------------------------------------------------
199
-
200
- /** A single data point with pixel coordinates, datum, and pre-computed tooltip. */
201
- interface VoronoiPoint {
202
- x: number;
203
- y: number;
204
- datum: Record<string, unknown>;
205
- tooltip?: TooltipContent;
206
- color: string;
207
- }
208
-
209
- /**
210
- * Collect all dataPoints from line and area marks for nearest-point lookup.
211
- */
212
- function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
213
- const points: VoronoiPoint[] = [];
214
- for (const mark of layout.marks) {
215
- if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
216
- const color = mark.type === 'line' ? mark.stroke : getRepresentativeColor(mark.fill);
217
- for (const dp of mark.dataPoints) {
218
- points.push({ ...dp, color });
219
- }
220
- }
221
- }
222
- return points;
223
- }
224
-
225
- /**
226
- * Find the nearest VoronoiPoint to a given (x, y) position using linear scan.
227
- * Returns null if no points exist.
228
- */
229
- function findNearestPoint(points: VoronoiPoint[], x: number, y: number): VoronoiPoint | null {
230
- if (points.length === 0) return null;
231
-
232
- let nearest = points[0];
233
- let minDist = (points[0].x - x) ** 2 + (points[0].y - y) ** 2;
234
-
235
- for (let i = 1; i < points.length; i++) {
236
- const dist = (points[i].x - x) ** 2 + (points[i].y - y) ** 2;
237
- if (dist < minDist) {
238
- minDist = dist;
239
- nearest = points[i];
240
- }
241
- }
242
-
243
- return nearest;
244
- }
245
-
246
- /**
247
- * Wire voronoi overlay tooltip events for line/area charts.
248
- * Uses a transparent overlay rect with nearest-point lookup instead of
249
- * per-point event listeners, eliminating DOM bloat.
250
- * Returns a cleanup function.
251
- */
252
- function wireVoronoiTooltipEvents(
253
- svg: SVGElement,
254
- layout: ChartLayout,
255
- tooltipManager: TooltipManager,
256
- ): () => void {
257
- const overlay = svg.querySelector('[data-voronoi-overlay]');
258
- if (!overlay) return () => {};
259
-
260
- const voronoiPoints = collectVoronoiPoints(layout);
261
- if (voronoiPoints.length === 0) return () => {};
262
-
263
- const crosshair = svg.querySelector('[data-crosshair]') as SVGLineElement | null;
264
- const cleanups: Array<() => void> = [];
265
-
266
- const handleMouseMove = (e: Event) => {
267
- const mouseEvent = e as MouseEvent;
268
- const svgEl = svg as unknown as SVGSVGElement;
269
- const svgRect = svgEl.getBoundingClientRect();
270
- const viewBox = svgEl.viewBox?.baseVal;
271
-
272
- // Convert client coordinates to SVG viewBox coordinates
273
- const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
274
- const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
275
- const svgX = (mouseEvent.clientX - svgRect.left) * scaleX;
276
- const svgY = (mouseEvent.clientY - svgRect.top) * scaleY;
277
-
278
- const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
279
- if (!nearest?.tooltip) return;
280
-
281
- // Update crosshair position to match the nearest data point's x
282
- if (crosshair) {
283
- crosshair.setAttribute('x1', String(nearest.x));
284
- crosshair.setAttribute('x2', String(nearest.x));
285
- crosshair.style.display = '';
286
- }
287
-
288
- // Show tooltip at the mouse position (relative to container, not SVG viewBox)
289
- const containerX = mouseEvent.clientX - svgRect.left;
290
- const containerY = mouseEvent.clientY - svgRect.top;
291
- tooltipManager.show(nearest.tooltip, containerX, containerY);
292
- };
293
-
294
- const handleMouseLeave = () => {
295
- if (crosshair) crosshair.style.display = 'none';
296
- tooltipManager.hide();
297
- };
298
-
299
- // Touch support
300
- const handleTouchStart = (e: Event) => {
301
- const touchEvent = e as TouchEvent;
302
- if (touchEvent.touches.length > 0) {
303
- const touch = touchEvent.touches[0];
304
- const svgEl = svg as unknown as SVGSVGElement;
305
- const svgRect = svgEl.getBoundingClientRect();
306
- const viewBox = svgEl.viewBox?.baseVal;
307
-
308
- const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
309
- const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
310
- const svgX = (touch.clientX - svgRect.left) * scaleX;
311
- const svgY = (touch.clientY - svgRect.top) * scaleY;
312
-
313
- const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
314
- if (!nearest?.tooltip) return;
315
-
316
- // Update crosshair position on touch
317
- if (crosshair) {
318
- crosshair.setAttribute('x1', String(nearest.x));
319
- crosshair.setAttribute('x2', String(nearest.x));
320
- crosshair.style.display = '';
321
- }
322
-
323
- const containerX = touch.clientX - svgRect.left;
324
- const containerY = touch.clientY - svgRect.top;
325
- tooltipManager.show(nearest.tooltip, containerX, containerY);
326
- }
327
- };
328
-
329
- overlay.addEventListener('mousemove', handleMouseMove);
330
- overlay.addEventListener('mouseleave', handleMouseLeave);
331
- overlay.addEventListener('touchstart', handleTouchStart);
332
-
333
- cleanups.push(() => {
334
- overlay.removeEventListener('mousemove', handleMouseMove);
335
- overlay.removeEventListener('mouseleave', handleMouseLeave);
336
- overlay.removeEventListener('touchstart', handleTouchStart);
337
- });
338
-
339
- return () => {
340
- for (const cleanup of cleanups) {
341
- cleanup();
342
- }
343
- };
344
- }
345
-
346
- // ---------------------------------------------------------------------------
347
- // Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
348
- // ---------------------------------------------------------------------------
349
-
350
- /**
351
- * Build a map from data-mark-id to { datum, series } so event handlers
352
- * can look up the data row associated with a clicked/hovered mark element.
353
- */
354
- function buildMarkDataMap(
355
- layout: ChartLayout,
356
- ): Map<string, { datum: Record<string, unknown>; series?: string }> {
357
- const map = new Map<string, { datum: Record<string, unknown>; series?: string }>();
358
-
359
- for (let i = 0; i < layout.marks.length; i++) {
360
- const mark = layout.marks[i];
361
- switch (mark.type) {
362
- case 'line':
363
- map.set(`line-${mark.seriesKey ?? i}`, {
364
- // For line marks, data is an array. Use the first row as representative.
365
- datum: mark.data[0] ?? {},
366
- series: mark.seriesKey,
367
- });
368
- break;
369
- case 'area':
370
- map.set(`area-${mark.seriesKey ?? i}`, {
371
- datum: mark.data[0] ?? {},
372
- series: mark.seriesKey,
373
- });
374
- break;
375
- case 'rect':
376
- map.set(`rect-${i}`, { datum: mark.data });
377
- break;
378
- case 'arc':
379
- map.set(`arc-${i}`, { datum: mark.data });
380
- break;
381
- case 'point':
382
- map.set(`point-${i}`, { datum: mark.data });
383
- break;
384
- }
385
- }
386
-
387
- return map;
388
- }
389
-
390
- /**
391
- * Wire chart event handlers (onMarkClick, onMarkHover, onMarkLeave) to mark
392
- * elements, onLegendToggle to legend entries, and onAnnotationClick to annotation
393
- * elements inside an SVG.
394
- *
395
- * Returns a cleanup function to remove all listeners.
396
- */
397
- function wireChartEvents(
398
- svg: SVGElement,
399
- layout: ChartLayout,
400
- specAnnotations: import('@opendata-ai/openchart-core').Annotation[],
401
- handlers: ChartEventHandlers,
402
- ): () => void {
403
- const cleanups: Array<() => void> = [];
404
- const markDataMap = buildMarkDataMap(layout);
405
-
406
- // Wire mark click/hover/leave events
407
- if (handlers.onMarkClick || handlers.onMarkHover || handlers.onMarkLeave) {
408
- const markElements = svg.querySelectorAll('[data-mark-id]');
409
-
410
- for (const el of markElements) {
411
- const markId = el.getAttribute('data-mark-id');
412
- if (!markId) continue;
413
-
414
- const markInfo = markDataMap.get(markId);
415
- if (!markInfo) continue;
416
-
417
- const series = markInfo.series ?? el.getAttribute('data-series') ?? undefined;
418
-
419
- if (handlers.onMarkClick) {
420
- const handleClick = (e: Event) => {
421
- const mouseEvent = e as MouseEvent;
422
- const svgRect = svg.getBoundingClientRect();
423
- handlers.onMarkClick!({
424
- datum: markInfo.datum,
425
- series,
426
- position: {
427
- x: mouseEvent.clientX - svgRect.left,
428
- y: mouseEvent.clientY - svgRect.top,
429
- },
430
- event: mouseEvent,
431
- });
432
- };
433
- el.addEventListener('click', handleClick);
434
- cleanups.push(() => el.removeEventListener('click', handleClick));
435
- }
436
-
437
- if (handlers.onMarkHover) {
438
- const handleEnter = (e: Event) => {
439
- const mouseEvent = e as MouseEvent;
440
- const svgRect = svg.getBoundingClientRect();
441
- handlers.onMarkHover!({
442
- datum: markInfo.datum,
443
- series,
444
- position: {
445
- x: mouseEvent.clientX - svgRect.left,
446
- y: mouseEvent.clientY - svgRect.top,
447
- },
448
- event: mouseEvent,
449
- });
450
- };
451
- el.addEventListener('mouseenter', handleEnter);
452
- cleanups.push(() => el.removeEventListener('mouseenter', handleEnter));
453
- }
454
-
455
- if (handlers.onMarkLeave) {
456
- const handleLeave = () => {
457
- handlers.onMarkLeave!();
458
- };
459
- el.addEventListener('mouseleave', handleLeave);
460
- cleanups.push(() => el.removeEventListener('mouseleave', handleLeave));
461
- }
462
- }
463
- }
464
-
465
- // Wire annotation click events
466
- if (handlers.onAnnotationClick) {
467
- const annotationElements = svg.querySelectorAll('.oc-annotation');
468
-
469
- for (let i = 0; i < annotationElements.length; i++) {
470
- const el = annotationElements[i];
471
- const specAnnotation = specAnnotations[i];
472
- if (!specAnnotation) continue;
473
-
474
- const handleClick = (e: Event) => {
475
- const mouseEvent = e as MouseEvent;
476
- handlers.onAnnotationClick!(specAnnotation, mouseEvent);
477
- };
478
-
479
- el.addEventListener('click', handleClick);
480
- cleanups.push(() => el.removeEventListener('click', handleClick));
481
- }
482
- }
483
-
484
- return () => {
485
- for (const cleanup of cleanups) {
486
- cleanup();
487
- }
488
- };
489
- }
490
-
491
- // ---------------------------------------------------------------------------
492
- // Shared drag handler utility
493
- // ---------------------------------------------------------------------------
494
-
495
- interface DragConfig {
496
- element: SVGElement;
497
- svg: SVGSVGElement;
498
- onMove: (dx: number, dy: number) => void;
499
- onEnd: (dx: number, dy: number, moved: boolean) => void;
500
- setDragging: (dragging: boolean) => void;
501
- threshold?: number; // default: 3
502
- }
503
-
504
- /**
505
- * Reusable drag handler for SVG elements.
506
- * Handles mouse and touch events, viewBox scaling, threshold detection,
507
- * click suppression after drag, and cursor state.
508
- *
509
- * Returns a cleanup function that removes all listeners.
510
- */
511
- function createDragHandler(config: DragConfig): () => void {
512
- const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config;
513
- const cleanups: Array<() => void> = [];
514
-
515
- // Track active document listeners so cleanup can remove them mid-drag
516
- let activeDocMouseMove: ((e: MouseEvent) => void) | null = null;
517
- let activeDocMouseUp: ((e: MouseEvent) => void) | null = null;
518
- let activeDocTouchMove: ((e: TouchEvent) => void) | null = null;
519
- let activeDocTouchEnd: ((e: TouchEvent) => void) | null = null;
520
- let activeDocTouchCancel: ((e: TouchEvent) => void) | null = null;
521
-
522
- function getScale(): { scaleX: number; scaleY: number } {
523
- const viewBox = svg.viewBox?.baseVal;
524
- const svgRect = svg.getBoundingClientRect();
525
- return {
526
- scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1,
527
- scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1,
528
- };
529
- }
530
-
531
- function startDrag(startX: number, startY: number): void {
532
- setDragging(true);
533
- const { scaleX, scaleY } = getScale();
534
-
535
- element.style.cursor = 'grabbing';
536
- // Prevent text selection during drag
537
- svg.style.userSelect = 'none';
538
-
539
- const handleMove = (clientX: number, clientY: number) => {
540
- const dx = (clientX - startX) * scaleX;
541
- const dy = (clientY - startY) * scaleY;
542
- onMove(dx, dy);
543
- };
544
-
545
- const cleanupDocListeners = () => {
546
- if (activeDocMouseMove) {
547
- document.removeEventListener('mousemove', activeDocMouseMove);
548
- activeDocMouseMove = null;
549
- }
550
- if (activeDocMouseUp) {
551
- document.removeEventListener('mouseup', activeDocMouseUp);
552
- activeDocMouseUp = null;
553
- }
554
- if (activeDocTouchMove) {
555
- document.removeEventListener('touchmove', activeDocTouchMove);
556
- activeDocTouchMove = null;
557
- }
558
- if (activeDocTouchEnd) {
559
- document.removeEventListener('touchend', activeDocTouchEnd);
560
- activeDocTouchEnd = null;
561
- }
562
- if (activeDocTouchCancel) {
563
- document.removeEventListener('touchcancel', activeDocTouchCancel);
564
- activeDocTouchCancel = null;
565
- }
566
- };
567
-
568
- const handleEnd = (clientX: number, clientY: number) => {
569
- const dx = (clientX - startX) * scaleX;
570
- const dy = (clientY - startY) * scaleY;
571
- const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold;
572
-
573
- onEnd(dx, dy, moved);
574
-
575
- // Suppress click if drag actually moved
576
- if (moved) {
577
- element.addEventListener(
578
- 'click',
579
- (clickE) => {
580
- clickE.stopPropagation();
581
- },
582
- { capture: true, once: true },
583
- );
584
- }
585
-
586
- element.style.cursor = 'grab';
587
- svg.style.userSelect = '';
588
-
589
- cleanupDocListeners();
590
- setDragging(false);
591
- };
592
-
593
- // Mouse listeners
594
- const onMouseMove = (moveEvent: MouseEvent) => {
595
- handleMove(moveEvent.clientX, moveEvent.clientY);
596
- };
597
- const onMouseUp = (upEvent: MouseEvent) => {
598
- handleEnd(upEvent.clientX, upEvent.clientY);
599
- };
600
- document.addEventListener('mousemove', onMouseMove);
601
- document.addEventListener('mouseup', onMouseUp);
602
- activeDocMouseMove = onMouseMove;
603
- activeDocMouseUp = onMouseUp;
604
-
605
- // Touch listeners
606
- const onTouchMove = (moveEvent: TouchEvent) => {
607
- if (moveEvent.touches.length > 0) {
608
- moveEvent.preventDefault();
609
- handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY);
610
- }
611
- };
612
- const onTouchEnd = (endEvent: TouchEvent) => {
613
- const touch = endEvent.changedTouches[0];
614
- if (touch) {
615
- handleEnd(touch.clientX, touch.clientY);
616
- } else {
617
- handleEnd(startX, startY);
618
- }
619
- };
620
- document.addEventListener('touchmove', onTouchMove, { passive: false });
621
- document.addEventListener('touchend', onTouchEnd);
622
- document.addEventListener('touchcancel', onTouchEnd);
623
- activeDocTouchMove = onTouchMove;
624
- activeDocTouchEnd = onTouchEnd;
625
- activeDocTouchCancel = onTouchEnd;
626
- }
627
-
628
- // Mouse down handler
629
- const handleMouseDown = (e: Event) => {
630
- const mouseEvent = e as MouseEvent;
631
- mouseEvent.preventDefault();
632
- startDrag(mouseEvent.clientX, mouseEvent.clientY);
633
- };
634
-
635
- // Touch start handler
636
- const handleTouchStart = (e: Event) => {
637
- const touchEvent = e as TouchEvent;
638
- if (touchEvent.touches.length === 1) {
639
- touchEvent.preventDefault();
640
- startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY);
641
- }
642
- };
643
-
644
- element.addEventListener('mousedown', handleMouseDown);
645
- element.addEventListener('touchstart', handleTouchStart, { passive: false });
646
- cleanups.push(() => {
647
- element.removeEventListener('mousedown', handleMouseDown);
648
- element.removeEventListener('touchstart', handleTouchStart);
649
- });
650
-
651
- return () => {
652
- for (const cleanup of cleanups) {
653
- cleanup();
654
- }
655
- // Clean up any active document listeners (mid-drag unmount)
656
- if (activeDocMouseMove) {
657
- document.removeEventListener('mousemove', activeDocMouseMove);
658
- activeDocMouseMove = null;
659
- }
660
- if (activeDocMouseUp) {
661
- document.removeEventListener('mouseup', activeDocMouseUp);
662
- activeDocMouseUp = null;
663
- }
664
- if (activeDocTouchMove) {
665
- document.removeEventListener('touchmove', activeDocTouchMove);
666
- activeDocTouchMove = null;
667
- }
668
- if (activeDocTouchEnd) {
669
- document.removeEventListener('touchend', activeDocTouchEnd);
670
- activeDocTouchEnd = null;
671
- }
672
- if (activeDocTouchCancel) {
673
- document.removeEventListener('touchcancel', activeDocTouchCancel);
674
- activeDocTouchCancel = null;
675
- }
676
- // Restore user-select in case of mid-drag cleanup
677
- svg.style.userSelect = '';
678
- };
679
- }
680
-
681
- // ---------------------------------------------------------------------------
682
- // Annotation drag editing
683
- // ---------------------------------------------------------------------------
684
-
685
- /**
686
- * Wire drag-to-reposition on text annotation labels.
687
- * Only activates for text annotations (not range or refline).
688
- * During drag, applies a CSS transform for real-time visual feedback and
689
- * counter-adjusts straight connector endpoints so the data-point end stays fixed.
690
- * On mouseup, fires the callback with the updated offset values.
691
- *
692
- * Returns a cleanup function to remove all listeners.
693
- */
694
- function wireAnnotationDrag(
695
- svg: SVGElement,
696
- specAnnotations: Annotation[],
697
- onAnnotationEdit:
698
- | ((annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void)
699
- | undefined,
700
- onEdit: ((edit: ElementEdit) => void) | undefined,
701
- setDragging: (dragging: boolean) => void,
702
- ): () => void {
703
- const annotationElements = svg.querySelectorAll('.oc-annotation-text');
704
- const cleanups: Array<() => void> = [];
705
-
706
- for (const el of annotationElements) {
707
- const indexStr = el.getAttribute('data-annotation-index');
708
- if (indexStr === null) continue;
709
-
710
- const index = Number(indexStr);
711
- const specAnnotation = specAnnotations[index];
712
- if (!specAnnotation || specAnnotation.type !== 'text') continue;
713
-
714
- const textAnnotation = specAnnotation as TextAnnotation;
715
- const annotationG = el as SVGGElement;
716
-
717
- // Visual affordance: show grab cursor
718
- annotationG.style.cursor = 'grab';
719
-
720
- // Stash connector info for real-time updates during drag
721
- const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
722
- const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
723
- const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
724
-
725
- // For curved connectors, stash path/polygon elements to hide during drag
726
- const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
727
- const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
728
- const hasCurvedConnector = curvedPath !== null;
729
-
730
- const origDx = textAnnotation.offset?.dx ?? 0;
731
- const origDy = textAnnotation.offset?.dy ?? 0;
732
-
733
- const cleanup = createDragHandler({
734
- element: annotationG,
735
- svg: svg as unknown as SVGSVGElement,
736
- onMove: (dx, dy) => {
737
- // Move the entire annotation group
738
- annotationG.setAttribute('transform', `translate(${dx}, ${dy})`);
739
-
740
- // For straight connectors, counter-adjust the data-point end
741
- if (connectorLine && !hasCurvedConnector) {
742
- connectorLine.setAttribute('x2', String(origX2 - dx));
743
- connectorLine.setAttribute('y2', String(origY2 - dy));
744
- }
745
-
746
- // Hide curved connector elements during drag
747
- if (hasCurvedConnector) {
748
- if (curvedPath) curvedPath.setAttribute('display', 'none');
749
- if (arrowhead) arrowhead.setAttribute('display', 'none');
750
- }
751
- },
752
- onEnd: (dx, dy, moved) => {
753
- // Clean up visual state
754
- annotationG.removeAttribute('transform');
755
-
756
- // Restore straight connector to original values
757
- if (connectorLine && !hasCurvedConnector) {
758
- connectorLine.setAttribute('x2', String(origX2));
759
- connectorLine.setAttribute('y2', String(origY2));
760
- }
761
-
762
- // Restore curved connector elements
763
- if (hasCurvedConnector) {
764
- if (curvedPath) curvedPath.removeAttribute('display');
765
- if (arrowhead) arrowhead.removeAttribute('display');
766
- }
767
-
768
- if (moved) {
769
- const newOffset: AnnotationOffset = {
770
- dx: origDx + dx,
771
- dy: origDy + dy,
772
- };
773
- // Fire legacy callback
774
- onAnnotationEdit?.(textAnnotation, newOffset);
775
- // Fire unified edit callback
776
- onEdit?.({ type: 'annotation', annotation: textAnnotation, offset: newOffset });
777
- }
778
- },
779
- setDragging,
780
- });
781
-
782
- cleanups.push(cleanup);
783
- }
784
-
785
- return () => {
786
- for (const cleanup of cleanups) {
787
- cleanup();
788
- }
789
- };
790
- }
791
-
792
- // ---------------------------------------------------------------------------
793
- // Connector endpoint drag
794
- // ---------------------------------------------------------------------------
795
-
796
- /**
797
- * Wire drag on connector endpoint handles for text annotations.
798
- * Dynamically creates invisible handle circles at connector endpoints
799
- * so they only exist when editing is active (not in every chart).
800
- * During drag, updates the handle position and the connector line endpoints.
801
- * On end, fires onEdit with the accumulated endpoint offset.
802
- *
803
- * Shows handles on hover over the parent annotation group.
804
- * Returns a cleanup function that removes handles and all listeners.
805
- */
806
- function wireConnectorEndpointDrag(
807
- svg: SVGElement,
808
- specAnnotations: Annotation[],
809
- onEdit: (edit: ElementEdit) => void,
810
- setDragging: (dragging: boolean) => void,
811
- ): () => void {
812
- const SVG_NS = 'http://www.w3.org/2000/svg';
813
- const cleanups: Array<() => void> = [];
814
- const annotationGroups = svg.querySelectorAll('.oc-annotation-text');
815
-
816
- for (const el of annotationGroups) {
817
- const annotationG = el as SVGGElement;
818
- const indexStr = annotationG.getAttribute('data-annotation-index');
819
- if (indexStr === null) continue;
820
-
821
- const index = Number(indexStr);
822
- const specAnnotation = specAnnotations[index];
823
- if (!specAnnotation || specAnnotation.type !== 'text') continue;
824
-
825
- const textAnnotation = specAnnotation as TextAnnotation;
826
-
827
- // Find connector line or curved connector to determine endpoints
828
- const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
829
- const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
830
- if (!connectorLine && !curvedPath) continue;
831
-
832
- // Determine connector endpoint positions from the connector element
833
- let fromX: number, fromY: number, toX: number, toY: number;
834
- if (connectorLine) {
835
- fromX = Number(connectorLine.getAttribute('x1')) || 0;
836
- fromY = Number(connectorLine.getAttribute('y1')) || 0;
837
- toX = Number(connectorLine.getAttribute('x2')) || 0;
838
- toY = Number(connectorLine.getAttribute('y2')) || 0;
839
- } else {
840
- // For curved connectors, get positions from the path data
841
- // The path starts at M x y, so parse the first coordinates
842
- const pathD = curvedPath!.getAttribute('d') ?? '';
843
- const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/);
844
- fromX = mMatch ? Number(mMatch[1]) : 0;
845
- fromY = mMatch ? Number(mMatch[2]) : 0;
846
- // For curved connectors, the arrow polygon has the target
847
- const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
848
- const points = arrowhead?.getAttribute('points') ?? '';
849
- const firstPoint = points.split(' ')[0] ?? '0,0';
850
- const [px, py] = firstPoint.split(',');
851
- toX = Number(px) || 0;
852
- toY = Number(py) || 0;
853
- }
854
-
855
- // Create handles dynamically
856
- const endpoints: Array<{ name: 'from' | 'to'; cx: number; cy: number }> = [
857
- { name: 'from', cx: fromX, cy: fromY },
858
- { name: 'to', cx: toX, cy: toY },
859
- ];
860
-
861
- const createdHandles: SVGCircleElement[] = [];
862
-
863
- for (const ep of endpoints) {
864
- // Skip endpoints with invalid coordinates to prevent NaN in SVG attributes
865
- if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
866
-
867
- const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
868
- handleEl.setAttribute('class', 'oc-connector-handle');
869
- handleEl.setAttribute('data-endpoint', ep.name);
870
- handleEl.setAttribute('cx', String(ep.cx));
871
- handleEl.setAttribute('cy', String(ep.cy));
872
- handleEl.setAttribute('r', '4');
873
- handleEl.setAttribute('opacity', '0');
874
- handleEl.setAttribute('fill', 'currentColor');
875
- handleEl.setAttribute('stroke', 'currentColor');
876
- annotationG.appendChild(handleEl);
877
- createdHandles.push(handleEl);
878
-
879
- const origCx = ep.cx;
880
- const origCy = ep.cy;
881
-
882
- // Prevent parent annotation drag from firing
883
- const stopProp = (e: Event) => {
884
- e.stopPropagation();
885
- };
886
- handleEl.addEventListener('mousedown', stopProp);
887
- handleEl.addEventListener('touchstart', stopProp);
888
- cleanups.push(() => {
889
- handleEl.removeEventListener('mousedown', stopProp);
890
- handleEl.removeEventListener('touchstart', stopProp);
891
- });
892
-
893
- const cleanup = createDragHandler({
894
- element: handleEl,
895
- svg: svg as unknown as SVGSVGElement,
896
- onMove: (dx, dy) => {
897
- handleEl.setAttribute('cx', String(origCx + dx));
898
- handleEl.setAttribute('cy', String(origCy + dy));
899
-
900
- if (connectorLine) {
901
- if (ep.name === 'from') {
902
- connectorLine.setAttribute('x1', String(origCx + dx));
903
- connectorLine.setAttribute('y1', String(origCy + dy));
904
- } else {
905
- connectorLine.setAttribute('x2', String(origCx + dx));
906
- connectorLine.setAttribute('y2', String(origCy + dy));
907
- }
908
- }
909
- },
910
- onEnd: (dx, dy, moved) => {
911
- handleEl.setAttribute('cx', String(origCx));
912
- handleEl.setAttribute('cy', String(origCy));
913
-
914
- if (connectorLine) {
915
- if (ep.name === 'from') {
916
- connectorLine.setAttribute('x1', String(origCx));
917
- connectorLine.setAttribute('y1', String(origCy));
918
- } else {
919
- connectorLine.setAttribute('x2', String(origCx));
920
- connectorLine.setAttribute('y2', String(origCy));
921
- }
922
- }
923
-
924
- if (moved) {
925
- const existingOffset = textAnnotation.connectorOffset?.[ep.name];
926
- const origEndDx = existingOffset?.dx ?? 0;
927
- const origEndDy = existingOffset?.dy ?? 0;
928
- onEdit({
929
- type: 'annotation-connector',
930
- annotation: textAnnotation,
931
- endpoint: ep.name,
932
- offset: { dx: origEndDx + dx, dy: origEndDy + dy },
933
- });
934
- }
935
- },
936
- setDragging,
937
- });
938
-
939
- cleanups.push(cleanup);
940
- }
941
-
942
- // Wire hover to show/hide handles
943
- const showHandles = () => {
944
- for (const h of createdHandles) {
945
- h.setAttribute('opacity', '0.6');
946
- }
947
- };
948
- const hideHandles = () => {
949
- for (const h of createdHandles) {
950
- h.setAttribute('opacity', '0');
951
- }
952
- };
953
-
954
- annotationG.addEventListener('mouseenter', showHandles);
955
- annotationG.addEventListener('mouseleave', hideHandles);
956
- cleanups.push(() => {
957
- annotationG.removeEventListener('mouseenter', showHandles);
958
- annotationG.removeEventListener('mouseleave', hideHandles);
959
- // Remove dynamically created handles
960
- for (const h of createdHandles) {
961
- h.remove();
962
- }
963
- });
964
- }
965
-
966
- return () => {
967
- for (const cleanup of cleanups) {
968
- cleanup();
969
- }
970
- };
971
- }
972
-
973
- // ---------------------------------------------------------------------------
974
- // Range/refline annotation label drag
975
- // ---------------------------------------------------------------------------
976
-
977
- /**
978
- * Wire drag on range and refline annotation labels.
979
- * On drag end, fires onEdit with the label offset.
980
- * Returns a cleanup function.
981
- */
982
- function wireAnnotationLabelDrag(
983
- svg: SVGElement,
984
- specAnnotations: Annotation[],
985
- onEdit: (edit: ElementEdit) => void,
986
- setDragging: (dragging: boolean) => void,
987
- ): () => void {
988
- const cleanups: Array<() => void> = [];
989
-
990
- // Target range and refline annotation labels
991
- const selectors = [
992
- '.oc-annotation-range .oc-annotation-label',
993
- '.oc-annotation-refline .oc-annotation-label',
994
- ];
995
-
996
- for (const selector of selectors) {
997
- const labels = svg.querySelectorAll(selector);
998
-
999
- for (const label of labels) {
1000
- const annotationG = label.closest('.oc-annotation') as SVGGElement | null;
1001
- if (!annotationG) continue;
1002
-
1003
- const indexStr = annotationG.getAttribute('data-annotation-index');
1004
- if (indexStr === null) continue;
1005
-
1006
- const index = Number(indexStr);
1007
- const specAnnotation = specAnnotations[index];
1008
- if (!specAnnotation) continue;
1009
-
1010
- const labelEl = label as SVGTextElement;
1011
- labelEl.style.cursor = 'grab';
1012
-
1013
- const isRange = specAnnotation.type === 'range';
1014
- const existingLabelOffset = isRange
1015
- ? (specAnnotation as RangeAnnotation).labelOffset
1016
- : (specAnnotation as RefLineAnnotation).labelOffset;
1017
- const origLabelDx = existingLabelOffset?.dx ?? 0;
1018
- const origLabelDy = existingLabelOffset?.dy ?? 0;
1019
-
1020
- const cleanup = createDragHandler({
1021
- element: labelEl,
1022
- svg: svg as unknown as SVGSVGElement,
1023
- onMove: (dx, dy) => {
1024
- (labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
1025
- `translate(${dx}px, ${dy}px)`;
1026
- },
1027
- onEnd: (dx, dy, moved) => {
1028
- (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
1029
-
1030
- if (moved) {
1031
- if (isRange) {
1032
- onEdit({
1033
- type: 'range-label',
1034
- annotation: specAnnotation as RangeAnnotation,
1035
- labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
1036
- });
1037
- } else {
1038
- onEdit({
1039
- type: 'refline-label',
1040
- annotation: specAnnotation as RefLineAnnotation,
1041
- labelOffset: { dx: origLabelDx + dx, dy: origLabelDy + dy },
1042
- });
1043
- }
1044
- }
1045
- },
1046
- setDragging,
1047
- });
1048
-
1049
- cleanups.push(cleanup);
1050
- }
1051
- }
1052
-
1053
- return () => {
1054
- for (const cleanup of cleanups) {
1055
- cleanup();
1056
- }
1057
- };
1058
- }
1059
-
1060
- // ---------------------------------------------------------------------------
1061
- // Chrome text drag
1062
- // ---------------------------------------------------------------------------
1063
-
1064
- /**
1065
- * Wire drag on chrome text elements (title, subtitle, source, byline, footer).
1066
- * On drag end, fires onEdit with the chrome key, text, and offset.
1067
- * Returns a cleanup function.
1068
- */
1069
- function wireChromeDrag(
1070
- svg: SVGElement,
1071
- spec: ChartSpec | GraphSpec,
1072
- onEdit: (edit: ElementEdit) => void,
1073
- setDragging: (dragging: boolean) => void,
1074
- ): () => void {
1075
- const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]');
1076
- const cleanups: Array<() => void> = [];
1077
-
1078
- // Read existing chrome offsets from the spec
1079
- const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
1080
-
1081
- for (const el of chromeTexts) {
1082
- const textEl = el as SVGTextElement;
1083
- const key = textEl.getAttribute('data-chrome-key') as ChromeKey;
1084
- if (!key) continue;
1085
-
1086
- // Read existing offset for this chrome element
1087
- const chromeEntry = chromeConfig?.[key];
1088
- const existingOffset =
1089
- typeof chromeEntry === 'object' && chromeEntry !== null ? chromeEntry.offset : undefined;
1090
- const origChromeDx = existingOffset?.dx ?? 0;
1091
- const origChromeDy = existingOffset?.dy ?? 0;
1092
-
1093
- textEl.style.cursor = 'grab';
1094
-
1095
- const cleanup = createDragHandler({
1096
- element: textEl,
1097
- svg: svg as unknown as SVGSVGElement,
1098
- onMove: (dx, dy) => {
1099
- (textEl as SVGElement & ElementCSSInlineStyle).style.transform =
1100
- `translate(${dx}px, ${dy}px)`;
1101
- },
1102
- onEnd: (dx, dy, moved) => {
1103
- (textEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
1104
-
1105
- if (moved) {
1106
- onEdit({
1107
- type: 'chrome',
1108
- key,
1109
- text: textEl.textContent ?? '',
1110
- offset: { dx: origChromeDx + dx, dy: origChromeDy + dy },
1111
- });
1112
- }
1113
- },
1114
- setDragging,
1115
- });
1116
-
1117
- cleanups.push(cleanup);
1118
- }
1119
-
1120
- return () => {
1121
- for (const cleanup of cleanups) {
1122
- cleanup();
1123
- }
1124
- };
1125
- }
1126
-
1127
- // ---------------------------------------------------------------------------
1128
- // Legend drag
1129
- // ---------------------------------------------------------------------------
1130
-
1131
- /**
1132
- * Wire drag on the legend group.
1133
- * Click suppression prevents legend toggle from firing after a drag.
1134
- * On drag end, fires onEdit with the legend offset.
1135
- * Returns a cleanup function.
1136
- */
1137
- function wireLegendDrag(
1138
- svg: SVGElement,
1139
- spec: ChartSpec | GraphSpec,
1140
- onEdit: (edit: ElementEdit) => void,
1141
- setDragging: (dragging: boolean) => void,
1142
- ): () => void {
1143
- const legendG = svg.querySelector('.oc-legend') as SVGGElement | null;
1144
- if (!legendG) return () => {};
1145
-
1146
- const cleanups: Array<() => void> = [];
1147
-
1148
- // Read existing legend offset from the spec
1149
- const legendConfig = 'legend' in spec ? spec.legend : undefined;
1150
- const origLegendDx = legendConfig?.offset?.dx ?? 0;
1151
- const origLegendDy = legendConfig?.offset?.dy ?? 0;
1152
-
1153
- // Set grab cursor on the legend background, not on entry elements
1154
- legendG.style.cursor = 'grab';
1155
-
1156
- const cleanup = createDragHandler({
1157
- element: legendG,
1158
- svg: svg as unknown as SVGSVGElement,
1159
- onMove: (dx, dy) => {
1160
- (legendG as SVGElement & ElementCSSInlineStyle).style.transform =
1161
- `translate(${dx}px, ${dy}px)`;
1162
- },
1163
- onEnd: (dx, dy, moved) => {
1164
- (legendG as SVGElement & ElementCSSInlineStyle).style.transform = '';
1165
-
1166
- if (moved) {
1167
- onEdit({ type: 'legend', offset: { dx: origLegendDx + dx, dy: origLegendDy + dy } });
1168
- }
1169
- },
1170
- setDragging,
1171
- });
1172
-
1173
- cleanups.push(cleanup);
1174
-
1175
- return () => {
1176
- for (const cleanup of cleanups) {
1177
- cleanup();
1178
- }
1179
- };
1180
- }
1181
-
1182
- // ---------------------------------------------------------------------------
1183
- // Series label drag
1184
- // ---------------------------------------------------------------------------
1185
-
1186
- /**
1187
- * Wire drag on series label elements (.oc-mark-label[data-series]).
1188
- * On drag end, fires onEdit with the series name and offset.
1189
- * Returns a cleanup function.
1190
- */
1191
- function wireSeriesLabelDrag(
1192
- svg: SVGElement,
1193
- spec: ChartSpec | GraphSpec,
1194
- onEdit: (edit: ElementEdit) => void,
1195
- setDragging: (dragging: boolean) => void,
1196
- ): () => void {
1197
- const labels = svg.querySelectorAll('.oc-mark-label');
1198
- const cleanups: Array<() => void> = [];
1199
-
1200
- // Read existing label offsets from the spec (skip boolean shorthand)
1201
- const rawLabels = 'labels' in spec ? spec.labels : undefined;
1202
- const labelsConfig = typeof rawLabels === 'object' ? rawLabels : undefined;
1203
-
1204
- for (const label of labels) {
1205
- const labelEl = label as SVGTextElement;
1206
- // Check label itself first, then fall back to the parent mark group's data-series
1207
- const series =
1208
- labelEl.getAttribute('data-series') ??
1209
- labelEl.closest('[data-series]')?.getAttribute('data-series');
1210
- if (!series) continue;
1211
-
1212
- // Read existing offset for this series label
1213
- const existingSeriesOffset = labelsConfig?.offsets?.[series];
1214
- const origSeriesDx = existingSeriesOffset?.dx ?? 0;
1215
- const origSeriesDy = existingSeriesOffset?.dy ?? 0;
1216
-
1217
- labelEl.style.cursor = 'grab';
1218
-
1219
- const cleanup = createDragHandler({
1220
- element: labelEl,
1221
- svg: svg as unknown as SVGSVGElement,
1222
- onMove: (dx, dy) => {
1223
- (labelEl as SVGElement & ElementCSSInlineStyle).style.transform =
1224
- `translate(${dx}px, ${dy}px)`;
1225
- },
1226
- onEnd: (dx, dy, moved) => {
1227
- (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = '';
1228
-
1229
- if (moved) {
1230
- onEdit({
1231
- type: 'series-label',
1232
- series,
1233
- offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy },
1234
- });
1235
- }
1236
- },
1237
- setDragging,
1238
- });
1239
-
1240
- cleanups.push(cleanup);
1241
- }
1242
-
1243
- return () => {
1244
- for (const cleanup of cleanups) {
1245
- cleanup();
1246
- }
1247
- };
1248
- }
1249
-
1250
- // ---------------------------------------------------------------------------
1251
- // Legend interactivity
1252
- // ---------------------------------------------------------------------------
1253
-
1254
- /**
1255
- * Wire click handlers on legend entries to toggle series visibility.
1256
- * Fires onEdit with { type: 'legend-toggle', series, hidden } for each toggle,
1257
- * and optionally calls the legacy onLegendToggle callback.
1258
- * Legend entries for hidden series stay visible but dimmed (opacity 0.3).
1259
- * Returns a cleanup function.
1260
- */
1261
- function wireLegendInteraction(
1262
- svg: SVGElement,
1263
- _layout: ChartLayout,
1264
- onLegendToggle?: (series: string, visible: boolean) => void,
1265
- onEdit?: (edit: ElementEdit) => void,
1266
- ): () => void {
1267
- const legendEntries = svg.querySelectorAll('[data-legend-index]');
1268
- const cleanups: Array<() => void> = [];
1269
-
1270
- // Track which series are hidden
1271
- const hiddenSeries = new Set<string>();
1272
-
1273
- for (const entry of legendEntries) {
1274
- // Skip overflow indicator entries ("+N more")
1275
- if (entry.getAttribute('data-legend-overflow') === 'true') continue;
1276
-
1277
- const handleClick = () => {
1278
- const label = entry.getAttribute('data-legend-label');
1279
- if (!label) return;
1280
-
1281
- if (hiddenSeries.has(label)) {
1282
- hiddenSeries.delete(label);
1283
- entry.setAttribute('opacity', '1');
1284
- entry.setAttribute('aria-label', `${label}: visible`);
1285
- onLegendToggle?.(label, true);
1286
- onEdit?.({ type: 'legend-toggle', series: label, hidden: false });
1287
- } else {
1288
- hiddenSeries.add(label);
1289
- entry.setAttribute('opacity', '0.3');
1290
- entry.setAttribute('aria-label', `${label}: hidden`);
1291
- onLegendToggle?.(label, false);
1292
- onEdit?.({ type: 'legend-toggle', series: label, hidden: true });
1293
- }
1294
-
1295
- // Toggle visibility of marks with matching series.
1296
- // Uses the data-series attribute set by the SVG renderer, which works
1297
- // for all mark types (line, area, rect, arc, point).
1298
- const marks = svg.querySelectorAll('.oc-mark');
1299
- for (const mark of marks) {
1300
- const seriesName = mark.getAttribute('data-series');
1301
- if (!seriesName) continue;
1302
-
1303
- if (hiddenSeries.has(seriesName)) {
1304
- (mark as SVGElement).style.display = 'none';
1305
- } else {
1306
- (mark as SVGElement).style.display = '';
1307
- }
1308
- }
1309
- };
1310
-
1311
- entry.addEventListener('click', handleClick);
1312
- cleanups.push(() => entry.removeEventListener('click', handleClick));
1313
- }
1314
-
1315
- return () => {
1316
- for (const cleanup of cleanups) {
1317
- cleanup();
1318
- }
1319
- };
1320
- }
1321
-
1322
- // ---------------------------------------------------------------------------
1323
- // Keyboard navigation
1324
- // ---------------------------------------------------------------------------
1325
-
1326
- /**
1327
- * Wire keyboard navigation on the SVG element.
1328
- * Arrow keys move focus between mark elements. Enter/Space shows tooltip.
1329
- * Escape hides tooltip. Returns a cleanup function.
1330
- */
1331
- function wireKeyboardNav(
1332
- svg: SVGElement,
1333
- container: HTMLElement,
1334
- tooltipDescriptors: Map<string, TooltipContent>,
1335
- tooltipManager: TooltipManager,
1336
- layout: ChartLayout,
1337
- ): () => void {
1338
- // Make container focusable
1339
- container.setAttribute('tabindex', '0');
1340
- container.setAttribute('aria-roledescription', 'chart');
1341
- container.setAttribute('aria-label', layout.a11y.altText);
1342
-
1343
- // Collect navigable mark elements (those with tooltip content)
1344
- const markElements: SVGElement[] = [];
1345
- const allMarkEls = svg.querySelectorAll('[data-mark-id]');
1346
- for (const el of allMarkEls) {
1347
- const markId = el.getAttribute('data-mark-id');
1348
- if (markId && tooltipDescriptors.has(markId)) {
1349
- markElements.push(el as SVGElement);
1350
- }
1351
- }
1352
-
1353
- let focusIndex = -1;
1354
-
1355
- function highlightMark(index: number): void {
1356
- // Remove previous highlight
1357
- if (focusIndex >= 0 && focusIndex < markElements.length) {
1358
- markElements[focusIndex].classList.remove('oc-mark-focused');
1359
- markElements[focusIndex].removeAttribute('aria-selected');
1360
- }
1361
-
1362
- focusIndex = index;
1363
-
1364
- if (focusIndex >= 0 && focusIndex < markElements.length) {
1365
- const el = markElements[focusIndex];
1366
- el.classList.add('oc-mark-focused');
1367
- el.setAttribute('aria-selected', 'true');
1368
- }
1369
- }
1370
-
1371
- function showTooltipForFocused(): void {
1372
- if (focusIndex < 0 || focusIndex >= markElements.length) return;
1373
-
1374
- const el = markElements[focusIndex];
1375
- const markId = el.getAttribute('data-mark-id');
1376
- if (!markId) return;
1377
-
1378
- const content = tooltipDescriptors.get(markId);
1379
- if (!content) return;
1380
-
1381
- // Position tooltip near the mark element
1382
- const bbox = el.getBoundingClientRect();
1383
- const containerRect = container.getBoundingClientRect();
1384
- const x = bbox.left + bbox.width / 2 - containerRect.left;
1385
- const y = bbox.top - containerRect.top;
1386
- tooltipManager.show(content, x, y);
1387
- }
1388
-
1389
- const handleKeyDown = (e: KeyboardEvent) => {
1390
- if (markElements.length === 0) return;
1391
-
1392
- switch (e.key) {
1393
- case 'ArrowRight':
1394
- case 'ArrowDown': {
1395
- e.preventDefault();
1396
- const next = focusIndex < markElements.length - 1 ? focusIndex + 1 : 0;
1397
- highlightMark(next);
1398
- showTooltipForFocused();
1399
- break;
1400
- }
1401
- case 'ArrowLeft':
1402
- case 'ArrowUp': {
1403
- e.preventDefault();
1404
- const prev = focusIndex > 0 ? focusIndex - 1 : markElements.length - 1;
1405
- highlightMark(prev);
1406
- showTooltipForFocused();
1407
- break;
1408
- }
1409
- case 'Enter':
1410
- case ' ': {
1411
- e.preventDefault();
1412
- if (focusIndex >= 0) {
1413
- showTooltipForFocused();
1414
- } else if (markElements.length > 0) {
1415
- highlightMark(0);
1416
- showTooltipForFocused();
1417
- }
1418
- break;
1419
- }
1420
- case 'Escape': {
1421
- e.preventDefault();
1422
- tooltipManager.hide();
1423
- highlightMark(-1);
1424
- break;
1425
- }
1426
- }
1427
- };
1428
-
1429
- container.addEventListener('keydown', handleKeyDown);
1430
-
1431
- return () => {
1432
- container.removeEventListener('keydown', handleKeyDown);
1433
- container.removeAttribute('tabindex');
1434
- container.removeAttribute('aria-roledescription');
1435
- container.removeAttribute('aria-label');
1436
- };
1437
- }
1438
-
1439
- // ---------------------------------------------------------------------------
1440
- // Hidden data table for screen readers
1441
- // ---------------------------------------------------------------------------
1442
-
1443
- /**
1444
- * Create a visually-hidden data table from the chart's a11y fallback data.
1445
- * Returns the table element (append to container) and a cleanup function.
1446
- */
1447
- function createScreenReaderTable(
1448
- layout: ChartLayout,
1449
- container: HTMLElement,
1450
- ): HTMLTableElement | null {
1451
- const data = layout.a11y.dataTableFallback;
1452
- if (!data || data.length === 0) return null;
1453
-
1454
- const table = document.createElement('table');
1455
- table.className = 'oc-sr-only';
1456
- // Inline critical SR-only styles so the table stays hidden even when the
1457
- // external stylesheet isn't loaded (e.g. CDN / esm.sh usage).
1458
- table.style.position = 'absolute';
1459
- table.style.width = '1px';
1460
- table.style.height = '1px';
1461
- table.style.padding = '0';
1462
- table.style.margin = '-1px';
1463
- table.style.overflow = 'hidden';
1464
- table.style.clipPath = 'inset(50%)';
1465
- table.style.whiteSpace = 'nowrap';
1466
- table.style.borderWidth = '0';
1467
- table.setAttribute('role', 'table');
1468
- table.setAttribute('aria-label', `Data table: ${layout.a11y.altText}`);
1469
-
1470
- // First row is headers
1471
- if (data.length > 0) {
1472
- const thead = document.createElement('thead');
1473
- const headerRow = document.createElement('tr');
1474
- const headers = data[0] as unknown[];
1475
- for (const header of headers) {
1476
- const th = document.createElement('th');
1477
- th.textContent = String(header ?? '');
1478
- th.setAttribute('scope', 'col');
1479
- headerRow.appendChild(th);
1480
- }
1481
- thead.appendChild(headerRow);
1482
- table.appendChild(thead);
1483
- }
1484
-
1485
- // Remaining rows are data
1486
- if (data.length > 1) {
1487
- const tbody = document.createElement('tbody');
1488
- for (let i = 1; i < data.length; i++) {
1489
- const tr = document.createElement('tr');
1490
- const cells = data[i] as unknown[];
1491
- for (const cell of cells) {
1492
- const td = document.createElement('td');
1493
- td.textContent = String(cell ?? '');
1494
- tr.appendChild(td);
1495
- }
1496
- tbody.appendChild(tr);
1497
- }
1498
- table.appendChild(tbody);
1499
- }
1500
-
1501
- container.appendChild(table);
1502
- return table;
1503
- }
1504
-
1505
132
  // ---------------------------------------------------------------------------
1506
133
  // Editable element helpers
1507
134
  // ---------------------------------------------------------------------------
1508
135
 
1509
- /** CSS for editable hover feedback, injected into the SVG as a <style> element. */
1510
136
  const EDITABLE_HOVER_CSS = `
1511
137
  .oc-editable-hover {
1512
138
  outline: 1.5px solid rgba(79, 70, 229, 0.35);
@@ -1515,257 +141,32 @@ const EDITABLE_HOVER_CSS = `
1515
141
  }
1516
142
  `;
1517
143
 
1518
- /**
1519
- * Inject editable styles into an SVG element and make it focusable.
1520
- * Called when any editing callback is provided.
1521
- */
1522
144
  function makeEditable(svg: SVGElement): void {
1523
145
  svg.setAttribute('tabindex', '0');
1524
146
  svg.style.outline = 'none';
1525
147
 
1526
- // Inject hover style into SVG defs
1527
148
  const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1528
149
  style.textContent = EDITABLE_HOVER_CSS;
1529
150
  svg.insertBefore(style, svg.firstChild);
1530
151
  }
1531
152
 
1532
- /**
1533
- * Check whether any editing-related callback is provided in the options.
1534
- */
1535
153
  function hasEditingCallbacks(opts?: MountOptions): boolean {
1536
154
  return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
1537
155
  }
1538
156
 
1539
- /**
1540
- * Find a DOM element inside the SVG that matches the given ElementRef.
1541
- */
1542
- function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
1543
- switch (ref.type) {
1544
- case 'annotation': {
1545
- // Prefer id-based lookup when available
1546
- if (ref.id) {
1547
- const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
1548
- if (byId) return byId as SVGElement;
1549
- }
1550
- return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
1551
- }
1552
- case 'chrome':
1553
- return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
1554
- case 'series-label':
1555
- return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
1556
- case 'legend':
1557
- return svg.querySelector('.oc-legend') as SVGElement | null;
1558
- case 'legend-entry':
1559
- return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
1560
- }
1561
- }
1562
-
1563
- /**
1564
- * Build an ElementRef from a DOM element's data attributes.
1565
- * Walks up the tree to find the closest editable ancestor if needed.
1566
- */
1567
- function buildElementRef(element: Element, _specAnnotations: Annotation[]): ElementRef | null {
1568
- // Check for annotation
1569
- const annotationEl = element.closest('[data-annotation-index]');
1570
- if (annotationEl) {
1571
- const index = Number(annotationEl.getAttribute('data-annotation-index'));
1572
- const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
1573
- return elementRef.annotation(index, id);
1574
- }
1575
-
1576
- // Check for chrome
1577
- const chromeEl = element.closest('[data-chrome-key]');
1578
- if (chromeEl) {
1579
- const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
1580
- if (key) return elementRef.chrome(key);
1581
- }
1582
-
1583
- // Check for series label
1584
- const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
1585
- if (seriesLabelEl) {
1586
- const series = seriesLabelEl.getAttribute('data-series');
1587
- if (series) return elementRef.seriesLabel(series);
1588
- }
1589
-
1590
- // Check for legend entry
1591
- const legendEntryEl = element.closest('[data-legend-index]');
1592
- if (legendEntryEl) {
1593
- const index = Number(legendEntryEl.getAttribute('data-legend-index'));
1594
- const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
1595
- return elementRef.legendEntry(series, index);
1596
- }
1597
-
1598
- // Check for legend group
1599
- const legendEl = element.closest('.oc-legend');
1600
- if (legendEl) return elementRef.legend();
1601
-
1602
- return null;
1603
- }
1604
-
1605
- /**
1606
- * Get an ordered list of all editable ElementRefs from the current spec and layout.
1607
- * Order: chrome (title, subtitle, source, byline, footer), annotations by index,
1608
- * series labels alphabetical, legend.
1609
- */
1610
- function getEditableElements(
1611
- spec: ChartSpec | LayerSpec | GraphSpec,
1612
- layout: ChartLayout,
1613
- ): ElementRef[] {
1614
- const refs: ElementRef[] = [];
1615
-
1616
- // Chrome keys in display order
1617
- const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
1618
- for (const key of chromeKeys) {
1619
- if (layout.chrome[key]) {
1620
- refs.push(elementRef.chrome(key));
1621
- }
1622
- }
1623
-
1624
- // Annotations by index
1625
- const annotations: Annotation[] =
1626
- 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
1627
- for (let i = 0; i < annotations.length; i++) {
1628
- refs.push(elementRef.annotation(i, annotations[i].id));
1629
- }
1630
-
1631
- // Series labels (alphabetical)
1632
- const seriesLabels: string[] = [];
1633
- for (const mark of layout.marks) {
1634
- if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
1635
- seriesLabels.push(mark.seriesKey);
1636
- }
1637
- }
1638
- seriesLabels.sort();
1639
- for (const series of seriesLabels) {
1640
- refs.push(elementRef.seriesLabel(series));
1641
- }
1642
-
1643
- // Legend
1644
- if ('entries' in layout.legend && layout.legend.entries.length > 0) {
1645
- refs.push(elementRef.legend());
1646
- }
1647
-
1648
- return refs;
1649
- }
1650
-
1651
- /**
1652
- * Check if an ElementRef points to a text-editable element (chrome text or text annotation).
1653
- */
1654
- function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
1655
- if (ref.type === 'chrome') return true;
1656
- if (ref.type === 'annotation') {
1657
- const annotation = specAnnotations[ref.index];
1658
- return annotation?.type === 'text';
1659
- }
1660
- return false;
1661
- }
1662
-
1663
- /**
1664
- * Get the current text content for an element ref.
1665
- */
1666
- function getElementText(ref: ElementRef, spec: ChartSpec | LayerSpec | GraphSpec): string | null {
1667
- if (ref.type === 'chrome') {
1668
- const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
1669
- if (!chromeConfig) return null;
1670
- const entry = chromeConfig[ref.key];
1671
- if (typeof entry === 'string') return entry;
1672
- if (typeof entry === 'object' && entry !== null && 'text' in entry) {
1673
- return (entry as { text: string }).text;
1674
- }
1675
- return null;
1676
- }
1677
- if (ref.type === 'annotation') {
1678
- const annotations: Annotation[] =
1679
- 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
1680
- const annotation = annotations[ref.index];
1681
- if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
1682
- if (annotation?.label) return annotation.label;
1683
- return null;
1684
- }
1685
- return null;
1686
- }
1687
-
1688
- /**
1689
- * Compare two ElementRefs for equality.
1690
- */
1691
- function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
1692
- if (a === null || b === null) return a === b;
1693
- if (a.type !== b.type) return false;
1694
- switch (a.type) {
1695
- case 'annotation': {
1696
- const bAnno = b as typeof a;
1697
- if (a.id && bAnno.id) return a.id === bAnno.id;
1698
- return a.index === bAnno.index;
1699
- }
1700
- case 'chrome':
1701
- return a.key === (b as typeof a).key;
1702
- case 'series-label':
1703
- return a.series === (b as typeof a).series;
1704
- case 'legend':
1705
- return true;
1706
- case 'legend-entry': {
1707
- const bEntry = b as typeof a;
1708
- return a.index === bEntry.index && a.series === bEntry.series;
1709
- }
1710
- }
1711
- }
1712
-
1713
- /**
1714
- * Render a selection overlay rectangle around a target element.
1715
- * Returns the overlay group element.
1716
- */
1717
- function renderSelectionOverlay(
1718
- svg: SVGElement,
1719
- ref: ElementRef,
1720
- layout: ChartLayout,
1721
- ): SVGGElement | null {
1722
- const target = findElementByRef(svg, ref);
1723
- if (!target) return null;
1724
-
1725
- const bbox = (target as SVGGraphicsElement).getBBox();
1726
- const padding = 4;
1727
-
1728
- // Resolve accent color from theme
1729
- const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
1730
-
1731
- const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1732
- g.setAttribute('class', 'oc-selection-overlay');
1733
-
1734
- const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1735
- rect.setAttribute('x', String(bbox.x - padding));
1736
- rect.setAttribute('y', String(bbox.y - padding));
1737
- rect.setAttribute('width', String(bbox.width + padding * 2));
1738
- rect.setAttribute('height', String(bbox.height + padding * 2));
1739
- rect.setAttribute('rx', '3');
1740
- rect.setAttribute('fill', 'transparent');
1741
- rect.setAttribute('stroke', accentColor);
1742
- rect.setAttribute('stroke-width', '1.5');
1743
- rect.setAttribute('pointer-events', 'none');
1744
-
1745
- g.appendChild(rect);
1746
- svg.appendChild(g);
1747
-
1748
- return g;
1749
- }
1750
-
1751
157
  // ---------------------------------------------------------------------------
1752
158
  // Main API
1753
159
  // ---------------------------------------------------------------------------
1754
160
 
1755
161
  /**
1756
162
  * Create a chart instance from a spec and mount it into a container.
1757
- *
1758
- * @param container - The DOM element to render into.
1759
- * @param spec - The visualization spec.
1760
- * @param options - Mount options (theme, darkMode, responsive, etc.).
1761
- * @returns A ChartInstance with update/resize/export/destroy methods.
1762
163
  */
1763
- export function createChart(
164
+ export function createChart<TData extends DataRow = DataRow>(
1764
165
  container: HTMLElement,
1765
- spec: ChartSpec | LayerSpec | GraphSpec,
166
+ spec: ChartSpec<TData> | LayerSpec<TData> | GraphSpec,
1766
167
  options?: MountOptions,
1767
168
  ): ChartInstance {
1768
- let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec;
169
+ let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec as ChartSpec | LayerSpec | GraphSpec;
1769
170
  let currentLayout: ChartLayout;
1770
171
  let svgElement: SVGElement | null = null;
1771
172
  let tooltipManager: TooltipManager | null = null;
@@ -1793,10 +194,18 @@ export function createChart(
1793
194
  let selectedElement: ElementRef | null = options?.selectedElement ?? null;
1794
195
  let overlayElement: SVGGElement | null = null;
1795
196
  let isTextEditingActive = false;
197
+
198
+ // Runtime legend-toggle state
199
+ const runtimeHiddenSeries = new Set<string>();
200
+ const runtimeShownSeries = new Set<string>();
1796
201
  let textEditCleanup: (() => void) | null = null;
1797
202
 
1798
203
  const measureText = createMeasureText();
1799
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // Compilation
207
+ // ---------------------------------------------------------------------------
208
+
1800
209
  function compile(): ChartLayout {
1801
210
  const { width, height } = getContainerDimensions();
1802
211
  const darkMode = resolveDarkMode(options?.darkMode);
@@ -1811,19 +220,104 @@ export function createChart(
1811
220
  };
1812
221
 
1813
222
  if (isLayerSpec(currentSpec)) {
1814
- return compileLayer(currentSpec as LayerSpec, compileOpts);
223
+ return compileLayer(withRuntimeHidden(currentSpec as LayerSpec) as LayerSpec, compileOpts);
224
+ }
225
+ return compileChart(withRuntimeHidden(currentSpec) as ChartSpec | GraphSpec, compileOpts);
226
+ }
227
+
228
+ function withRuntimeHidden<T extends { hiddenSeries?: string[]; annotations?: Annotation[] }>(
229
+ spec: T,
230
+ ): T {
231
+ if (runtimeHiddenSeries.size === 0 && runtimeShownSeries.size === 0) return spec;
232
+ const userHidden = spec.hiddenSeries ?? [];
233
+ const finalHidden = new Set<string>(userHidden);
234
+ for (const s of runtimeHiddenSeries) finalHidden.add(s);
235
+ for (const s of runtimeShownSeries) finalHidden.delete(s);
236
+ const out: T = { ...spec, hiddenSeries: Array.from(finalHidden) };
237
+ if (finalHidden.size > 0 && spec.annotations) {
238
+ const filtered = spec.annotations.filter((a) => a.type !== 'text');
239
+ out.annotations = filtered.length > 0 ? filtered : undefined;
240
+ }
241
+ return out;
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Legend toggle
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function countSeries(spec: ChartSpec | GraphSpec | LayerSpec): number {
249
+ if (isLayerSpec(spec)) return Infinity;
250
+ const enc = (spec as ChartSpec).encoding;
251
+ const colorEnc = enc?.color;
252
+ if (!colorEnc || 'condition' in colorEnc || colorEnc.type === 'quantitative') return Infinity;
253
+ if (!('field' in colorEnc) || !colorEnc.field) return Infinity;
254
+ const field = colorEnc.field;
255
+ const seen = new Set<string>();
256
+ for (const row of (spec as ChartSpec).data ?? []) {
257
+ seen.add(String((row as Record<string, unknown>)[field]));
258
+ }
259
+ return seen.size;
260
+ }
261
+
262
+ function isSeriesHidden(series: string): boolean {
263
+ if (runtimeShownSeries.has(series)) return false;
264
+ if (runtimeHiddenSeries.has(series)) return true;
265
+ const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
266
+ return userHidden.includes(series);
267
+ }
268
+
269
+ function toggleSeriesVisibility(series: string): boolean {
270
+ const wasHidden = isSeriesHidden(series);
271
+ if (!wasHidden) {
272
+ const total = countSeries(currentSpec);
273
+ if (Number.isFinite(total)) {
274
+ const userHidden = new Set((currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? []);
275
+ let visibleAfter = 0;
276
+ const seriesField = getColorField(currentSpec);
277
+ if (seriesField) {
278
+ const allSeries = new Set<string>();
279
+ for (const row of (currentSpec as ChartSpec).data ?? []) {
280
+ allSeries.add(String((row as Record<string, unknown>)[seriesField]));
281
+ }
282
+ for (const s of allSeries) {
283
+ if (s === series) continue;
284
+ if (runtimeShownSeries.has(s)) {
285
+ visibleAfter++;
286
+ continue;
287
+ }
288
+ if (runtimeHiddenSeries.has(s)) continue;
289
+ if (!userHidden.has(s)) visibleAfter++;
290
+ }
291
+ }
292
+ if (visibleAfter === 0) return false;
293
+ }
294
+ }
295
+ if (wasHidden) {
296
+ runtimeHiddenSeries.delete(series);
297
+ const userHidden = (currentSpec as { hiddenSeries?: string[] }).hiddenSeries ?? [];
298
+ if (userHidden.includes(series)) runtimeShownSeries.add(series);
299
+ } else {
300
+ runtimeShownSeries.delete(series);
301
+ runtimeHiddenSeries.add(series);
1815
302
  }
1816
- return compileChart(currentSpec as ChartSpec | GraphSpec, compileOpts);
303
+ render();
304
+ return !wasHidden;
305
+ }
306
+
307
+ function getColorField(spec: ChartSpec | GraphSpec | LayerSpec): string | undefined {
308
+ if (isLayerSpec(spec)) return undefined;
309
+ const colorEnc = (spec as ChartSpec).encoding?.color;
310
+ if (!colorEnc || 'condition' in colorEnc) return undefined;
311
+ if (!('field' in colorEnc) || !colorEnc.field) return undefined;
312
+ return colorEnc.field;
1817
313
  }
1818
314
 
315
+ // ---------------------------------------------------------------------------
316
+ // Container dimensions
317
+ // ---------------------------------------------------------------------------
318
+
1819
319
  function getContainerDimensions(): { width: number; height: number } {
1820
320
  const rect = container.getBoundingClientRect();
1821
- // Sparkline mode allows tiny containers (KPI cards, inline strips). Drop
1822
- // the standard floor and, when the wrapper has auto-height (collapsed to 0
1823
- // because we haven't rendered yet), measure the parent that the user sized
1824
- // explicitly. Without this, a <div style={{height: 40}}><Chart/></div>
1825
- // would still render at the fallback height because oc-chart-root has
1826
- // width:100% but no intrinsic height.
1827
321
  const isSparkline =
1828
322
  'display' in currentSpec && (currentSpec as ChartSpec).display === 'sparkline';
1829
323
  if (isSparkline) {
@@ -1845,22 +339,22 @@ export function createChart(
1845
339
  };
1846
340
  }
1847
341
 
1848
- /** Get the current spec's annotations array. */
342
+ // ---------------------------------------------------------------------------
343
+ // Selection & text editing
344
+ // ---------------------------------------------------------------------------
345
+
1849
346
  function getSpecAnnotations(): Annotation[] {
1850
347
  return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
1851
348
  ? currentSpec.annotations
1852
349
  : [];
1853
350
  }
1854
351
 
1855
- /** Select an element: render overlay, fire onSelect, update state. */
1856
352
  function selectElement(ref: ElementRef): void {
1857
353
  if (!svgElement) return;
1858
354
 
1859
- // Confirm the target element exists before deselecting the previous one
1860
355
  const target = findElementByRef(svgElement, ref);
1861
356
  if (!target) return;
1862
357
 
1863
- // Deselect previous if different
1864
358
  if (selectedElement && !refsEqual(selectedElement, ref)) {
1865
359
  deselectElement();
1866
360
  }
@@ -1869,15 +363,12 @@ export function createChart(
1869
363
  overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
1870
364
  options?.onSelect?.(ref);
1871
365
 
1872
- // Focus SVG for keyboard events
1873
366
  (svgElement as SVGSVGElement).focus();
1874
367
  }
1875
368
 
1876
- /** Deselect the current element: remove overlay, fire onDeselect, clear state. */
1877
369
  function deselectElement(): void {
1878
370
  if (!selectedElement) return;
1879
371
 
1880
- // Cancel text editing if active
1881
372
  if (isTextEditingActive && textEditCleanup) {
1882
373
  textEditCleanup();
1883
374
  textEditCleanup = null;
@@ -1895,7 +386,6 @@ export function createChart(
1895
386
  options?.onDeselect?.(prev);
1896
387
  }
1897
388
 
1898
- /** Enter text editing mode for the currently selected element. */
1899
389
  function enterTextEditing(): void {
1900
390
  if (!svgElement || !selectedElement || isTextEditingActive) return;
1901
391
 
@@ -1905,11 +395,9 @@ export function createChart(
1905
395
  const currentText = getElementText(selectedElement, currentSpec);
1906
396
  if (currentText === null) return;
1907
397
 
1908
- // Find the text element within the selected element
1909
398
  const target = findElementByRef(svgElement, selectedElement);
1910
399
  if (!target) return;
1911
400
 
1912
- // The target might be a group; find the actual text element
1913
401
  const textEl = target.tagName === 'text' ? target : target.querySelector('text');
1914
402
  if (!textEl) return;
1915
403
 
@@ -1926,7 +414,6 @@ export function createChart(
1926
414
  textEditCleanup = null;
1927
415
 
1928
416
  if (newText !== currentText) {
1929
- // Fire text edit callbacks
1930
417
  options?.onTextEdit?.(editRef, currentText, newText);
1931
418
  options?.onEdit?.({
1932
419
  type: 'text-edit',
@@ -1945,32 +432,24 @@ export function createChart(
1945
432
  textEditCleanup = overlay.destroy;
1946
433
  }
1947
434
 
1948
- /**
1949
- * Wire click-based selection events on the SVG.
1950
- * Uses event delegation for efficiency.
1951
- */
1952
435
  function wireSelectionEvents(): () => void {
1953
436
  if (!svgElement) return () => {};
1954
437
 
1955
438
  const svg = svgElement;
1956
439
  const cleanups: Array<() => void> = [];
1957
440
 
1958
- // Click handler for selection
1959
441
  const handleClick = (e: Event) => {
1960
442
  const mouseEvent = e as MouseEvent;
1961
443
  const target = mouseEvent.target as Element;
1962
444
 
1963
- // Don't interfere with text editing
1964
445
  if (isTextEditingActive) return;
1965
446
 
1966
447
  const specAnnotations = getSpecAnnotations();
1967
448
  const ref = buildElementRef(target, specAnnotations);
1968
449
 
1969
450
  if (ref) {
1970
- // Clicked on an editable element
1971
451
  selectElement(ref);
1972
452
  } else {
1973
- // Clicked on empty area / non-editable element, deselect
1974
453
  deselectElement();
1975
454
  }
1976
455
  };
@@ -1978,7 +457,6 @@ export function createChart(
1978
457
  svg.addEventListener('click', handleClick);
1979
458
  cleanups.push(() => svg.removeEventListener('click', handleClick));
1980
459
 
1981
- // Hover feedback on editable elements
1982
460
  const handleMouseEnter = (e: Event) => {
1983
461
  const target = (e.target as Element).closest(
1984
462
  '[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend, [data-legend-index]',
@@ -2002,7 +480,6 @@ export function createChart(
2002
480
  svg.removeEventListener('mouseleave', handleMouseLeave, true);
2003
481
  });
2004
482
 
2005
- // Double-click to enter text editing
2006
483
  const handleDblClick = (e: Event) => {
2007
484
  const mouseEvent = e as MouseEvent;
2008
485
  const target = mouseEvent.target as Element;
@@ -2010,7 +487,6 @@ export function createChart(
2010
487
  const ref = buildElementRef(target, specAnnotations);
2011
488
 
2012
489
  if (ref && isTextEditable(ref, specAnnotations)) {
2013
- // Select first if not already selected
2014
490
  if (!refsEqual(selectedElement, ref)) {
2015
491
  selectElement(ref);
2016
492
  }
@@ -2028,10 +504,6 @@ export function createChart(
2028
504
  };
2029
505
  }
2030
506
 
2031
- /**
2032
- * Wire keyboard events for edit actions on the SVG.
2033
- * Delete/Backspace -> delete, Escape -> cancel/deselect, Tab -> cycle, Enter -> text edit.
2034
- */
2035
507
  function wireKeyboardEditEvents(): () => void {
2036
508
  if (!svgElement) return () => {};
2037
509
 
@@ -2046,7 +518,6 @@ export function createChart(
2046
518
  if (selectedElement && !isTextEditingActive) {
2047
519
  e.preventDefault();
2048
520
  options?.onEdit?.({ type: 'delete', element: selectedElement });
2049
- // Stay selected (consumer decides whether to remove the element)
2050
521
  }
2051
522
  break;
2052
523
  }
@@ -2054,7 +525,6 @@ export function createChart(
2054
525
  case 'Escape': {
2055
526
  e.preventDefault();
2056
527
  if (isTextEditingActive && textEditCleanup) {
2057
- // Cancel text editing, remain selected
2058
528
  textEditCleanup();
2059
529
  textEditCleanup = null;
2060
530
  isTextEditingActive = false;
@@ -2113,8 +583,11 @@ export function createChart(
2113
583
  };
2114
584
  }
2115
585
 
586
+ // ---------------------------------------------------------------------------
587
+ // Render cycle
588
+ // ---------------------------------------------------------------------------
589
+
2116
590
  function render(): void {
2117
- // Defer re-render if a drag is in progress to avoid destroying the dragged element
2118
591
  if (isDragging) {
2119
592
  pendingRender = true;
2120
593
  return;
@@ -2128,42 +601,24 @@ export function createChart(
2128
601
  cancelAnimations(svgElement);
2129
602
 
2130
603
  // Clean up previous render
2131
- if (cleanupTooltipEvents) {
2132
- cleanupTooltipEvents();
2133
- cleanupTooltipEvents = null;
2134
- }
2135
- if (cleanupVoronoiEvents) {
2136
- cleanupVoronoiEvents();
2137
- cleanupVoronoiEvents = null;
2138
- }
2139
- if (cleanupKeyboardNav) {
2140
- cleanupKeyboardNav();
2141
- cleanupKeyboardNav = null;
2142
- }
2143
- if (cleanupLegend) {
2144
- cleanupLegend();
2145
- cleanupLegend = null;
2146
- }
2147
- if (cleanupChartEvents) {
2148
- cleanupChartEvents();
2149
- cleanupChartEvents = null;
2150
- }
2151
- if (cleanupAnnotationDrag) {
2152
- cleanupAnnotationDrag();
2153
- cleanupAnnotationDrag = null;
2154
- }
2155
- if (cleanupEditDrags) {
2156
- cleanupEditDrags();
2157
- cleanupEditDrags = null;
2158
- }
2159
- if (cleanupSelection) {
2160
- cleanupSelection();
2161
- cleanupSelection = null;
2162
- }
2163
- if (cleanupKeyboardEdit) {
2164
- cleanupKeyboardEdit();
2165
- cleanupKeyboardEdit = null;
2166
- }
604
+ cleanupTooltipEvents?.();
605
+ cleanupTooltipEvents = null;
606
+ cleanupVoronoiEvents?.();
607
+ cleanupVoronoiEvents = null;
608
+ cleanupKeyboardNav?.();
609
+ cleanupKeyboardNav = null;
610
+ cleanupLegend?.();
611
+ cleanupLegend = null;
612
+ cleanupChartEvents?.();
613
+ cleanupChartEvents = null;
614
+ cleanupAnnotationDrag?.();
615
+ cleanupAnnotationDrag = null;
616
+ cleanupEditDrags?.();
617
+ cleanupEditDrags = null;
618
+ cleanupSelection?.();
619
+ cleanupSelection = null;
620
+ cleanupKeyboardEdit?.();
621
+ cleanupKeyboardEdit = null;
2167
622
  if (textEditCleanup) {
2168
623
  textEditCleanup();
2169
624
  textEditCleanup = null;
@@ -2183,8 +638,6 @@ export function createChart(
2183
638
 
2184
639
  currentLayout = compile();
2185
640
  const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
2186
- // Crosshair is resolved by the engine (handles sparkline default-off,
2187
- // user-explicit precedence, breakpoint overrides). Mount just reads it.
2188
641
  const crosshair = !!currentLayout.crosshair;
2189
642
  svgElement = renderChartSVG(currentLayout, container, {
2190
643
  animate: shouldAnimate,
@@ -2215,6 +668,7 @@ export function createChart(
2215
668
  cleanupLegend = wireLegendInteraction(
2216
669
  svgElement,
2217
670
  currentLayout,
671
+ toggleSeriesVisibility,
2218
672
  options?.onLegendToggle,
2219
673
  options?.onEdit,
2220
674
  );
@@ -2226,7 +680,7 @@ export function createChart(
2226
680
  options?.onMarkLeave ||
2227
681
  options?.onAnnotationClick
2228
682
  ) {
2229
- const specAnnotations: import('@opendata-ai/openchart-core').Annotation[] =
683
+ const specAnnotations: Annotation[] =
2230
684
  'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
2231
685
  ? currentSpec.annotations
2232
686
  : [];
@@ -2242,13 +696,12 @@ export function createChart(
2242
696
  }
2243
697
  };
2244
698
 
2245
- // Shared annotation list for drag handlers (computed once)
2246
699
  const dragAnnotations: Annotation[] =
2247
700
  'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
2248
701
  ? currentSpec.annotations
2249
702
  : [];
2250
703
 
2251
- // Wire annotation drag editing (activates when onAnnotationEdit or onEdit is provided)
704
+ // Wire annotation drag editing
2252
705
  if (options?.onAnnotationEdit || options?.onEdit) {
2253
706
  cleanupAnnotationDrag = wireAnnotationDrag(
2254
707
  svgElement,
@@ -2263,24 +716,16 @@ export function createChart(
2263
716
  if (options?.onEdit) {
2264
717
  const editCleanups: Array<() => void> = [];
2265
718
 
2266
- // Connector endpoint drag
2267
719
  editCleanups.push(
2268
720
  wireConnectorEndpointDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
2269
721
  );
2270
-
2271
- // Range/refline annotation label drag
2272
722
  editCleanups.push(
2273
723
  wireAnnotationLabelDrag(svgElement, dragAnnotations, options.onEdit, setDragging),
2274
724
  );
2275
725
 
2276
- // Chrome text drag
2277
726
  const editSpec = currentSpec as ChartSpec | GraphSpec;
2278
727
  editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
2279
-
2280
- // Legend drag
2281
728
  editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
2282
-
2283
- // Series label drag
2284
729
  editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
2285
730
 
2286
731
  cleanupEditDrags = () => {
@@ -2296,13 +741,11 @@ export function createChart(
2296
741
  cleanupSelection = wireSelectionEvents();
2297
742
  cleanupKeyboardEdit = wireKeyboardEditEvents();
2298
743
 
2299
- // Restore selection overlay after re-render
2300
744
  if (selectedElement) {
2301
745
  const target = findElementByRef(svgElement, selectedElement);
2302
746
  if (target) {
2303
747
  overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
2304
748
  } else {
2305
- // Element no longer exists in DOM, clear selection silently
2306
749
  selectedElement = null;
2307
750
  overlayElement = null;
2308
751
  }
@@ -2321,10 +764,7 @@ export function createChart(
2321
764
  container.classList.remove('oc-dark');
2322
765
  }
2323
766
 
2324
- // Set up animation cleanup on first render only.
2325
- // onComplete fires when animations finish naturally (not on cancellation/destroy).
2326
- // It nulls out cleanupAnimations so resizes work after the animation window,
2327
- // and replays any resize that was skipped mid-animation.
767
+ // Set up animation cleanup on first render only
2328
768
  if (shouldAnimate && svgElement) {
2329
769
  cleanupAnimations = setupAnimationCleanup(svgElement, () => {
2330
770
  cleanupAnimations = null;
@@ -2339,9 +779,15 @@ export function createChart(
2339
779
  }
2340
780
  }
2341
781
 
782
+ // ---------------------------------------------------------------------------
783
+ // Public API methods
784
+ // ---------------------------------------------------------------------------
785
+
2342
786
  function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
2343
787
  if (destroyed) return;
2344
788
  currentSpec = newSpec;
789
+ runtimeHiddenSeries.clear();
790
+ runtimeShownSeries.clear();
2345
791
  if (updateOpts && 'selectedElement' in updateOpts) {
2346
792
  selectedElement = updateOpts.selectedElement ?? null;
2347
793
  }
@@ -2350,10 +796,6 @@ export function createChart(
2350
796
 
2351
797
  function resize(): void {
2352
798
  if (destroyed) return;
2353
- // Skip resize during entrance animation. The resize observer fires
2354
- // immediately when the container first enters DOM layout, and re-rendering
2355
- // would destroy the animated SVG. Resizes during this window are queued
2356
- // and replayed once the animation completes via the onComplete callback.
2357
799
  if (cleanupAnimations) {
2358
800
  pendingResize = true;
2359
801
  return;
@@ -2396,7 +838,6 @@ export function createChart(
2396
838
  if (destroyed) return;
2397
839
  destroyed = true;
2398
840
 
2399
- // Cancel entrance animations (cancellation does not fire onComplete)
2400
841
  if (cleanupAnimations) {
2401
842
  cleanupAnimations();
2402
843
  cleanupAnimations = null;
@@ -2404,42 +845,24 @@ export function createChart(
2404
845
  }
2405
846
  cancelAnimations(svgElement);
2406
847
 
2407
- if (cleanupTooltipEvents) {
2408
- cleanupTooltipEvents();
2409
- cleanupTooltipEvents = null;
2410
- }
2411
- if (cleanupVoronoiEvents) {
2412
- cleanupVoronoiEvents();
2413
- cleanupVoronoiEvents = null;
2414
- }
2415
- if (cleanupKeyboardNav) {
2416
- cleanupKeyboardNav();
2417
- cleanupKeyboardNav = null;
2418
- }
2419
- if (cleanupLegend) {
2420
- cleanupLegend();
2421
- cleanupLegend = null;
2422
- }
2423
- if (cleanupChartEvents) {
2424
- cleanupChartEvents();
2425
- cleanupChartEvents = null;
2426
- }
2427
- if (cleanupAnnotationDrag) {
2428
- cleanupAnnotationDrag();
2429
- cleanupAnnotationDrag = null;
2430
- }
2431
- if (cleanupEditDrags) {
2432
- cleanupEditDrags();
2433
- cleanupEditDrags = null;
2434
- }
2435
- if (cleanupSelection) {
2436
- cleanupSelection();
2437
- cleanupSelection = null;
2438
- }
2439
- if (cleanupKeyboardEdit) {
2440
- cleanupKeyboardEdit();
2441
- cleanupKeyboardEdit = null;
2442
- }
848
+ cleanupTooltipEvents?.();
849
+ cleanupTooltipEvents = null;
850
+ cleanupVoronoiEvents?.();
851
+ cleanupVoronoiEvents = null;
852
+ cleanupKeyboardNav?.();
853
+ cleanupKeyboardNav = null;
854
+ cleanupLegend?.();
855
+ cleanupLegend = null;
856
+ cleanupChartEvents?.();
857
+ cleanupChartEvents = null;
858
+ cleanupAnnotationDrag?.();
859
+ cleanupAnnotationDrag = null;
860
+ cleanupEditDrags?.();
861
+ cleanupEditDrags = null;
862
+ cleanupSelection?.();
863
+ cleanupSelection = null;
864
+ cleanupKeyboardEdit?.();
865
+ cleanupKeyboardEdit = null;
2443
866
  if (textEditCleanup) {
2444
867
  textEditCleanup();
2445
868
  textEditCleanup = null;
@@ -2470,9 +893,7 @@ export function createChart(
2470
893
  // Initial render
2471
894
  render();
2472
895
 
2473
- // Set up responsive resize. The observeResize helper already debounces at
2474
- // ~60fps (16ms) internally, which is sufficient to coalesce a drag-burst
2475
- // into a single render without additive delay.
896
+ // Set up responsive resize
2476
897
  if (options?.responsive !== false) {
2477
898
  disconnectResize = observeResize(container, () => {
2478
899
  resize();