@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.
@@ -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,7 +855,7 @@ 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));
796
860
  if (annotation.id) {
797
861
  g.setAttribute('data-annotation-id', annotation.id);
@@ -800,7 +864,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
800
864
  // Range rect
801
865
  if (annotation.rect) {
802
866
  const rect = createSVGElement('rect');
803
- rect.setAttribute('class', 'viz-annotation-range');
867
+ rect.setAttribute('class', 'oc-annotation-range');
804
868
  setAttrs(rect, {
805
869
  x: annotation.rect.x,
806
870
  y: annotation.rect.y,
@@ -817,7 +881,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
817
881
  // Reference line
818
882
  if (annotation.line) {
819
883
  const line = createSVGElement('line');
820
- line.setAttribute('class', 'viz-annotation-line');
884
+ line.setAttribute('class', 'oc-annotation-line');
821
885
  setAttrs(line, {
822
886
  x1: annotation.line.start.x,
823
887
  y1: annotation.line.start.y,
@@ -857,7 +921,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
857
921
  const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
858
922
  const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
859
923
  const path = createSVGElement('path');
860
- path.setAttribute('class', 'viz-annotation-connector');
924
+ path.setAttribute('class', 'oc-annotation-connector');
861
925
  setAttrs(path, {
862
926
  d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
863
927
  fill: 'none',
@@ -872,7 +936,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
872
936
  renderCurvedArrow(g, c.from, c.to, c.stroke);
873
937
  } else {
874
938
  const connector = createSVGElement('line');
875
- connector.setAttribute('class', 'viz-annotation-connector');
939
+ connector.setAttribute('class', 'oc-annotation-connector');
876
940
  setAttrs(connector, {
877
941
  x1: c.from.x,
878
942
  y1: c.from.y,
@@ -887,7 +951,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
887
951
  }
888
952
 
889
953
  const text = createSVGElement('text');
890
- text.setAttribute('class', 'viz-annotation-label');
954
+ text.setAttribute('class', 'oc-annotation-label');
891
955
  setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
892
956
  applyTextStyle(text, annotation.label.style);
893
957
 
@@ -919,7 +983,7 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
919
983
  ? annotation.label.x - maxLineWidth / 2 - pad
920
984
  : annotation.label.x - pad;
921
985
  const bgRect = createSVGElement('rect');
922
- bgRect.setAttribute('class', 'viz-annotation-bg');
986
+ bgRect.setAttribute('class', 'oc-annotation-bg');
923
987
  setAttrs(bgRect, {
924
988
  x: bgX,
925
989
  y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
@@ -945,7 +1009,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
945
1009
  if (legend.entries.length === 0) return;
946
1010
 
947
1011
  const g = createSVGElement('g');
948
- g.setAttribute('class', 'viz-legend');
1012
+ g.setAttribute('class', 'oc-legend');
949
1013
  g.setAttribute('role', 'list');
950
1014
  g.setAttribute('aria-label', 'Chart legend');
951
1015
 
@@ -970,7 +1034,7 @@ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
970
1034
  }
971
1035
  }
972
1036
  const entryG = createSVGElement('g');
973
- entryG.setAttribute('class', 'viz-legend-entry');
1037
+ entryG.setAttribute('class', 'oc-legend-entry');
974
1038
  entryG.setAttribute('role', 'listitem');
975
1039
  entryG.setAttribute('data-legend-index', String(i));
976
1040
  entryG.setAttribute('data-legend-label', entry.label);
@@ -1100,7 +1164,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1100
1164
  a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
1101
1165
  a.setAttribute('target', '_blank');
1102
1166
  a.setAttribute('rel', 'noopener');
1103
- a.setAttribute('class', 'viz-chrome-ref');
1167
+ a.setAttribute('class', 'oc-chrome-ref');
1104
1168
 
1105
1169
  // "Open" in normal weight, "Data" in semibold, rendered as a single
1106
1170
  // right-aligned text element with two tspans.
@@ -1141,8 +1205,16 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1141
1205
  * @param container - DOM element to mount the SVG into.
1142
1206
  * @returns The created SVG element.
1143
1207
  */
1144
- export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVGElement {
1208
+ export function renderChartSVG(
1209
+ layout: ChartLayout,
1210
+ container: HTMLElement,
1211
+ opts?: { animate?: boolean },
1212
+ ): SVGElement {
1145
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;
1146
1218
 
1147
1219
  const svg = createSVGElement('svg') as SVGSVGElement;
1148
1220
  setAttrs(svg, {
@@ -1161,7 +1233,39 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1161
1233
  svg.style.height = `${height}px`;
1162
1234
  svg.setAttribute('role', layout.a11y.role);
1163
1235
  svg.setAttribute('aria-label', layout.a11y.altText);
1164
- 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
+ }
1165
1269
 
1166
1270
  // Background
1167
1271
  const bg = createSVGElement('rect');
@@ -1177,7 +1281,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1177
1281
  // Clip path to prevent marks (especially area fills) from overflowing
1178
1282
  // into the chrome region (title/subtitle). Extends full width so
1179
1283
  // end-of-line labels aren't clipped, but constrains vertically.
1180
- const clipId = `viz-clip-${Math.random().toString(36).slice(2, 8)}`;
1284
+ const clipId = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
1181
1285
  const defs = createSVGElement('defs');
1182
1286
  const clipPath = createSVGElement('clipPath');
1183
1287
  clipPath.setAttribute('id', clipId);
@@ -1217,7 +1321,7 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1217
1321
  height: layout.area.height,
1218
1322
  fill: 'transparent',
1219
1323
  });
1220
- overlay.setAttribute('class', 'viz-voronoi-overlay');
1324
+ overlay.setAttribute('class', 'oc-voronoi-overlay');
1221
1325
  overlay.setAttribute('data-voronoi-overlay', 'true');
1222
1326
  clippedGroup.appendChild(overlay);
1223
1327
  }
@@ -1233,6 +1337,9 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1233
1337
  // Brand renders as a footer item, right-aligned on the source/footer row
1234
1338
  renderBrand(svg, layout);
1235
1339
 
1340
+ // Reset module-level animation state after rendering
1341
+ currentAnimation = undefined;
1342
+
1236
1343
  container.appendChild(svg);
1237
1344
  return svg;
1238
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') {