@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.
@@ -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) {
@@ -20,6 +20,7 @@ import type {
20
20
  Point,
21
21
  PointMark,
22
22
  RectMark,
23
+ ResolvedAnimation,
23
24
  ResolvedAnnotation,
24
25
  ResolvedChromeElement,
25
26
  RuleMarkLayout,
@@ -28,9 +29,38 @@ import type {
28
29
  TickMarkLayout,
29
30
  } from '@opendata-ai/openchart-core';
30
31
  import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
32
+ import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
31
33
 
32
34
  const SVG_NS = 'http://www.w3.org/2000/svg';
33
35
 
36
+ /**
37
+ * Module-level animation state. Set by renderChartSVG before rendering marks
38
+ * so mark renderers can read it without changing their function signatures.
39
+ */
40
+ let currentAnimation: ResolvedAnimation | undefined;
41
+
42
+ /**
43
+ * Stamp animation index attributes on a mark element when animation is enabled.
44
+ * Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
45
+ * (for CSS calc-based stagger delay).
46
+ */
47
+ function stampAnimationAttrs(
48
+ el: SVGElement,
49
+ mark: { animationIndex?: number },
50
+ fallbackIndex: number,
51
+ ): void {
52
+ if (!currentAnimation?.enabled) return;
53
+ const idx = mark.animationIndex ?? fallbackIndex;
54
+ el.setAttribute('data-animation-index', String(idx));
55
+ (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
56
+ }
57
+
58
+ /** CSS easing preset map for inline style custom properties. */
59
+ const EASE_VAR_MAP: Record<string, string> = {
60
+ smooth: 'var(--oc-ease-smooth)',
61
+ snappy: 'var(--oc-ease-snappy)',
62
+ };
63
+
34
64
  /**
35
65
  * Compute the vertical extent of x-axis labels below the chart area.
36
66
  * Accounts for rotated tick labels which need more vertical space.
@@ -77,7 +107,7 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
77
107
  'font-weight': style.fontWeight,
78
108
  });
79
109
  // Use inline style for fill so it takes priority over CSS class defaults
80
- // (e.g. .viz-title { fill: var(--viz-text) } which would override attributes)
110
+ // (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
81
111
  (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
82
112
  if (style.textAnchor) {
83
113
  el.setAttribute('text-anchor', style.textAnchor);
@@ -174,16 +204,16 @@ function renderChromeElement(
174
204
 
175
205
  function renderChrome(parent: SVGElement, layout: ChartLayout): void {
176
206
  const g = createSVGElement('g');
177
- g.setAttribute('class', 'viz-chrome');
207
+ g.setAttribute('class', 'oc-chrome');
178
208
 
179
209
  const { chrome } = layout;
180
210
 
181
211
  // Top chrome: render at their stored y positions (already absolute)
182
212
  if (chrome.title) {
183
- renderChromeElement(g, chrome.title, 'viz-title', 'title');
213
+ renderChromeElement(g, chrome.title, 'oc-title', 'title');
184
214
  }
185
215
  if (chrome.subtitle) {
186
- renderChromeElement(g, chrome.subtitle, 'viz-subtitle', 'subtitle');
216
+ renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
187
217
  }
188
218
 
189
219
  // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
@@ -194,7 +224,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
194
224
  renderChromeElement(
195
225
  g,
196
226
  { ...chrome.source, y: bottomOffset + chrome.source.y },
197
- 'viz-source',
227
+ 'oc-source',
198
228
  'source',
199
229
  );
200
230
  }
@@ -202,7 +232,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
202
232
  renderChromeElement(
203
233
  g,
204
234
  { ...chrome.byline, y: bottomOffset + chrome.byline.y },
205
- 'viz-byline',
235
+ 'oc-byline',
206
236
  'byline',
207
237
  );
208
238
  }
@@ -210,7 +240,7 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
210
240
  renderChromeElement(
211
241
  g,
212
242
  { ...chrome.footer, y: bottomOffset + chrome.footer.y },
213
- 'viz-footer',
243
+ 'oc-footer',
214
244
  'footer',
215
245
  );
216
246
  }
@@ -229,7 +259,7 @@ function renderAxis(
229
259
  layout: ChartLayout,
230
260
  ): void {
231
261
  const g = createSVGElement('g');
232
- g.setAttribute('class', `viz-axis viz-axis-${orientation}`);
262
+ g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
233
263
 
234
264
  const { area } = layout;
235
265
 
@@ -237,7 +267,7 @@ function renderAxis(
237
267
  // Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
238
268
  if (orientation === 'x') {
239
269
  const line = createSVGElement('line');
240
- line.setAttribute('class', 'viz-axis-line');
270
+ line.setAttribute('class', 'oc-axis-line');
241
271
  setAttrs(line, {
242
272
  x1: axis.start.x,
243
273
  y1: axis.start.y,
@@ -257,7 +287,7 @@ function renderAxis(
257
287
  if (orientation === 'x') {
258
288
  // Label (no tick marks -- gridlines provide sufficient reference)
259
289
  const label = createSVGElement('text');
260
- label.setAttribute('class', 'viz-axis-tick');
290
+ label.setAttribute('class', 'oc-axis-tick');
261
291
 
262
292
  if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
263
293
  // Rotated labels: anchor at the rotation pivot point
@@ -284,7 +314,7 @@ function renderAxis(
284
314
  } else {
285
315
  // Label (no tick marks -- gridlines provide sufficient reference)
286
316
  const label = createSVGElement('text');
287
- label.setAttribute('class', 'viz-axis-tick');
317
+ label.setAttribute('class', 'oc-axis-tick');
288
318
  setAttrs(label, {
289
319
  x: area.x - 6,
290
320
  y: tick.position,
@@ -300,7 +330,7 @@ function renderAxis(
300
330
  // Gridlines (positions are also absolute from the scales)
301
331
  for (const gridline of axis.gridlines) {
302
332
  const gl = createSVGElement('line');
303
- gl.setAttribute('class', 'viz-gridline');
333
+ gl.setAttribute('class', 'oc-gridline');
304
334
  if (orientation === 'y') {
305
335
  setAttrs(gl, {
306
336
  x1: area.x,
@@ -328,7 +358,7 @@ function renderAxis(
328
358
  // Axis label
329
359
  if (axis.label && axis.labelStyle) {
330
360
  const axisLabel = createSVGElement('text');
331
- axisLabel.setAttribute('class', 'viz-axis-title');
361
+ axisLabel.setAttribute('class', 'oc-axis-title');
332
362
  applyTextStyle(axisLabel, axis.labelStyle);
333
363
  axisLabel.textContent = axis.label;
334
364
 
@@ -401,7 +431,8 @@ export function registerMarkRenderer<T extends Mark>(
401
431
  function renderLineMark(mark: LineMark, index: number): SVGElement {
402
432
  const g = createSVGElement('g');
403
433
  g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
404
- g.setAttribute('class', 'viz-mark viz-mark-line');
434
+ g.setAttribute('class', 'oc-mark oc-mark-line');
435
+ stampAnimationAttrs(g, mark, index);
405
436
 
406
437
  if (mark.points.length > 1) {
407
438
  const path = createSVGElement('path');
@@ -421,13 +452,15 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
421
452
  if (mark.opacity != null) {
422
453
  path.setAttribute('opacity', String(mark.opacity));
423
454
  }
455
+ // Note: line drawing animation is handled via CSS clip-path on the group,
456
+ // no inline dasharray/dashoffset needed.
424
457
  g.appendChild(path);
425
458
  }
426
459
 
427
460
  // Render end-of-line label if present and visible
428
461
  if (mark.label?.visible) {
429
462
  const label = createSVGElement('text');
430
- label.setAttribute('class', 'viz-mark-label');
463
+ label.setAttribute('class', 'oc-mark-label');
431
464
  if (mark.seriesKey) {
432
465
  label.setAttribute('data-series', mark.seriesKey);
433
466
  }
@@ -439,7 +472,7 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
439
472
  // Render connector line if label was offset from anchor
440
473
  if (mark.label.connector) {
441
474
  const connector = createSVGElement('line');
442
- connector.setAttribute('class', 'viz-mark-connector');
475
+ connector.setAttribute('class', 'oc-mark-connector');
443
476
  setAttrs(connector, {
444
477
  x1: mark.label.connector.from.x,
445
478
  y1: mark.label.connector.from.y,
@@ -459,7 +492,8 @@ function renderLineMark(mark: LineMark, index: number): SVGElement {
459
492
  function renderAreaMark(mark: AreaMark, index: number): SVGElement {
460
493
  const g = createSVGElement('g');
461
494
  g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
462
- g.setAttribute('class', 'viz-mark viz-mark-area');
495
+ g.setAttribute('class', 'oc-mark oc-mark-area');
496
+ stampAnimationAttrs(g, mark, index);
463
497
 
464
498
  if (mark.path) {
465
499
  // Area fill: the full closed shape (top line + baseline), no stroke
@@ -475,12 +509,15 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
475
509
  // Top-line stroke: only along the data points, not the baseline
476
510
  if (mark.stroke && mark.topPath) {
477
511
  const strokePath = createSVGElement('path');
512
+ strokePath.setAttribute('class', 'oc-area-top');
478
513
  setAttrs(strokePath, {
479
514
  d: mark.topPath,
480
515
  fill: 'none',
481
516
  stroke: mark.stroke,
482
517
  'stroke-width': mark.strokeWidth ?? 1,
483
518
  });
519
+ // Note: area drawing animation is handled via CSS clip-path on the group,
520
+ // no inline dasharray/dashoffset needed.
484
521
  g.appendChild(strokePath);
485
522
  }
486
523
  }
@@ -491,7 +528,12 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
491
528
  function renderRectMark(mark: RectMark, index: number): SVGElement {
492
529
  const g = createSVGElement('g');
493
530
  g.setAttribute('data-mark-id', `rect-${index}`);
494
- g.setAttribute('class', 'viz-mark viz-mark-rect');
531
+ g.setAttribute('class', 'oc-mark oc-mark-rect');
532
+ stampAnimationAttrs(g, mark, index);
533
+ // Use engine-provided orientation for animation direction
534
+ if (currentAnimation?.enabled && mark.orient === 'horizontal') {
535
+ g.setAttribute('data-orient', 'horizontal');
536
+ }
495
537
 
496
538
  const rect = createSVGElement('rect');
497
539
  setAttrs(rect, {
@@ -515,7 +557,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
515
557
  // Render value label if present and visible
516
558
  if (mark.label?.visible) {
517
559
  const label = createSVGElement('text');
518
- label.setAttribute('class', 'viz-mark-label');
560
+ label.setAttribute('class', 'oc-mark-label');
519
561
  setAttrs(label, { x: mark.label.x, y: mark.label.y });
520
562
  applyTextStyle(label, mark.label.style);
521
563
  label.textContent = mark.label.text;
@@ -528,8 +570,9 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
528
570
  function renderArcMark(mark: ArcMark, index: number): SVGElement {
529
571
  const g = createSVGElement('g');
530
572
  g.setAttribute('data-mark-id', `arc-${index}`);
531
- g.setAttribute('class', 'viz-mark viz-mark-arc');
573
+ g.setAttribute('class', 'oc-mark oc-mark-arc');
532
574
  g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
575
+ stampAnimationAttrs(g, mark, index);
533
576
 
534
577
  const path = createSVGElement('path');
535
578
  setAttrs(path, {
@@ -543,7 +586,7 @@ function renderArcMark(mark: ArcMark, index: number): SVGElement {
543
586
  // Render label if present and visible
544
587
  if (mark.label?.visible) {
545
588
  const label = createSVGElement('text');
546
- label.setAttribute('class', 'viz-mark-label');
589
+ label.setAttribute('class', 'oc-mark-label');
547
590
  // Label position is in absolute coords, but we're in a translated group,
548
591
  // so subtract the center offset
549
592
  setAttrs(label, {
@@ -561,7 +604,9 @@ function renderArcMark(mark: ArcMark, index: number): SVGElement {
561
604
  function renderPointMark(mark: PointMark, index: number): SVGElement {
562
605
  const circle = createSVGElement('circle');
563
606
  circle.setAttribute('data-mark-id', `point-${index}`);
564
- circle.setAttribute('class', 'viz-mark viz-mark-point');
607
+ circle.setAttribute('class', 'oc-mark oc-mark-point');
608
+ stampAnimationAttrs(circle, mark, index);
609
+
565
610
  setAttrs(circle, {
566
611
  cx: mark.cx,
567
612
  cy: mark.cy,
@@ -579,7 +624,9 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
579
624
  function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
580
625
  const text = createSVGElement('text');
581
626
  text.setAttribute('data-mark-id', `textMark-${index}`);
582
- text.setAttribute('class', 'viz-mark viz-mark-text');
627
+ text.setAttribute('class', 'oc-mark oc-mark-text');
628
+ stampAnimationAttrs(text, mark, index);
629
+
583
630
  setAttrs(text, {
584
631
  x: mark.x,
585
632
  y: mark.y,
@@ -603,7 +650,9 @@ function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
603
650
  function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
604
651
  const line = createSVGElement('line');
605
652
  line.setAttribute('data-mark-id', `rule-${index}`);
606
- line.setAttribute('class', 'viz-mark viz-mark-rule');
653
+ line.setAttribute('class', 'oc-mark oc-mark-rule');
654
+ stampAnimationAttrs(line, mark, index);
655
+
607
656
  setAttrs(line, {
608
657
  x1: mark.x1,
609
658
  y1: mark.y1,
@@ -624,7 +673,8 @@ function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
624
673
  function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
625
674
  const line = createSVGElement('line');
626
675
  line.setAttribute('data-mark-id', `tick-${index}`);
627
- line.setAttribute('class', 'viz-mark viz-mark-tick');
676
+ line.setAttribute('class', 'oc-mark oc-mark-tick');
677
+ stampAnimationAttrs(line, mark, index);
628
678
 
629
679
  // Tick is a short line segment centered at (x, y)
630
680
  const half = mark.length / 2;
@@ -685,24 +735,38 @@ function getMarkSeries(mark: Mark): string | undefined {
685
735
 
686
736
  function renderMarks(parent: SVGElement, layout: ChartLayout): void {
687
737
  const g = createSVGElement('g');
688
- g.setAttribute('class', 'viz-marks');
738
+ g.setAttribute('class', 'oc-marks');
689
739
 
690
740
  for (let i = 0; i < layout.marks.length; i++) {
691
741
  const mark = layout.marks[i];
692
742
  const renderer = markRenderers[mark.type];
693
- if (renderer) {
694
- const el = renderer(mark, i);
695
- // Add ARIA label if present
696
- if (mark.aria?.label) {
697
- el.setAttribute('aria-label', mark.aria.label);
698
- }
699
- // Add data-series attribute for legend toggle matching
700
- const series = getMarkSeries(mark);
701
- if (series) {
702
- el.setAttribute('data-series', series);
743
+ if (!renderer) continue;
744
+
745
+ const el = renderer(mark, i);
746
+ // Add ARIA label if present
747
+ if (mark.aria?.label) {
748
+ el.setAttribute('aria-label', mark.aria.label);
749
+ }
750
+ // Add data-series attribute for legend toggle matching
751
+ const series = getMarkSeries(mark);
752
+ if (series) {
753
+ el.setAttribute('data-series', series);
754
+ }
755
+
756
+ // For stacked segments, set stack position for sequential animation chaining.
757
+ // stackPos is computed by the engine on RectMark during compilation.
758
+ if (currentAnimation?.enabled && mark.type === 'rect') {
759
+ const rect = mark as RectMark;
760
+ if (rect.stackGroup && rect.stackPos !== undefined) {
761
+ el.setAttribute('data-stack-pos', String(rect.stackPos));
762
+ (el as SVGElement & ElementCSSInlineStyle).style.setProperty(
763
+ '--oc-stack-pos',
764
+ String(rect.stackPos),
765
+ );
703
766
  }
704
- g.appendChild(el);
705
767
  }
768
+
769
+ g.appendChild(el);
706
770
  }
707
771
 
708
772
  parent.appendChild(g);
@@ -716,7 +780,7 @@ function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
716
780
  if (layout.annotations.length === 0) return;
717
781
 
718
782
  const g = createSVGElement('g');
719
- g.setAttribute('class', 'viz-annotations');
783
+ g.setAttribute('class', 'oc-annotations');
720
784
 
721
785
  // Annotations are already sorted by zIndex from the engine, so render in order
722
786
  for (let i = 0; i < layout.annotations.length; i++) {
@@ -763,7 +827,7 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
763
827
  const baseY = tipY - uy * arrowLen;
764
828
 
765
829
  const path = createSVGElement('path');
766
- path.setAttribute('class', 'viz-annotation-connector');
830
+ path.setAttribute('class', 'oc-annotation-connector');
767
831
  setAttrs(path, {
768
832
  d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
769
833
  fill: 'none',
@@ -777,7 +841,7 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
777
841
  const py = ux;
778
842
 
779
843
  const arrow = createSVGElement('polygon');
780
- arrow.setAttribute('class', 'viz-annotation-connector');
844
+ arrow.setAttribute('class', 'oc-annotation-connector');
781
845
  setAttrs(arrow, {
782
846
  points: [
783
847
  `${to.x},${tipY}`,
@@ -791,13 +855,16 @@ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: s
791
855
 
792
856
  function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, index: number): void {
793
857
  const g = createSVGElement('g');
794
- g.setAttribute('class', `viz-annotation viz-annotation-${annotation.type}`);
858
+ g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`);
795
859
  g.setAttribute('data-annotation-index', String(index));
860
+ if (annotation.id) {
861
+ g.setAttribute('data-annotation-id', annotation.id);
862
+ }
796
863
 
797
864
  // Range rect
798
865
  if (annotation.rect) {
799
866
  const rect = createSVGElement('rect');
800
- rect.setAttribute('class', 'viz-annotation-range');
867
+ rect.setAttribute('class', 'oc-annotation-range');
801
868
  setAttrs(rect, {
802
869
  x: annotation.rect.x,
803
870
  y: annotation.rect.y,
@@ -814,7 +881,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
814
881
  // Reference line
815
882
  if (annotation.line) {
816
883
  const line = createSVGElement('line');
817
- line.setAttribute('class', 'viz-annotation-line');
884
+ line.setAttribute('class', 'oc-annotation-line');
818
885
  setAttrs(line, {
819
886
  x1: annotation.line.start.x,
820
887
  y1: annotation.line.start.y,
@@ -854,7 +921,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
854
921
  const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
855
922
  const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
856
923
  const path = createSVGElement('path');
857
- path.setAttribute('class', 'viz-annotation-connector');
924
+ path.setAttribute('class', 'oc-annotation-connector');
858
925
  setAttrs(path, {
859
926
  d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
860
927
  fill: 'none',
@@ -869,7 +936,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
869
936
  renderCurvedArrow(g, c.from, c.to, c.stroke);
870
937
  } else {
871
938
  const connector = createSVGElement('line');
872
- connector.setAttribute('class', 'viz-annotation-connector');
939
+ connector.setAttribute('class', 'oc-annotation-connector');
873
940
  setAttrs(connector, {
874
941
  x1: c.from.x,
875
942
  y1: c.from.y,
@@ -884,7 +951,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
884
951
  }
885
952
 
886
953
  const text = createSVGElement('text');
887
- text.setAttribute('class', 'viz-annotation-label');
954
+ text.setAttribute('class', 'oc-annotation-label');
888
955
  setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
889
956
  applyTextStyle(text, annotation.label.style);
890
957
 
@@ -916,7 +983,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
916
983
  ? annotation.label.x - maxLineWidth / 2 - pad
917
984
  : annotation.label.x - pad;
918
985
  const bgRect = createSVGElement('rect');
919
- bgRect.setAttribute('class', 'viz-annotation-bg');
986
+ bgRect.setAttribute('class', 'oc-annotation-bg');
920
987
  setAttrs(bgRect, {
921
988
  x: bgX,
922
989
  y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
@@ -942,7 +1009,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
942
1009
  if (legend.entries.length === 0) return;
943
1010
 
944
1011
  const g = createSVGElement('g');
945
- g.setAttribute('class', 'viz-legend');
1012
+ g.setAttribute('class', 'oc-legend');
946
1013
  g.setAttribute('role', 'list');
947
1014
  g.setAttribute('aria-label', 'Chart legend');
948
1015
 
@@ -967,7 +1034,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
967
1034
  }
968
1035
  }
969
1036
  const entryG = createSVGElement('g');
970
- entryG.setAttribute('class', 'viz-legend-entry');
1037
+ entryG.setAttribute('class', 'oc-legend-entry');
971
1038
  entryG.setAttribute('role', 'listitem');
972
1039
  entryG.setAttribute('data-legend-index', String(i));
973
1040
  entryG.setAttribute('data-legend-label', entry.label);
@@ -1097,7 +1164,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1097
1164
  a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
1098
1165
  a.setAttribute('target', '_blank');
1099
1166
  a.setAttribute('rel', 'noopener');
1100
- a.setAttribute('class', 'viz-chrome-ref');
1167
+ a.setAttribute('class', 'oc-chrome-ref');
1101
1168
 
1102
1169
  // "Open" in normal weight, "Data" in semibold, rendered as a single
1103
1170
  // right-aligned text element with two tspans.
@@ -1138,8 +1205,16 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1138
1205
  * @param container - DOM element to mount the SVG into.
1139
1206
  * @returns The created SVG element.
1140
1207
  */
1141
- export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVGElement {
1208
+ export function renderChartSVG(
1209
+ layout: ChartLayout,
1210
+ container: HTMLElement,
1211
+ opts?: { animate?: boolean },
1212
+ ): SVGElement {
1142
1213
  const { width, height } = layout.dimensions;
1214
+ const animation = layout.animation;
1215
+
1216
+ // Set module-level animation state so mark renderers can access it
1217
+ currentAnimation = animation;
1143
1218
 
1144
1219
  const svg = createSVGElement('svg') as SVGSVGElement;
1145
1220
  setAttrs(svg, {
@@ -1158,7 +1233,39 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1158
1233
  svg.style.height = `${height}px`;
1159
1234
  svg.setAttribute('role', layout.a11y.role);
1160
1235
  svg.setAttribute('aria-label', layout.a11y.altText);
1161
- svg.setAttribute('class', 'viz-chart');
1236
+
1237
+ // oc-animate must be set before the SVG enters the DOM to prevent a flash
1238
+ // of the final state. mount.ts passes animate: true only on genuine first render.
1239
+ const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
1240
+ svg.setAttribute('class', classes);
1241
+
1242
+ // Set animation CSS custom properties when enabled
1243
+ if (animation?.enabled) {
1244
+ const markCount = layout.marks.length;
1245
+ const stagger = clampStaggerDelay(animation.staggerDelay, markCount);
1246
+ svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
1247
+ svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
1248
+ svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
1249
+ const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
1250
+ svg.style.setProperty('--oc-animation-ease', easeVar);
1251
+
1252
+ // Compute per-segment duration for stacked bars so the total bar animation
1253
+ // time stays consistent regardless of segment count.
1254
+ // stackPos is set by the engine (0-indexed position within each stack group).
1255
+ let maxSegments = 0;
1256
+ for (const m of layout.marks) {
1257
+ if (m.type === 'rect') {
1258
+ const pos = (m as RectMark).stackPos;
1259
+ if (pos !== undefined && pos + 1 > maxSegments) {
1260
+ maxSegments = pos + 1;
1261
+ }
1262
+ }
1263
+ }
1264
+ if (maxSegments > 0) {
1265
+ const segDuration = Math.round(animation.duration / maxSegments);
1266
+ svg.style.setProperty('--oc-stack-segment-duration', `${segDuration}ms`);
1267
+ }
1268
+ }
1162
1269
 
1163
1270
  // Background
1164
1271
  const bg = createSVGElement('rect');
@@ -1174,7 +1281,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1174
1281
  // Clip path to prevent marks (especially area fills) from overflowing
1175
1282
  // into the chrome region (title/subtitle). Extends full width so
1176
1283
  // end-of-line labels aren't clipped, but constrains vertically.
1177
- const clipId = `viz-clip-${Math.random().toString(36).slice(2, 8)}`;
1284
+ const clipId = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
1178
1285
  const defs = createSVGElement('defs');
1179
1286
  const clipPath = createSVGElement('clipPath');
1180
1287
  clipPath.setAttribute('id', clipId);
@@ -1214,7 +1321,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1214
1321
  height: layout.area.height,
1215
1322
  fill: 'transparent',
1216
1323
  });
1217
- overlay.setAttribute('class', 'viz-voronoi-overlay');
1324
+ overlay.setAttribute('class', 'oc-voronoi-overlay');
1218
1325
  overlay.setAttribute('data-voronoi-overlay', 'true');
1219
1326
  clippedGroup.appendChild(overlay);
1220
1327
  }
@@ -1230,6 +1337,9 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1230
1337
  // Brand renders as a footer item, right-aligned on the source/footer row
1231
1338
  renderBrand(svg, layout);
1232
1339
 
1340
+ // Reset module-level animation state after rendering
1341
+ currentAnimation = undefined;
1342
+
1233
1343
  container.appendChild(svg);
1234
1344
  return svg;
1235
1345
  }
@@ -73,9 +73,9 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
73
73
  }
74
74
 
75
75
  function clearFocusHighlight(): void {
76
- const prev = wrapper.querySelector('.viz-table-cell-focus');
76
+ const prev = wrapper.querySelector('.oc-table-cell-focus');
77
77
  if (prev) {
78
- prev.classList.remove('viz-table-cell-focus');
78
+ prev.classList.remove('oc-table-cell-focus');
79
79
  prev.removeAttribute('id');
80
80
  }
81
81
  }
@@ -99,9 +99,9 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
99
99
  const cell = cells[col];
100
100
  if (!cell) return;
101
101
 
102
- const cellId = `viz-cell-${row}-${col}`;
102
+ const cellId = `oc-cell-${row}-${col}`;
103
103
  cell.id = cellId;
104
- cell.classList.add('viz-table-cell-focus');
104
+ cell.classList.add('oc-table-cell-focus');
105
105
  cell.setAttribute('data-row', String(row));
106
106
  cell.setAttribute('data-col', String(col));
107
107
 
@@ -219,7 +219,7 @@ export function attachKeyboardNav(options: KeyboardNavOptions): () => void {
219
219
  }
220
220
 
221
221
  // Search escape handling
222
- const searchInput = wrapper.querySelector('.viz-table-search input') as HTMLInputElement | null;
222
+ const searchInput = wrapper.querySelector('.oc-table-search input') as HTMLInputElement | null;
223
223
 
224
224
  function handleSearchKeydown(e: KeyboardEvent): void {
225
225
  if (e.key === 'Escape') {