@opendata-ai/openchart-vanilla 6.4.1 → 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
@@ -29,6 +29,7 @@ import type {
29
29
  } from '@opendata-ai/openchart-core';
30
30
  import { elementRef, isLayerSpec } from '@opendata-ai/openchart-core';
31
31
  import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
32
+ import { cancelAnimations, setupAnimationCleanup } from './animation';
32
33
  import {
33
34
  exportCSV,
34
35
  exportJPG,
@@ -477,7 +478,7 @@ function wireChartEvents(
477
478
 
478
479
  // Wire annotation click events
479
480
  if (handlers.onAnnotationClick) {
480
- const annotationElements = svg.querySelectorAll('.viz-annotation');
481
+ const annotationElements = svg.querySelectorAll('.oc-annotation');
481
482
 
482
483
  for (let i = 0; i < annotationElements.length; i++) {
483
484
  const el = annotationElements[i];
@@ -713,7 +714,7 @@ function wireAnnotationDrag(
713
714
  onEdit: ((edit: ElementEdit) => void) | undefined,
714
715
  setDragging: (dragging: boolean) => void,
715
716
  ): () => void {
716
- const annotationElements = svg.querySelectorAll('.viz-annotation-text');
717
+ const annotationElements = svg.querySelectorAll('.oc-annotation-text');
717
718
  const cleanups: Array<() => void> = [];
718
719
 
719
720
  for (const el of annotationElements) {
@@ -731,13 +732,13 @@ function wireAnnotationDrag(
731
732
  annotationG.style.cursor = 'grab';
732
733
 
733
734
  // Stash connector info for real-time updates during drag
734
- const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
735
+ const connectorLine = annotationG.querySelector('line.oc-annotation-connector');
735
736
  const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0;
736
737
  const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0;
737
738
 
738
739
  // For curved connectors, stash path/polygon elements to hide during drag
739
- const curvedPath = annotationG.querySelector('path.viz-annotation-connector');
740
- 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');
741
742
  const hasCurvedConnector = curvedPath !== null;
742
743
 
743
744
  const origDx = textAnnotation.offset?.dx ?? 0;
@@ -824,7 +825,7 @@ function wireConnectorEndpointDrag(
824
825
  ): () => void {
825
826
  const SVG_NS = 'http://www.w3.org/2000/svg';
826
827
  const cleanups: Array<() => void> = [];
827
- const annotationGroups = svg.querySelectorAll('.viz-annotation-text');
828
+ const annotationGroups = svg.querySelectorAll('.oc-annotation-text');
828
829
 
829
830
  for (const el of annotationGroups) {
830
831
  const annotationG = el as SVGGElement;
@@ -838,8 +839,8 @@ function wireConnectorEndpointDrag(
838
839
  const textAnnotation = specAnnotation as TextAnnotation;
839
840
 
840
841
  // Find connector line or curved connector to determine endpoints
841
- const connectorLine = annotationG.querySelector('line.viz-annotation-connector');
842
- 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');
843
844
  if (!connectorLine && !curvedPath) continue;
844
845
 
845
846
  // Determine connector endpoint positions from the connector element
@@ -857,7 +858,7 @@ function wireConnectorEndpointDrag(
857
858
  fromX = mMatch ? Number(mMatch[1]) : 0;
858
859
  fromY = mMatch ? Number(mMatch[2]) : 0;
859
860
  // For curved connectors, the arrow polygon has the target
860
- const arrowhead = annotationG.querySelector('polygon.viz-annotation-connector');
861
+ const arrowhead = annotationG.querySelector('polygon.oc-annotation-connector');
861
862
  const points = arrowhead?.getAttribute('points') ?? '';
862
863
  const firstPoint = points.split(' ')[0] ?? '0,0';
863
864
  const [px, py] = firstPoint.split(',');
@@ -878,7 +879,7 @@ function wireConnectorEndpointDrag(
878
879
  if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
879
880
 
880
881
  const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
881
- handleEl.setAttribute('class', 'viz-connector-handle');
882
+ handleEl.setAttribute('class', 'oc-connector-handle');
882
883
  handleEl.setAttribute('data-endpoint', ep.name);
883
884
  handleEl.setAttribute('cx', String(ep.cx));
884
885
  handleEl.setAttribute('cy', String(ep.cy));
@@ -1002,15 +1003,15 @@ function wireAnnotationLabelDrag(
1002
1003
 
1003
1004
  // Target range and refline annotation labels
1004
1005
  const selectors = [
1005
- '.viz-annotation-range .viz-annotation-label',
1006
- '.viz-annotation-refline .viz-annotation-label',
1006
+ '.oc-annotation-range .oc-annotation-label',
1007
+ '.oc-annotation-refline .oc-annotation-label',
1007
1008
  ];
1008
1009
 
1009
1010
  for (const selector of selectors) {
1010
1011
  const labels = svg.querySelectorAll(selector);
1011
1012
 
1012
1013
  for (const label of labels) {
1013
- const annotationG = label.closest('.viz-annotation') as SVGGElement | null;
1014
+ const annotationG = label.closest('.oc-annotation') as SVGGElement | null;
1014
1015
  if (!annotationG) continue;
1015
1016
 
1016
1017
  const indexStr = annotationG.getAttribute('data-annotation-index');
@@ -1085,7 +1086,7 @@ function wireChromeDrag(
1085
1086
  onEdit: (edit: ElementEdit) => void,
1086
1087
  setDragging: (dragging: boolean) => void,
1087
1088
  ): () => void {
1088
- const chromeTexts = svg.querySelectorAll('.viz-chrome text[data-chrome-key]');
1089
+ const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]');
1089
1090
  const cleanups: Array<() => void> = [];
1090
1091
 
1091
1092
  // Read existing chrome offsets from the spec
@@ -1153,7 +1154,7 @@ function wireLegendDrag(
1153
1154
  onEdit: (edit: ElementEdit) => void,
1154
1155
  setDragging: (dragging: boolean) => void,
1155
1156
  ): () => void {
1156
- const legendG = svg.querySelector('.viz-legend') as SVGGElement | null;
1157
+ const legendG = svg.querySelector('.oc-legend') as SVGGElement | null;
1157
1158
  if (!legendG) return () => {};
1158
1159
 
1159
1160
  const cleanups: Array<() => void> = [];
@@ -1197,7 +1198,7 @@ function wireLegendDrag(
1197
1198
  // ---------------------------------------------------------------------------
1198
1199
 
1199
1200
  /**
1200
- * Wire drag on series label elements (.viz-mark-label[data-series]).
1201
+ * Wire drag on series label elements (.oc-mark-label[data-series]).
1201
1202
  * On drag end, fires onEdit with the series name and offset.
1202
1203
  * Returns a cleanup function.
1203
1204
  */
@@ -1207,7 +1208,7 @@ function wireSeriesLabelDrag(
1207
1208
  onEdit: (edit: ElementEdit) => void,
1208
1209
  setDragging: (dragging: boolean) => void,
1209
1210
  ): () => void {
1210
- const labels = svg.querySelectorAll('.viz-mark-label');
1211
+ const labels = svg.querySelectorAll('.oc-mark-label');
1211
1212
  const cleanups: Array<() => void> = [];
1212
1213
 
1213
1214
  // Read existing label offsets from the spec
@@ -1307,7 +1308,7 @@ function wireLegendInteraction(
1307
1308
  // Toggle visibility of marks with matching series.
1308
1309
  // Uses the data-series attribute set by the SVG renderer, which works
1309
1310
  // for all mark types (line, area, rect, arc, point).
1310
- const marks = svg.querySelectorAll('.viz-mark');
1311
+ const marks = svg.querySelectorAll('.oc-mark');
1311
1312
  for (const mark of marks) {
1312
1313
  const seriesName = mark.getAttribute('data-series');
1313
1314
  if (!seriesName) continue;
@@ -1367,7 +1368,7 @@ function wireKeyboardNav(
1367
1368
  function highlightMark(index: number): void {
1368
1369
  // Remove previous highlight
1369
1370
  if (focusIndex >= 0 && focusIndex < markElements.length) {
1370
- markElements[focusIndex].classList.remove('viz-mark-focused');
1371
+ markElements[focusIndex].classList.remove('oc-mark-focused');
1371
1372
  markElements[focusIndex].removeAttribute('aria-selected');
1372
1373
  }
1373
1374
 
@@ -1375,7 +1376,7 @@ function wireKeyboardNav(
1375
1376
 
1376
1377
  if (focusIndex >= 0 && focusIndex < markElements.length) {
1377
1378
  const el = markElements[focusIndex];
1378
- el.classList.add('viz-mark-focused');
1379
+ el.classList.add('oc-mark-focused');
1379
1380
  el.setAttribute('aria-selected', 'true');
1380
1381
  }
1381
1382
  }
@@ -1464,7 +1465,7 @@ function createScreenReaderTable(
1464
1465
  if (!data || data.length === 0) return null;
1465
1466
 
1466
1467
  const table = document.createElement('table');
1467
- table.className = 'viz-sr-only';
1468
+ table.className = 'oc-sr-only';
1468
1469
  // Inline critical SR-only styles so the table stays hidden even when the
1469
1470
  // external stylesheet isn't loaded (e.g. CDN / esm.sh usage).
1470
1471
  table.style.position = 'absolute';
@@ -1520,7 +1521,7 @@ function createScreenReaderTable(
1520
1521
 
1521
1522
  /** CSS for editable hover feedback, injected into the SVG as a <style> element. */
1522
1523
  const EDITABLE_HOVER_CSS = `
1523
- .viz-editable-hover {
1524
+ .oc-editable-hover {
1524
1525
  outline: 1.5px solid rgba(79, 70, 229, 0.35);
1525
1526
  outline-offset: 2px;
1526
1527
  border-radius: 2px;
@@ -1564,9 +1565,9 @@ function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
1564
1565
  case 'chrome':
1565
1566
  return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
1566
1567
  case 'series-label':
1567
- return svg.querySelector(`.viz-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
1568
+ return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
1568
1569
  case 'legend':
1569
- return svg.querySelector('.viz-legend') as SVGElement | null;
1570
+ return svg.querySelector('.oc-legend') as SVGElement | null;
1570
1571
  case 'legend-entry':
1571
1572
  return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
1572
1573
  }
@@ -1593,7 +1594,7 @@ function buildElementRef(element: Element, _specAnnotations: Annotation[]): Elem
1593
1594
  }
1594
1595
 
1595
1596
  // Check for series label
1596
- const seriesLabelEl = element.closest('.viz-mark-label[data-series]');
1597
+ const seriesLabelEl = element.closest('.oc-mark-label[data-series]');
1597
1598
  if (seriesLabelEl) {
1598
1599
  const series = seriesLabelEl.getAttribute('data-series');
1599
1600
  if (series) return elementRef.seriesLabel(series);
@@ -1608,7 +1609,7 @@ function buildElementRef(element: Element, _specAnnotations: Annotation[]): Elem
1608
1609
  }
1609
1610
 
1610
1611
  // Check for legend group
1611
- const legendEl = element.closest('.viz-legend');
1612
+ const legendEl = element.closest('.oc-legend');
1612
1613
  if (legendEl) return elementRef.legend();
1613
1614
 
1614
1615
  return null;
@@ -1741,7 +1742,7 @@ function renderSelectionOverlay(
1741
1742
  const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
1742
1743
 
1743
1744
  const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1744
- g.setAttribute('class', 'viz-selection-overlay');
1745
+ g.setAttribute('class', 'oc-selection-overlay');
1745
1746
 
1746
1747
  const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1747
1748
  rect.setAttribute('x', String(bbox.x - padding));
@@ -1797,6 +1798,10 @@ export function createChart(
1797
1798
  let pendingRender = false;
1798
1799
  let resizeTimer: ReturnType<typeof setTimeout> | null = null;
1799
1800
 
1801
+ // Animation state
1802
+ let isFirstRender = true;
1803
+ let cleanupAnimations: (() => void) | null = null;
1804
+
1800
1805
  // Selection and text editing state
1801
1806
  let selectedElement: ElementRef | null = options?.selectedElement ?? null;
1802
1807
  let overlayElement: SVGGElement | null = null;
@@ -1967,17 +1972,17 @@ export function createChart(
1967
1972
  // Hover feedback on editable elements
1968
1973
  const handleMouseEnter = (e: Event) => {
1969
1974
  const target = (e.target as Element).closest(
1970
- '[data-annotation-index], [data-chrome-key], .viz-mark-label[data-series], .viz-legend, [data-legend-index]',
1975
+ '[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend, [data-legend-index]',
1971
1976
  );
1972
1977
  if (target) {
1973
- (target as SVGElement).classList.add('viz-editable-hover');
1978
+ (target as SVGElement).classList.add('oc-editable-hover');
1974
1979
  }
1975
1980
  };
1976
1981
 
1977
1982
  const handleMouseLeave = (e: Event) => {
1978
- const target = (e.target as Element).closest('.viz-editable-hover');
1983
+ const target = (e.target as Element).closest('.oc-editable-hover');
1979
1984
  if (target) {
1980
- (target as SVGElement).classList.remove('viz-editable-hover');
1985
+ (target as SVGElement).classList.remove('oc-editable-hover');
1981
1986
  }
1982
1987
  };
1983
1988
 
@@ -2106,6 +2111,13 @@ export function createChart(
2106
2111
  return;
2107
2112
  }
2108
2113
 
2114
+ // Cancel any in-progress entrance animations before tearing down
2115
+ if (cleanupAnimations) {
2116
+ cleanupAnimations();
2117
+ cleanupAnimations = null;
2118
+ }
2119
+ cancelAnimations(svgElement);
2120
+
2109
2121
  // Clean up previous render
2110
2122
  if (cleanupTooltipEvents) {
2111
2123
  cleanupTooltipEvents();
@@ -2161,7 +2173,8 @@ export function createChart(
2161
2173
  }
2162
2174
 
2163
2175
  currentLayout = compile();
2164
- svgElement = renderChartSVG(currentLayout, container);
2176
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
2177
+ svgElement = renderChartSVG(currentLayout, container, { animate: shouldAnimate });
2165
2178
  tooltipManager = createTooltipManager(container);
2166
2179
 
2167
2180
  // Wire tooltip events on mark elements
@@ -2285,12 +2298,20 @@ export function createChart(
2285
2298
  srTable = createScreenReaderTable(currentLayout, container);
2286
2299
 
2287
2300
  // Apply container classes for CSS variable scoping and dark mode
2288
- container.classList.add('viz-root');
2301
+ container.classList.add('oc-root');
2289
2302
  const isDark = resolveDarkMode(options?.darkMode);
2290
2303
  if (isDark) {
2291
- container.classList.add('viz-dark');
2304
+ container.classList.add('oc-dark');
2292
2305
  } else {
2293
- 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;
2294
2315
  }
2295
2316
  }
2296
2317
 
@@ -2305,6 +2326,12 @@ export function createChart(
2305
2326
 
2306
2327
  function resize(): void {
2307
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;
2308
2335
  render();
2309
2336
  }
2310
2337
 
@@ -2343,6 +2370,13 @@ export function createChart(
2343
2370
  if (destroyed) return;
2344
2371
  destroyed = true;
2345
2372
 
2373
+ // Cancel entrance animations
2374
+ if (cleanupAnimations) {
2375
+ cleanupAnimations();
2376
+ cleanupAnimations = null;
2377
+ }
2378
+ cancelAnimations(svgElement);
2379
+
2346
2380
  if (resizeTimer !== null) {
2347
2381
  clearTimeout(resizeTimer);
2348
2382
  resizeTimer = null;
@@ -2406,8 +2440,8 @@ export function createChart(
2406
2440
  srTable.parentNode.removeChild(srTable);
2407
2441
  srTable = null;
2408
2442
  }
2409
- container.classList.remove('viz-dark');
2410
- container.classList.remove('viz-root');
2443
+ container.classList.remove('oc-dark');
2444
+ container.classList.remove('oc-root');
2411
2445
  }
2412
2446
 
2413
2447
  // Initial render
@@ -148,6 +148,7 @@ export function renderTextCell(cell: TextTableCell): HTMLTableCellElement {
148
148
  /** Render a heatmap-colored cell. */
149
149
  export function renderHeatmapCell(cell: HeatmapTableCell): HTMLTableCellElement {
150
150
  const td = document.createElement('td');
151
+ td.className = 'oc-table-heatmap';
151
152
  td.textContent = cell.formattedValue;
152
153
  applyCellStyle(td, cell);
153
154
  return td;
@@ -156,6 +157,7 @@ export function renderHeatmapCell(cell: HeatmapTableCell): HTMLTableCellElement
156
157
  /** Render a category-colored cell. */
157
158
  export function renderCategoryCell(cell: CategoryTableCell): HTMLTableCellElement {
158
159
  const td = document.createElement('td');
160
+ td.className = 'oc-table-category';
159
161
  td.textContent = cell.formattedValue;
160
162
  applyCellStyle(td, cell);
161
163
  return td;
@@ -164,18 +166,18 @@ export function renderCategoryCell(cell: CategoryTableCell): HTMLTableCellElemen
164
166
  /** Render a cell with an inline bar visualization. */
165
167
  export function renderBarCell(cell: BarTableCell): HTMLTableCellElement {
166
168
  const td = document.createElement('td');
167
- td.className = 'viz-table-bar';
169
+ td.className = 'oc-table-bar';
168
170
  applyCellStyle(td, cell);
169
171
 
170
172
  const fill = document.createElement('div');
171
- fill.className = 'viz-table-bar-fill';
173
+ fill.className = 'oc-table-bar-fill';
172
174
  fill.style.width = `${Math.round(cell.barWidth * 100)}%`;
173
175
  fill.style.left = `${Math.round(cell.barOffset * 100)}%`;
174
176
  fill.style.background = cell.barColor;
175
177
  td.appendChild(fill);
176
178
 
177
179
  const valueSpan = document.createElement('span');
178
- valueSpan.className = 'viz-table-bar-value';
180
+ valueSpan.className = 'oc-table-bar-value';
179
181
  valueSpan.textContent = cell.formattedValue;
180
182
  td.appendChild(valueSpan);
181
183
 
@@ -214,7 +216,7 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
214
216
  }
215
217
 
216
218
  const wrapper = document.createElement('span');
217
- wrapper.className = 'viz-table-sparkline';
219
+ wrapper.className = 'oc-table-sparkline';
218
220
 
219
221
  const svgNS = 'http://www.w3.org/2000/svg';
220
222
 
@@ -266,14 +268,14 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
266
268
  const dotSize = 5;
267
269
 
268
270
  const startDot = document.createElement('span');
269
- startDot.className = 'viz-table-sparkline-dot';
271
+ startDot.className = 'oc-table-sparkline-dot';
270
272
  startDot.style.left = '0';
271
273
  startDot.style.top = `${firstY - dotSize / 2}px`;
272
274
  startDot.style.background = sparklineData.color;
273
275
  wrapper.appendChild(startDot);
274
276
 
275
277
  const endDot = document.createElement('span');
276
- endDot.className = 'viz-table-sparkline-dot';
278
+ endDot.className = 'oc-table-sparkline-dot';
277
279
  endDot.style.right = '0';
278
280
  endDot.style.top = `${lastY - dotSize / 2}px`;
279
281
  endDot.style.background = sparklineData.color;
@@ -281,7 +283,7 @@ export function renderSparklineCell(cell: SparklineTableCell): HTMLTableCellElem
281
283
 
282
284
  // HTML labels below the SVG, positioned at left and right edges
283
285
  const labelsRow = document.createElement('span');
284
- labelsRow.className = 'viz-table-sparkline-labels';
286
+ labelsRow.className = 'oc-table-sparkline-labels';
285
287
  labelsRow.style.color = sparklineData.color;
286
288
 
287
289
  const startLabel = document.createElement('span');
@@ -378,7 +380,7 @@ export function renderImageCell(cell: ImageTableCell): HTMLTableCellElement {
378
380
  applyCellStyle(td, cell);
379
381
 
380
382
  const wrapper = document.createElement('span');
381
- wrapper.className = `viz-table-image${cell.rounded ? ' viz-table-image-rounded' : ''}`;
383
+ wrapper.className = `oc-table-image${cell.rounded ? ' oc-table-image-rounded' : ''}`;
382
384
 
383
385
  const img = document.createElement('img');
384
386
  img.src = cell.src;
@@ -399,7 +401,7 @@ export function renderFlagCell(cell: FlagTableCell): HTMLTableCellElement {
399
401
  applyCellStyle(td, cell);
400
402
 
401
403
  const span = document.createElement('span');
402
- span.className = 'viz-table-flag';
404
+ span.className = 'oc-table-flag';
403
405
  span.setAttribute('role', 'img');
404
406
 
405
407
  if (cell.countryCode && cell.countryCode.length === 2) {