@opendata-ai/openchart-vanilla 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mount.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  CompileOptions,
18
18
  DarkMode,
19
19
  ElementEdit,
20
+ ElementRef,
20
21
  GraphSpec,
21
22
  LayerSpec,
22
23
  MeasureTextFn,
@@ -26,8 +27,9 @@ import type {
26
27
  ThemeConfig,
27
28
  TooltipContent,
28
29
  } from '@opendata-ai/openchart-core';
29
- import { isLayerSpec } from '@opendata-ai/openchart-core';
30
+ import { elementRef, isLayerSpec } from '@opendata-ai/openchart-core';
30
31
  import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
32
+ import { cancelAnimations, setupAnimationCleanup } from './animation';
31
33
  import {
32
34
  exportCSV,
33
35
  exportJPG,
@@ -39,6 +41,7 @@ import {
39
41
  } from './export';
40
42
  import { observeResize } from './resize-observer';
41
43
  import { renderChartSVG } from './svg-renderer';
44
+ import { createTextEditOverlay } from './text-edit-overlay';
42
45
  import { createTooltipManager, type TooltipManager } from './tooltip';
43
46
 
44
47
  // ---------------------------------------------------------------------------
@@ -54,6 +57,13 @@ export interface MountOptions extends ChartEventHandlers {
54
57
  onDataPointClick?: (data: Record<string, unknown>) => void;
55
58
  /** Enable responsive resizing. Defaults to true. */
56
59
  responsive?: boolean;
60
+ /** Initial selected element. */
61
+ selectedElement?: ElementRef;
62
+ }
63
+
64
+ export interface UpdateOptions {
65
+ /** Override the selected element after update. When omitted, preserves current selection. */
66
+ selectedElement?: ElementRef;
57
67
  }
58
68
 
59
69
  export interface ExportOptions extends JPGExportOptions {
@@ -62,7 +72,7 @@ export interface ExportOptions extends JPGExportOptions {
62
72
 
63
73
  export interface ChartInstance {
64
74
  /** Re-compile and re-render with a new spec. */
65
- update(spec: ChartSpec | LayerSpec | GraphSpec): void;
75
+ update(spec: ChartSpec | LayerSpec | GraphSpec, options?: UpdateOptions): void;
66
76
  /** Re-compile at current container dimensions. */
67
77
  resize(): void;
68
78
  /** Export the chart. */
@@ -79,6 +89,14 @@ export interface ChartInstance {
79
89
  destroy(): void;
80
90
  /** The current compiled layout (for hooks / debugging). */
81
91
  readonly layout: ChartLayout;
92
+ /** Get the currently selected element, or null if none. */
93
+ getSelectedElement(): ElementRef | null;
94
+ /** Programmatically select an element. Silent no-op if element not found. */
95
+ select(ref: ElementRef): void;
96
+ /** Deselect the current element. */
97
+ deselect(): void;
98
+ /** Whether inline text editing is active. */
99
+ readonly isEditing: boolean;
82
100
  }
83
101
 
84
102
  // ---------------------------------------------------------------------------
@@ -460,7 +478,7 @@ function wireChartEvents(
460
478
 
461
479
  // Wire annotation click events
462
480
  if (handlers.onAnnotationClick) {
463
- const annotationElements = svg.querySelectorAll('.viz-annotation');
481
+ const annotationElements = svg.querySelectorAll('.oc-annotation');
464
482
 
465
483
  for (let i = 0; i < annotationElements.length; i++) {
466
484
  const el = annotationElements[i];
@@ -696,7 +714,7 @@ function wireAnnotationDrag(
696
714
  onEdit: ((edit: ElementEdit) => void) | undefined,
697
715
  setDragging: (dragging: boolean) => void,
698
716
  ): () => void {
699
- const annotationElements = svg.querySelectorAll('.viz-annotation-text');
717
+ const annotationElements = svg.querySelectorAll('.oc-annotation-text');
700
718
  const cleanups: Array<() => void> = [];
701
719
 
702
720
  for (const el of annotationElements) {
@@ -714,13 +732,13 @@ function wireAnnotationDrag(
714
732
  annotationG.style.cursor = 'grab';
715
733
 
716
734
  // Stash connector info for real-time updates during drag
717
- const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
735
+ const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
718
736
  const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
719
737
  const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
720
738
 
721
739
  // For curved connectors, stash path/polygon elements to hide during drag
722
- const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
723
- const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
740
+ const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
741
+ const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
724
742
  const hasCurvedConnector = curvedPath !== null;
725
743
 
726
744
  const origDx = textAnnotation.offset?.dx ?? 0;
@@ -807,7 +825,7 @@ function wireConnectorEndpointDrag(
807
825
  ): () => void {
808
826
  const SVG_NS = 'http://www.w3.org/2000/svg';
809
827
  const cleanups: Array<() => void> = [];
810
- const annotationGroups = svg.querySelectorAll('.viz-annotation-text');
828
+ const annotationGroups = svg.querySelectorAll('.oc-annotation-text');
811
829
 
812
830
  for (const el of annotationGroups) {
813
831
  const annotationG = el as SVGGElement;
@@ -821,8 +839,8 @@ function wireConnectorEndpointDrag(
821
839
  const textAnnotation = specAnnotation as TextAnnotation;
822
840
 
823
841
  // Find connector line or curved connector to determine endpoints
824
- const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
825
- const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
842
+ const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
843
+ const curvedPath = annotationG.querySelector('path.oc-annotation-connector');
826
844
  if (!connectorLine && !curvedPath) continue;
827
845
 
828
846
  // Determine connector endpoint positions from the connector element
@@ -840,7 +858,7 @@ function wireConnectorEndpointDrag(
840
858
  fromX = mMatch ? Number(mMatch[1]) : 0;
841
859
  fromY = mMatch ? Number(mMatch[2]) : 0;
842
860
  // For curved connectors, the arrow polygon has the target
843
- const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
861
+ const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
844
862
  const points = arrowhead?.getAttribute('points') ?? '';
845
863
  const firstPoint = points.split(' ')[0] ?? '0,0';
846
864
  const [px, py] = firstPoint.split(',');
@@ -861,7 +879,7 @@ function wireConnectorEndpointDrag(
861
879
  if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
862
880
 
863
881
  const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
864
- handleEl.setAttribute('class', 'viz-connector-handle');
882
+ handleEl.setAttribute('class', 'oc-connector-handle');
865
883
  handleEl.setAttribute('data-endpoint', ep.name);
866
884
  handleEl.setAttribute('cx', String(ep.cx));
867
885
  handleEl.setAttribute('cy', String(ep.cy));
@@ -985,15 +1003,15 @@ function wireAnnotationLabelDrag(
985
1003
 
986
1004
  // Target range and refline annotation labels
987
1005
  const selectors = [
988
- '.viz-annotation-range .viz-annotation-label',
989
- '.viz-annotation-refline .viz-annotation-label',
1006
+ '.oc-annotation-range .oc-annotation-label',
1007
+ '.oc-annotation-refline .oc-annotation-label',
990
1008
  ];
991
1009
 
992
1010
  for (const selector of selectors) {
993
1011
  const labels = svg.querySelectorAll(selector);
994
1012
 
995
1013
  for (const label of labels) {
996
- const annotationG = label.closest('.viz-annotation') as SVGGElement | null;
1014
+ const annotationG = label.closest('.oc-annotation') as SVGGElement | null;
997
1015
  if (!annotationG) continue;
998
1016
 
999
1017
  const indexStr = annotationG.getAttribute('data-annotation-index');
@@ -1068,7 +1086,7 @@ function wireChromeDrag(
1068
1086
  onEdit: (edit: ElementEdit) => void,
1069
1087
  setDragging: (dragging: boolean) => void,
1070
1088
  ): () => void {
1071
- const chromeTexts = svg.querySelectorAll('.viz-chrome text[data-chrome-key]');
1089
+ const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]');
1072
1090
  const cleanups: Array<() => void> = [];
1073
1091
 
1074
1092
  // Read existing chrome offsets from the spec
@@ -1136,7 +1154,7 @@ function wireLegendDrag(
1136
1154
  onEdit: (edit: ElementEdit) => void,
1137
1155
  setDragging: (dragging: boolean) => void,
1138
1156
  ): () => void {
1139
- const legendG = svg.querySelector('.viz-legend') as SVGGElement | null;
1157
+ const legendG = svg.querySelector('.oc-legend') as SVGGElement | null;
1140
1158
  if (!legendG) return () => {};
1141
1159
 
1142
1160
  const cleanups: Array<() => void> = [];
@@ -1180,7 +1198,7 @@ function wireLegendDrag(
1180
1198
  // ---------------------------------------------------------------------------
1181
1199
 
1182
1200
  /**
1183
- * Wire drag on series label elements (.viz-mark-label[data-series]).
1201
+ * Wire drag on series label elements (.oc-mark-label[data-series]).
1184
1202
  * On drag end, fires onEdit with the series name and offset.
1185
1203
  * Returns a cleanup function.
1186
1204
  */
@@ -1190,7 +1208,7 @@ function wireSeriesLabelDrag(
1190
1208
  onEdit: (edit: ElementEdit) => void,
1191
1209
  setDragging: (dragging: boolean) => void,
1192
1210
  ): () => void {
1193
- const labels = svg.querySelectorAll('.viz-mark-label');
1211
+ const labels = svg.querySelectorAll('.oc-mark-label');
1194
1212
  const cleanups: Array<() => void> = [];
1195
1213
 
1196
1214
  // Read existing label offsets from the spec
@@ -1290,7 +1308,7 @@ function wireLegendInteraction(
1290
1308
  // Toggle visibility of marks with matching series.
1291
1309
  // Uses the data-series attribute set by the SVG renderer, which works
1292
1310
  // for all mark types (line, area, rect, arc, point).
1293
- const marks = svg.querySelectorAll('.viz-mark');
1311
+ const marks = svg.querySelectorAll('.oc-mark');
1294
1312
  for (const mark of marks) {
1295
1313
  const seriesName = mark.getAttribute('data-series');
1296
1314
  if (!seriesName) continue;
@@ -1350,7 +1368,7 @@ function wireKeyboardNav(
1350
1368
  function highlightMark(index: number): void {
1351
1369
  // Remove previous highlight
1352
1370
  if (focusIndex >= 0 && focusIndex < markElements.length) {
1353
- markElements[focusIndex].classList.remove('viz-mark-focused');
1371
+ markElements[focusIndex].classList.remove('oc-mark-focused');
1354
1372
  markElements[focusIndex].removeAttribute('aria-selected');
1355
1373
  }
1356
1374
 
@@ -1358,7 +1376,7 @@ function wireKeyboardNav(
1358
1376
 
1359
1377
  if (focusIndex >= 0 && focusIndex < markElements.length) {
1360
1378
  const el = markElements[focusIndex];
1361
- el.classList.add('viz-mark-focused');
1379
+ el.classList.add('oc-mark-focused');
1362
1380
  el.setAttribute('aria-selected', 'true');
1363
1381
  }
1364
1382
  }
@@ -1447,7 +1465,7 @@ function createScreenReaderTable(
1447
1465
  if (!data || data.length === 0) return null;
1448
1466
 
1449
1467
  const table = document.createElement('table');
1450
- table.className = 'viz-sr-only';
1468
+ table.className = 'oc-sr-only';
1451
1469
  // Inline critical SR-only styles so the table stays hidden even when the
1452
1470
  // external stylesheet isn't loaded (e.g. CDN / esm.sh usage).
1453
1471
  table.style.position = 'absolute';
@@ -1497,6 +1515,252 @@ function createScreenReaderTable(
1497
1515
  return table;
1498
1516
  }
1499
1517
 
1518
+ // ---------------------------------------------------------------------------
1519
+ // Editable element helpers
1520
+ // ---------------------------------------------------------------------------
1521
+
1522
+ /** CSS for editable hover feedback, injected into the SVG as a <style> element. */
1523
+ const EDITABLE_HOVER_CSS = `
1524
+ .oc-editable-hover {
1525
+ outline: 1.5px solid rgba(79, 70, 229, 0.35);
1526
+ outline-offset: 2px;
1527
+ border-radius: 2px;
1528
+ }
1529
+ `;
1530
+
1531
+ /**
1532
+ * Inject editable styles into an SVG element and make it focusable.
1533
+ * Called when any editing callback is provided.
1534
+ */
1535
+ function makeEditable(svg: SVGElement): void {
1536
+ svg.setAttribute('tabindex', '0');
1537
+ svg.style.outline = 'none';
1538
+
1539
+ // Inject hover style into SVG defs
1540
+ const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1541
+ style.textContent = EDITABLE_HOVER_CSS;
1542
+ svg.insertBefore(style, svg.firstChild);
1543
+ }
1544
+
1545
+ /**
1546
+ * Check whether any editing-related callback is provided in the options.
1547
+ */
1548
+ function hasEditingCallbacks(opts?: MountOptions): boolean {
1549
+ return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
1550
+ }
1551
+
1552
+ /**
1553
+ * Find a DOM element inside the SVG that matches the given ElementRef.
1554
+ */
1555
+ function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
1556
+ switch (ref.type) {
1557
+ case 'annotation': {
1558
+ // Prefer id-based lookup when available
1559
+ if (ref.id) {
1560
+ const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
1561
+ if (byId) return byId as SVGElement;
1562
+ }
1563
+ return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
1564
+ }
1565
+ case 'chrome':
1566
+ return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
1567
+ case 'series-label':
1568
+ return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
1569
+ case 'legend':
1570
+ return svg.querySelector('.oc-legend') as SVGElement | null;
1571
+ case 'legend-entry':
1572
+ return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
1573
+ }
1574
+ }
1575
+
1576
+ /**
1577
+ * Build an ElementRef from a DOM element's data attributes.
1578
+ * Walks up the tree to find the closest editable ancestor if needed.
1579
+ */
1580
+ function buildElementRef(element: Element, _specAnnotations: Annotation[]): ElementRef | null {
1581
+ // Check for annotation
1582
+ const annotationEl = element.closest('[data-annotation-index]');
1583
+ if (annotationEl) {
1584
+ const index = Number(annotationEl.getAttribute('data-annotation-index'));
1585
+ const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
1586
+ return elementRef.annotation(index, id);
1587
+ }
1588
+
1589
+ // Check for chrome
1590
+ const chromeEl = element.closest('[data-chrome-key]');
1591
+ if (chromeEl) {
1592
+ const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
1593
+ if (key) return elementRef.chrome(key);
1594
+ }
1595
+
1596
+ // Check for series label
1597
+ const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
1598
+ if (seriesLabelEl) {
1599
+ const series = seriesLabelEl.getAttribute('data-series');
1600
+ if (series) return elementRef.seriesLabel(series);
1601
+ }
1602
+
1603
+ // Check for legend entry
1604
+ const legendEntryEl = element.closest('[data-legend-index]');
1605
+ if (legendEntryEl) {
1606
+ const index = Number(legendEntryEl.getAttribute('data-legend-index'));
1607
+ const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
1608
+ return elementRef.legendEntry(series, index);
1609
+ }
1610
+
1611
+ // Check for legend group
1612
+ const legendEl = element.closest('.oc-legend');
1613
+ if (legendEl) return elementRef.legend();
1614
+
1615
+ return null;
1616
+ }
1617
+
1618
+ /**
1619
+ * Get an ordered list of all editable ElementRefs from the current spec and layout.
1620
+ * Order: chrome (title, subtitle, source, byline, footer), annotations by index,
1621
+ * series labels alphabetical, legend.
1622
+ */
1623
+ function getEditableElements(
1624
+ spec: ChartSpec | LayerSpec | GraphSpec,
1625
+ layout: ChartLayout,
1626
+ ): ElementRef[] {
1627
+ const refs: ElementRef[] = [];
1628
+
1629
+ // Chrome keys in display order
1630
+ const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
1631
+ for (const key of chromeKeys) {
1632
+ if (layout.chrome[key]) {
1633
+ refs.push(elementRef.chrome(key));
1634
+ }
1635
+ }
1636
+
1637
+ // Annotations by index
1638
+ const annotations: Annotation[] =
1639
+ 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
1640
+ for (let i = 0; i < annotations.length; i++) {
1641
+ refs.push(elementRef.annotation(i, annotations[i].id));
1642
+ }
1643
+
1644
+ // Series labels (alphabetical)
1645
+ const seriesLabels: string[] = [];
1646
+ for (const mark of layout.marks) {
1647
+ if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
1648
+ seriesLabels.push(mark.seriesKey);
1649
+ }
1650
+ }
1651
+ seriesLabels.sort();
1652
+ for (const series of seriesLabels) {
1653
+ refs.push(elementRef.seriesLabel(series));
1654
+ }
1655
+
1656
+ // Legend
1657
+ if (layout.legend.entries.length > 0) {
1658
+ refs.push(elementRef.legend());
1659
+ }
1660
+
1661
+ return refs;
1662
+ }
1663
+
1664
+ /**
1665
+ * Check if an ElementRef points to a text-editable element (chrome text or text annotation).
1666
+ */
1667
+ function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
1668
+ if (ref.type === 'chrome') return true;
1669
+ if (ref.type === 'annotation') {
1670
+ const annotation = specAnnotations[ref.index];
1671
+ return annotation?.type === 'text';
1672
+ }
1673
+ return false;
1674
+ }
1675
+
1676
+ /**
1677
+ * Get the current text content for an element ref.
1678
+ */
1679
+ function getElementText(ref: ElementRef, spec: ChartSpec | LayerSpec | GraphSpec): string | null {
1680
+ if (ref.type === 'chrome') {
1681
+ const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
1682
+ if (!chromeConfig) return null;
1683
+ const entry = chromeConfig[ref.key];
1684
+ if (typeof entry === 'string') return entry;
1685
+ if (typeof entry === 'object' && entry !== null && 'text' in entry) {
1686
+ return (entry as { text: string }).text;
1687
+ }
1688
+ return null;
1689
+ }
1690
+ if (ref.type === 'annotation') {
1691
+ const annotations: Annotation[] =
1692
+ 'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
1693
+ const annotation = annotations[ref.index];
1694
+ if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
1695
+ if (annotation?.label) return annotation.label;
1696
+ return null;
1697
+ }
1698
+ return null;
1699
+ }
1700
+
1701
+ /**
1702
+ * Compare two ElementRefs for equality.
1703
+ */
1704
+ function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
1705
+ if (a === null || b === null) return a === b;
1706
+ if (a.type !== b.type) return false;
1707
+ switch (a.type) {
1708
+ case 'annotation': {
1709
+ const bAnno = b as typeof a;
1710
+ if (a.id && bAnno.id) return a.id === bAnno.id;
1711
+ return a.index === bAnno.index;
1712
+ }
1713
+ case 'chrome':
1714
+ return a.key === (b as typeof a).key;
1715
+ case 'series-label':
1716
+ return a.series === (b as typeof a).series;
1717
+ case 'legend':
1718
+ return true;
1719
+ case 'legend-entry': {
1720
+ const bEntry = b as typeof a;
1721
+ return a.index === bEntry.index && a.series === bEntry.series;
1722
+ }
1723
+ }
1724
+ }
1725
+
1726
+ /**
1727
+ * Render a selection overlay rectangle around a target element.
1728
+ * Returns the overlay group element.
1729
+ */
1730
+ function renderSelectionOverlay(
1731
+ svg: SVGElement,
1732
+ ref: ElementRef,
1733
+ layout: ChartLayout,
1734
+ ): SVGGElement | null {
1735
+ const target = findElementByRef(svg, ref);
1736
+ if (!target) return null;
1737
+
1738
+ const bbox = (target as SVGGraphicsElement).getBBox();
1739
+ const padding = 4;
1740
+
1741
+ // Resolve accent color from theme
1742
+ const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
1743
+
1744
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1745
+ g.setAttribute('class', 'oc-selection-overlay');
1746
+
1747
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1748
+ rect.setAttribute('x', String(bbox.x - padding));
1749
+ rect.setAttribute('y', String(bbox.y - padding));
1750
+ rect.setAttribute('width', String(bbox.width + padding * 2));
1751
+ rect.setAttribute('height', String(bbox.height + padding * 2));
1752
+ rect.setAttribute('rx', '3');
1753
+ rect.setAttribute('fill', 'transparent');
1754
+ rect.setAttribute('stroke', accentColor);
1755
+ rect.setAttribute('stroke-width', '1.5');
1756
+ rect.setAttribute('pointer-events', 'none');
1757
+
1758
+ g.appendChild(rect);
1759
+ svg.appendChild(g);
1760
+
1761
+ return g;
1762
+ }
1763
+
1500
1764
  // ---------------------------------------------------------------------------
1501
1765
  // Main API
1502
1766
  // ---------------------------------------------------------------------------
@@ -1526,12 +1790,24 @@ export function createChart(
1526
1790
  let cleanupChartEvents: (() => void) | null = null;
1527
1791
  let cleanupAnnotationDrag: (() => void) | null = null;
1528
1792
  let cleanupEditDrags: (() => void) | null = null;
1793
+ let cleanupSelection: (() => void) | null = null;
1794
+ let cleanupKeyboardEdit: (() => void) | null = null;
1529
1795
  let srTable: HTMLTableElement | null = null;
1530
1796
  let destroyed = false;
1531
1797
  let isDragging = false;
1532
1798
  let pendingRender = false;
1533
1799
  let resizeTimer: ReturnType<typeof setTimeout> | null = null;
1534
1800
 
1801
+ // Animation state
1802
+ let isFirstRender = true;
1803
+ let cleanupAnimations: (() => void) | null = null;
1804
+
1805
+ // Selection and text editing state
1806
+ let selectedElement: ElementRef | null = options?.selectedElement ?? null;
1807
+ let overlayElement: SVGGElement | null = null;
1808
+ let isTextEditingActive = false;
1809
+ let textEditCleanup: (() => void) | null = null;
1810
+
1535
1811
  const measureText = createMeasureText();
1536
1812
 
1537
1813
  function compile(): ChartLayout {
@@ -1560,6 +1836,274 @@ export function createChart(
1560
1836
  };
1561
1837
  }
1562
1838
 
1839
+ /** Get the current spec's annotations array. */
1840
+ function getSpecAnnotations(): Annotation[] {
1841
+ return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
1842
+ ? currentSpec.annotations
1843
+ : [];
1844
+ }
1845
+
1846
+ /** Select an element: render overlay, fire onSelect, update state. */
1847
+ function selectElement(ref: ElementRef): void {
1848
+ if (!svgElement) return;
1849
+
1850
+ // Confirm the target element exists before deselecting the previous one
1851
+ const target = findElementByRef(svgElement, ref);
1852
+ if (!target) return;
1853
+
1854
+ // Deselect previous if different
1855
+ if (selectedElement && !refsEqual(selectedElement, ref)) {
1856
+ deselectElement();
1857
+ }
1858
+
1859
+ selectedElement = ref;
1860
+ overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
1861
+ options?.onSelect?.(ref);
1862
+
1863
+ // Focus SVG for keyboard events
1864
+ (svgElement as SVGSVGElement).focus();
1865
+ }
1866
+
1867
+ /** Deselect the current element: remove overlay, fire onDeselect, clear state. */
1868
+ function deselectElement(): void {
1869
+ if (!selectedElement) return;
1870
+
1871
+ // Cancel text editing if active
1872
+ if (isTextEditingActive && textEditCleanup) {
1873
+ textEditCleanup();
1874
+ textEditCleanup = null;
1875
+ isTextEditingActive = false;
1876
+ }
1877
+
1878
+ const prev = selectedElement;
1879
+ selectedElement = null;
1880
+
1881
+ if (overlayElement?.parentNode) {
1882
+ overlayElement.parentNode.removeChild(overlayElement);
1883
+ }
1884
+ overlayElement = null;
1885
+
1886
+ options?.onDeselect?.(prev);
1887
+ }
1888
+
1889
+ /** Enter text editing mode for the currently selected element. */
1890
+ function enterTextEditing(): void {
1891
+ if (!svgElement || !selectedElement || isTextEditingActive) return;
1892
+
1893
+ const specAnnotations = getSpecAnnotations();
1894
+ if (!isTextEditable(selectedElement, specAnnotations)) return;
1895
+
1896
+ const currentText = getElementText(selectedElement, currentSpec);
1897
+ if (currentText === null) return;
1898
+
1899
+ // Find the text element within the selected element
1900
+ const target = findElementByRef(svgElement, selectedElement);
1901
+ if (!target) return;
1902
+
1903
+ // The target might be a group; find the actual text element
1904
+ const textEl = target.tagName === 'text' ? target : target.querySelector('text');
1905
+ if (!textEl) return;
1906
+
1907
+ isTextEditingActive = true;
1908
+ const editRef = selectedElement;
1909
+
1910
+ const overlay = createTextEditOverlay({
1911
+ container,
1912
+ svg: svgElement as SVGSVGElement,
1913
+ targetElement: textEl as SVGElement,
1914
+ currentText,
1915
+ onCommit: (newText: string) => {
1916
+ isTextEditingActive = false;
1917
+ textEditCleanup = null;
1918
+
1919
+ if (newText !== currentText) {
1920
+ // Fire text edit callbacks
1921
+ options?.onTextEdit?.(editRef, currentText, newText);
1922
+ options?.onEdit?.({
1923
+ type: 'text-edit',
1924
+ element: editRef,
1925
+ oldText: currentText,
1926
+ newText,
1927
+ });
1928
+ }
1929
+ },
1930
+ onCancel: () => {
1931
+ isTextEditingActive = false;
1932
+ textEditCleanup = null;
1933
+ },
1934
+ });
1935
+
1936
+ textEditCleanup = overlay.destroy;
1937
+ }
1938
+
1939
+ /**
1940
+ * Wire click-based selection events on the SVG.
1941
+ * Uses event delegation for efficiency.
1942
+ */
1943
+ function wireSelectionEvents(): () => void {
1944
+ if (!svgElement) return () => {};
1945
+
1946
+ const svg = svgElement;
1947
+ const cleanups: Array<() => void> = [];
1948
+
1949
+ // Click handler for selection
1950
+ const handleClick = (e: Event) => {
1951
+ const mouseEvent = e as MouseEvent;
1952
+ const target = mouseEvent.target as Element;
1953
+
1954
+ // Don't interfere with text editing
1955
+ if (isTextEditingActive) return;
1956
+
1957
+ const specAnnotations = getSpecAnnotations();
1958
+ const ref = buildElementRef(target, specAnnotations);
1959
+
1960
+ if (ref) {
1961
+ // Clicked on an editable element
1962
+ selectElement(ref);
1963
+ } else {
1964
+ // Clicked on empty area / non-editable element, deselect
1965
+ deselectElement();
1966
+ }
1967
+ };
1968
+
1969
+ svg.addEventListener('click', handleClick);
1970
+ cleanups.push(() => svg.removeEventListener('click', handleClick));
1971
+
1972
+ // Hover feedback on editable elements
1973
+ const handleMouseEnter = (e: Event) => {
1974
+ const target = (e.target as Element).closest(
1975
+ '[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend, [data-legend-index]',
1976
+ );
1977
+ if (target) {
1978
+ (target as SVGElement).classList.add('oc-editable-hover');
1979
+ }
1980
+ };
1981
+
1982
+ const handleMouseLeave = (e: Event) => {
1983
+ const target = (e.target as Element).closest('.oc-editable-hover');
1984
+ if (target) {
1985
+ (target as SVGElement).classList.remove('oc-editable-hover');
1986
+ }
1987
+ };
1988
+
1989
+ svg.addEventListener('mouseenter', handleMouseEnter, true);
1990
+ svg.addEventListener('mouseleave', handleMouseLeave, true);
1991
+ cleanups.push(() => {
1992
+ svg.removeEventListener('mouseenter', handleMouseEnter, true);
1993
+ svg.removeEventListener('mouseleave', handleMouseLeave, true);
1994
+ });
1995
+
1996
+ // Double-click to enter text editing
1997
+ const handleDblClick = (e: Event) => {
1998
+ const mouseEvent = e as MouseEvent;
1999
+ const target = mouseEvent.target as Element;
2000
+ const specAnnotations = getSpecAnnotations();
2001
+ const ref = buildElementRef(target, specAnnotations);
2002
+
2003
+ if (ref && isTextEditable(ref, specAnnotations)) {
2004
+ // Select first if not already selected
2005
+ if (!refsEqual(selectedElement, ref)) {
2006
+ selectElement(ref);
2007
+ }
2008
+ enterTextEditing();
2009
+ }
2010
+ };
2011
+
2012
+ svg.addEventListener('dblclick', handleDblClick);
2013
+ cleanups.push(() => svg.removeEventListener('dblclick', handleDblClick));
2014
+
2015
+ return () => {
2016
+ for (const cleanup of cleanups) {
2017
+ cleanup();
2018
+ }
2019
+ };
2020
+ }
2021
+
2022
+ /**
2023
+ * Wire keyboard events for edit actions on the SVG.
2024
+ * Delete/Backspace -> delete, Escape -> cancel/deselect, Tab -> cycle, Enter -> text edit.
2025
+ */
2026
+ function wireKeyboardEditEvents(): () => void {
2027
+ if (!svgElement) return () => {};
2028
+
2029
+ const svg = svgElement;
2030
+
2031
+ const handleKeyDown = (e: KeyboardEvent) => {
2032
+ const specAnnotations = getSpecAnnotations();
2033
+
2034
+ switch (e.key) {
2035
+ case 'Delete':
2036
+ case 'Backspace': {
2037
+ if (selectedElement && !isTextEditingActive) {
2038
+ e.preventDefault();
2039
+ options?.onEdit?.({ type: 'delete', element: selectedElement });
2040
+ // Stay selected (consumer decides whether to remove the element)
2041
+ }
2042
+ break;
2043
+ }
2044
+
2045
+ case 'Escape': {
2046
+ e.preventDefault();
2047
+ if (isTextEditingActive && textEditCleanup) {
2048
+ // Cancel text editing, remain selected
2049
+ textEditCleanup();
2050
+ textEditCleanup = null;
2051
+ isTextEditingActive = false;
2052
+ } else if (selectedElement) {
2053
+ deselectElement();
2054
+ }
2055
+ break;
2056
+ }
2057
+
2058
+ case 'ArrowDown':
2059
+ case 'ArrowRight': {
2060
+ if (!isTextEditingActive && selectedElement) {
2061
+ e.preventDefault();
2062
+ const editables = getEditableElements(currentSpec, currentLayout);
2063
+ if (editables.length === 0) break;
2064
+
2065
+ const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
2066
+ const nextIndex = currentIndex >= editables.length - 1 ? 0 : currentIndex + 1;
2067
+
2068
+ selectElement(editables[nextIndex]);
2069
+ }
2070
+ break;
2071
+ }
2072
+
2073
+ case 'ArrowUp':
2074
+ case 'ArrowLeft': {
2075
+ if (!isTextEditingActive && selectedElement) {
2076
+ e.preventDefault();
2077
+ const editables = getEditableElements(currentSpec, currentLayout);
2078
+ if (editables.length === 0) break;
2079
+
2080
+ const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
2081
+ const nextIndex = currentIndex <= 0 ? editables.length - 1 : currentIndex - 1;
2082
+
2083
+ selectElement(editables[nextIndex]);
2084
+ }
2085
+ break;
2086
+ }
2087
+
2088
+ case 'Enter': {
2089
+ if (selectedElement && !isTextEditingActive) {
2090
+ if (isTextEditable(selectedElement, specAnnotations)) {
2091
+ e.preventDefault();
2092
+ enterTextEditing();
2093
+ }
2094
+ }
2095
+ break;
2096
+ }
2097
+ }
2098
+ };
2099
+
2100
+ svg.addEventListener('keydown', handleKeyDown);
2101
+
2102
+ return () => {
2103
+ svg.removeEventListener('keydown', handleKeyDown);
2104
+ };
2105
+ }
2106
+
1563
2107
  function render(): void {
1564
2108
  // Defer re-render if a drag is in progress to avoid destroying the dragged element
1565
2109
  if (isDragging) {
@@ -1567,6 +2111,13 @@ export function createChart(
1567
2111
  return;
1568
2112
  }
1569
2113
 
2114
+ // Cancel any in-progress entrance animations before tearing down
2115
+ if (cleanupAnimations) {
2116
+ cleanupAnimations();
2117
+ cleanupAnimations = null;
2118
+ }
2119
+ cancelAnimations(svgElement);
2120
+
1570
2121
  // Clean up previous render
1571
2122
  if (cleanupTooltipEvents) {
1572
2123
  cleanupTooltipEvents();
@@ -1596,6 +2147,20 @@ export function createChart(
1596
2147
  cleanupEditDrags();
1597
2148
  cleanupEditDrags = null;
1598
2149
  }
2150
+ if (cleanupSelection) {
2151
+ cleanupSelection();
2152
+ cleanupSelection = null;
2153
+ }
2154
+ if (cleanupKeyboardEdit) {
2155
+ cleanupKeyboardEdit();
2156
+ cleanupKeyboardEdit = null;
2157
+ }
2158
+ if (textEditCleanup) {
2159
+ textEditCleanup();
2160
+ textEditCleanup = null;
2161
+ isTextEditingActive = false;
2162
+ }
2163
+ overlayElement = null;
1599
2164
  if (svgElement?.parentNode) {
1600
2165
  svgElement.parentNode.removeChild(svgElement);
1601
2166
  }
@@ -1608,7 +2173,8 @@ export function createChart(
1608
2173
  }
1609
2174
 
1610
2175
  currentLayout = compile();
1611
- svgElement = renderChartSVG(currentLayout, container);
2176
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
2177
+ svgElement = renderChartSVG(currentLayout, container, { animate: shouldAnimate });
1612
2178
  tooltipManager = createTooltipManager(container);
1613
2179
 
1614
2180
  // Wire tooltip events on mark elements
@@ -1709,27 +2275,63 @@ export function createChart(
1709
2275
  };
1710
2276
  }
1711
2277
 
2278
+ // Wire selection and keyboard edit events when editing callbacks are provided
2279
+ if (hasEditingCallbacks(options)) {
2280
+ makeEditable(svgElement);
2281
+ cleanupSelection = wireSelectionEvents();
2282
+ cleanupKeyboardEdit = wireKeyboardEditEvents();
2283
+
2284
+ // Restore selection overlay after re-render
2285
+ if (selectedElement) {
2286
+ const target = findElementByRef(svgElement, selectedElement);
2287
+ if (target) {
2288
+ overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
2289
+ } else {
2290
+ // Element no longer exists in DOM, clear selection silently
2291
+ selectedElement = null;
2292
+ overlayElement = null;
2293
+ }
2294
+ }
2295
+ }
2296
+
1712
2297
  // Create hidden data table for screen readers
1713
2298
  srTable = createScreenReaderTable(currentLayout, container);
1714
2299
 
1715
2300
  // Apply container classes for CSS variable scoping and dark mode
1716
- container.classList.add('viz-root');
2301
+ container.classList.add('oc-root');
1717
2302
  const isDark = resolveDarkMode(options?.darkMode);
1718
2303
  if (isDark) {
1719
- container.classList.add('viz-dark');
2304
+ container.classList.add('oc-dark');
1720
2305
  } else {
1721
- container.classList.remove('viz-dark');
2306
+ container.classList.remove('oc-dark');
2307
+ }
2308
+
2309
+ // Set up animation cleanup on first render only.
2310
+ if (shouldAnimate && svgElement) {
2311
+ cleanupAnimations = setupAnimationCleanup(svgElement);
2312
+ }
2313
+ if (isFirstRender) {
2314
+ isFirstRender = false;
1722
2315
  }
1723
2316
  }
1724
2317
 
1725
- function update(newSpec: ChartSpec | GraphSpec): void {
2318
+ function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
1726
2319
  if (destroyed) return;
1727
2320
  currentSpec = newSpec;
2321
+ if (updateOpts && 'selectedElement' in updateOpts) {
2322
+ selectedElement = updateOpts.selectedElement ?? null;
2323
+ }
1728
2324
  render();
1729
2325
  }
1730
2326
 
1731
2327
  function resize(): void {
1732
2328
  if (destroyed) return;
2329
+ // Skip resize during entrance animation. The resize observer fires
2330
+ // immediately when the container first enters DOM layout, and re-rendering
2331
+ // would destroy the animated SVG. This also blocks genuine resizes during
2332
+ // the animation window (~1s), but catches up on the next resize event
2333
+ // after the cleanup timeout fires.
2334
+ if (cleanupAnimations) return;
1733
2335
  render();
1734
2336
  }
1735
2337
 
@@ -1768,6 +2370,13 @@ export function createChart(
1768
2370
  if (destroyed) return;
1769
2371
  destroyed = true;
1770
2372
 
2373
+ // Cancel entrance animations
2374
+ if (cleanupAnimations) {
2375
+ cleanupAnimations();
2376
+ cleanupAnimations = null;
2377
+ }
2378
+ cancelAnimations(svgElement);
2379
+
1771
2380
  if (resizeTimer !== null) {
1772
2381
  clearTimeout(resizeTimer);
1773
2382
  resizeTimer = null;
@@ -1800,6 +2409,21 @@ export function createChart(
1800
2409
  cleanupEditDrags();
1801
2410
  cleanupEditDrags = null;
1802
2411
  }
2412
+ if (cleanupSelection) {
2413
+ cleanupSelection();
2414
+ cleanupSelection = null;
2415
+ }
2416
+ if (cleanupKeyboardEdit) {
2417
+ cleanupKeyboardEdit();
2418
+ cleanupKeyboardEdit = null;
2419
+ }
2420
+ if (textEditCleanup) {
2421
+ textEditCleanup();
2422
+ textEditCleanup = null;
2423
+ isTextEditingActive = false;
2424
+ }
2425
+ selectedElement = null;
2426
+ overlayElement = null;
1803
2427
  if (disconnectResize) {
1804
2428
  disconnectResize();
1805
2429
  disconnectResize = null;
@@ -1816,8 +2440,8 @@ export function createChart(
1816
2440
  srTable.parentNode.removeChild(srTable);
1817
2441
  srTable = null;
1818
2442
  }
1819
- container.classList.remove('viz-dark');
1820
- container.classList.remove('viz-root');
2443
+ container.classList.remove('oc-dark');
2444
+ container.classList.remove('oc-root');
1821
2445
  }
1822
2446
 
1823
2447
  // Initial render
@@ -1842,5 +2466,19 @@ export function createChart(
1842
2466
  get layout() {
1843
2467
  return currentLayout;
1844
2468
  },
2469
+ getSelectedElement(): ElementRef | null {
2470
+ return selectedElement;
2471
+ },
2472
+ select(ref: ElementRef): void {
2473
+ if (destroyed) return;
2474
+ selectElement(ref);
2475
+ },
2476
+ deselect(): void {
2477
+ if (destroyed) return;
2478
+ deselectElement();
2479
+ },
2480
+ get isEditing(): boolean {
2481
+ return isTextEditingActive;
2482
+ },
1845
2483
  };
1846
2484
  }