@opendata-ai/openchart-vanilla 7.7.0 → 7.9.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.
@@ -248,7 +248,7 @@ export function createBarList(
248
248
  }
249
249
  }
250
250
 
251
- if (currentLayout.animation?.enabled) {
251
+ if (currentLayout.animation?.enter) {
252
252
  animationCleanup = setupAnimationCleanup(newSvg, () => {
253
253
  if (pendingResize && !destroyed) {
254
254
  pendingResize = false;
@@ -134,7 +134,7 @@ function renderRows(
134
134
  rowGroup.setAttribute('aria-label', row.aria.label);
135
135
  }
136
136
 
137
- if (animation?.enabled) {
137
+ if (animation?.enter) {
138
138
  rowGroup.setAttribute('data-animation-index', String(row.animationIndex));
139
139
  const style = (rowGroup as SVGElement & ElementCSSInlineStyle).style;
140
140
  style.setProperty('--oc-mark-index', String(row.animationIndex));
@@ -236,7 +236,7 @@ export function renderBarListSVG(
236
236
  opts?: { animate?: boolean },
237
237
  ): SVGSVGElement {
238
238
  const { width, height, rows, a11y, watermark, animation } = layout;
239
- const animate = opts?.animate && animation?.enabled;
239
+ const animate = opts?.animate && !!animation?.enter;
240
240
 
241
241
  const svg = createSVGElement('svg') as SVGSVGElement;
242
242
  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
@@ -251,8 +251,8 @@ export function renderBarListSVG(
251
251
  const classes = animate ? 'oc-barlist oc-animate' : 'oc-barlist';
252
252
  svg.setAttribute('class', classes);
253
253
 
254
- if (animate && animation) {
255
- svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
254
+ if (animate && animation?.enter) {
255
+ svg.style.setProperty('--oc-animation-duration', `${animation.enter.duration}ms`);
256
256
  svg.style.setProperty('--oc-animation-stagger', '40ms');
257
257
  }
258
258
 
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export { createGraph } from './graph-mount';
31
31
  export type { ChartInstance, ExportOptions, MountOptions, UpdateOptions } from './mount';
32
32
  // Main mount API
33
33
  export { createChart } from './mount';
34
+ // Geometry helpers for mark path reconstruction
35
+ export { rectPathWithCorners } from './renderers/marks';
34
36
  // Cell renderers
35
37
  export {
36
38
  renderBarCell,
package/src/mount.ts CHANGED
@@ -59,6 +59,7 @@ import { renderChartSVG } from './svg-renderer';
59
59
  import { createTextEditOverlay } from './text-edit-overlay';
60
60
  import { stampThemeProperties } from './theme-tokens';
61
61
  import { createTooltipManager, type TooltipManager } from './tooltip';
62
+ import { canTransition, type GeometrySnapshot, runTransition } from './transition';
62
63
 
63
64
  // ---------------------------------------------------------------------------
64
65
  // Types
@@ -193,6 +194,10 @@ export function createChart<TData extends DataRow = DataRow>(
193
194
  let cleanupAnimations: (() => void) | null = null;
194
195
  let pendingResize = false;
195
196
 
197
+ // Data-update transition state
198
+ let transitionHandle: import('./transition').TransitionHandle | null = null;
199
+ let transitionSnapshot: GeometrySnapshot | null = null;
200
+
196
201
  // Set when webfonts have loaded and a recompile is owed to reflect final font
197
202
  // metrics. The next render() that actually recompiles flips
198
203
  // data-oc-fonts-state to 'ready' and clears this. Deferring the flip (rather
@@ -621,6 +626,12 @@ export function createChart<TData extends DataRow = DataRow>(
621
626
  return;
622
627
  }
623
628
 
629
+ // Cancel any in-progress data-update transition
630
+ if (transitionHandle) {
631
+ transitionHandle.cancel();
632
+ transitionHandle = null;
633
+ }
634
+
624
635
  // Cancel any in-progress entrance animations before tearing down
625
636
  if (cleanupAnimations) {
626
637
  cleanupAnimations();
@@ -665,7 +676,7 @@ export function createChart<TData extends DataRow = DataRow>(
665
676
  }
666
677
 
667
678
  currentLayout = compile();
668
- const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
679
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
669
680
  const crosshair = !!currentLayout.crosshair;
670
681
  svgElement = renderChartSVG(currentLayout, container, {
671
682
  animate: shouldAnimate,
@@ -831,6 +842,19 @@ export function createChart<TData extends DataRow = DataRow>(
831
842
 
832
843
  function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
833
844
  if (destroyed) return;
845
+
846
+ // Capture pre-update state for transition gating
847
+ const prevSpec = currentSpec;
848
+ const prevLayout = currentLayout;
849
+ const entranceWasRunning = cleanupAnimations != null;
850
+
851
+ // Snapshot in-flight transition geometry before render() cancels it.
852
+ // This enables retargeting: the next transition starts from the
853
+ // interrupted position instead of snapping to the previous final state.
854
+ if (transitionHandle?.running) {
855
+ transitionSnapshot = transitionHandle.snapshot();
856
+ }
857
+
834
858
  currentSpec = newSpec;
835
859
  // A new spec can change theme.fonts.family; rebuild the measurer so layout
836
860
  // measures the font compile will actually render with.
@@ -845,6 +869,38 @@ export function createChart<TData extends DataRow = DataRow>(
845
869
  selectedElement = updateOpts.selectedElement ?? null;
846
870
  }
847
871
  render();
872
+
873
+ // After render, check if we can run a smooth transition instead of the
874
+ // instant swap that render() already performed.
875
+ if (
876
+ svgElement &&
877
+ canTransition({
878
+ prevLayout,
879
+ nextLayout: currentLayout,
880
+ prevSpec,
881
+ nextSpec: newSpec,
882
+ isFirstRender: false,
883
+ entranceInFlight: entranceWasRunning,
884
+ })
885
+ ) {
886
+ // Consume snapshot (if any) for retargeting, then clear
887
+ const snapshot = transitionSnapshot;
888
+ transitionSnapshot = null;
889
+
890
+ transitionHandle = runTransition({
891
+ svg: svgElement as SVGSVGElement,
892
+ prevLayout,
893
+ nextLayout: currentLayout,
894
+ animation: currentLayout.animation!,
895
+ fromSnapshot: snapshot ?? undefined,
896
+ onComplete: () => {
897
+ transitionHandle = null;
898
+ },
899
+ });
900
+ } else {
901
+ // No transition started; clear any stale snapshot
902
+ transitionSnapshot = null;
903
+ }
848
904
  }
849
905
 
850
906
  function resize(): void {
@@ -913,6 +969,13 @@ export function createChart<TData extends DataRow = DataRow>(
913
969
  if (destroyed) return;
914
970
  destroyed = true;
915
971
 
972
+ // Cancel any in-progress data-update transition
973
+ if (transitionHandle) {
974
+ transitionHandle.cancel();
975
+ transitionHandle = null;
976
+ }
977
+ transitionSnapshot = null;
978
+
916
979
  if (cleanupAnimations) {
917
980
  cleanupAnimations();
918
981
  cleanupAnimations = null;
@@ -10,6 +10,7 @@ import {
10
10
  TICK_LABEL_OFFSET,
11
11
  textAscent,
12
12
  } from '@opendata-ai/openchart-core';
13
+ import { serializeKeyValue } from '@opendata-ai/openchart-engine';
13
14
  import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
14
15
 
15
16
  function appendCompoundLabel(
@@ -68,10 +69,14 @@ function renderAxis(
68
69
  // was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
69
70
  // Don't add area.x/area.y again or you'll double-offset everything.
70
71
  for (const tick of axis.ticks) {
72
+ // Stable key for data-update transitions
73
+ const tickKey = serializeKeyValue(tick.value);
74
+
71
75
  if (orientation === 'x') {
72
76
  // Label (no tick marks -- gridlines provide sufficient reference)
73
77
  const label = createSVGElement('text');
74
78
  label.setAttribute('class', 'oc-axis-tick');
79
+ label.setAttribute('data-tick-key', tickKey);
75
80
 
76
81
  if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
77
82
  // Rotated labels: anchor at the rotation pivot point
@@ -107,6 +112,7 @@ function renderAxis(
107
112
  // edge, no gutter reserved. The gridline itself is the visual axis.
108
113
  const label = createSVGElement('text');
109
114
  label.setAttribute('class', 'oc-axis-tick oc-axis-tick-inline');
115
+ label.setAttribute('data-tick-key', tickKey);
110
116
  setAttrs(label, {
111
117
  x: area.x,
112
118
  y: tick.position - 6,
@@ -118,6 +124,7 @@ function renderAxis(
118
124
  } else {
119
125
  const label = createSVGElement('text');
120
126
  label.setAttribute('class', 'oc-axis-tick');
127
+ label.setAttribute('data-tick-key', tickKey);
121
128
  setAttrs(label, {
122
129
  x: isRight ? area.x + area.width + TICK_LABEL_OFFSET : area.x - TICK_LABEL_OFFSET,
123
130
  y: tick.position,
@@ -207,9 +214,20 @@ function renderAxis(
207
214
  // Gridlines (positions are also absolute from the scales)
208
215
  // Skip gridlines for right-side y-axis (left y-axis gridlines are sufficient)
209
216
  if (!isRight) {
217
+ // Build position -> tick value map for keying gridlines
218
+ const posToTickKey = new Map<number, string>();
219
+ for (const tick of axis.ticks) {
220
+ posToTickKey.set(tick.position, serializeKeyValue(tick.value));
221
+ }
222
+
210
223
  for (const gridline of axis.gridlines) {
211
224
  const gl = createSVGElement('line');
212
225
  gl.setAttribute('class', 'oc-gridline');
226
+ // Stamp data-tick-key for data-update transitions
227
+ const glKey = posToTickKey.get(gridline.position);
228
+ if (glKey) {
229
+ gl.setAttribute('data-tick-key', glKey);
230
+ }
213
231
  if (orientation === 'y') {
214
232
  setAttrs(gl, {
215
233
  x1: area.x,
@@ -58,10 +58,14 @@ export function resetMarkRenderState(): void {
58
58
  */
59
59
  function stampAnimationAttrs(
60
60
  el: SVGElement,
61
- mark: { animationIndex?: number },
61
+ mark: { animationIndex?: number; key?: string },
62
62
  fallbackIndex: number,
63
63
  ): void {
64
- if (!currentAnimation?.enabled) return;
64
+ // Stamp stable identity key for data-update transitions (always, not just enter)
65
+ if (mark.key) {
66
+ el.setAttribute('data-key', mark.key);
67
+ }
68
+ if (!currentAnimation?.enter) return;
65
69
  const idx = mark.animationIndex ?? fallbackIndex;
66
70
  el.setAttribute('data-animation-index', String(idx));
67
71
  (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
@@ -185,7 +189,7 @@ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
185
189
  * stack, right of a horizontal stack) should round so the seams between
186
190
  * adjacent segments stay flush.
187
191
  */
188
- function _rectPathWithCorners(
192
+ export function rectPathWithCorners(
189
193
  mark: RectMark,
190
194
  sides: NonNullable<RectMark['cornerRadiusSides']>,
191
195
  ): string {
@@ -219,7 +223,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
219
223
  g.setAttribute('class', 'oc-mark oc-mark-rect');
220
224
  stampAnimationAttrs(g, mark, index);
221
225
  // Use engine-provided orientation for animation direction
222
- if (currentAnimation?.enabled && mark.orient === 'horizontal') {
226
+ if (currentAnimation?.enter && mark.orient === 'horizontal') {
223
227
  g.setAttribute('data-orient', 'horizontal');
224
228
  }
225
229
 
@@ -232,7 +236,7 @@ function renderRectMark(mark: RectMark, index: number): SVGElement {
232
236
  !!sides && (!sides.tl || !sides.tr || !sides.br || !sides.bl) && !!mark.cornerRadius;
233
237
  const shapeEl = partialCorners ? createSVGElement('path') : createSVGElement('rect');
234
238
  if (partialCorners) {
235
- shapeEl.setAttribute('d', _rectPathWithCorners(mark, sides));
239
+ shapeEl.setAttribute('d', rectPathWithCorners(mark, sides));
236
240
  } else {
237
241
  setAttrs(shapeEl, {
238
242
  x: mark.x,
@@ -407,6 +411,16 @@ registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
407
411
  registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
408
412
  registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
409
413
 
414
+ /**
415
+ * Render a single mark to an SVG element without appending it anywhere.
416
+ * Used by the transition module to create ghost elements for exiting marks.
417
+ */
418
+ export function renderSingleMark(mark: Mark, index: number): SVGElement | undefined {
419
+ const renderer = markRenderers[mark.type];
420
+ if (!renderer) return undefined;
421
+ return renderer(mark, index);
422
+ }
423
+
410
424
  /** Extract series name from a mark for legend toggle matching. */
411
425
  function getMarkSeries(mark: Mark): string | undefined {
412
426
  // Line and area marks have an explicit seriesKey
@@ -459,7 +473,7 @@ export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement
459
473
 
460
474
  // For stacked segments, set stack position for sequential animation chaining.
461
475
  // stackPos is computed by the engine on RectMark during compilation.
462
- if (currentAnimation?.enabled && mark.type === 'rect') {
476
+ if (currentAnimation?.enter && mark.type === 'rect') {
463
477
  const rect = mark as RectMark;
464
478
  if (rect.stackGroup && rect.stackPos !== undefined) {
465
479
  el.setAttribute('data-stack-pos', String(rect.stackPos));
@@ -503,7 +517,7 @@ export function renderMarks(parent: SVGElement, layout: ChartLayout): SVGElement
503
517
 
504
518
  // Stamp animation index so the CSS stagger delay works for overlay labels
505
519
  // (they're not children of a .oc-mark-rect group that carries --oc-mark-index).
506
- if (currentAnimation?.enabled) {
520
+ if (currentAnimation?.enter) {
507
521
  const idx = rect.animationIndex ?? i;
508
522
  label.setAttribute('data-animation-index', String(idx));
509
523
  (label as SVGElement & ElementCSSInlineStyle).style.setProperty(
@@ -399,7 +399,7 @@ export function createSankey(
399
399
  currentLayout = compile();
400
400
 
401
401
  // Determine if we should animate
402
- const shouldAnimate = isFirstRender && currentLayout.animation?.enabled;
402
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
403
403
  isFirstRender = false;
404
404
 
405
405
  // Render
@@ -547,7 +547,7 @@ export function createSankey(
547
547
  currentLayout = compile();
548
548
 
549
549
  // Determine if we should animate
550
- const shouldAnimate = currentLayout.animation?.enabled;
550
+ const shouldAnimate = !!currentLayout.animation?.enter;
551
551
  isFirstRender = false;
552
552
 
553
553
  // Render
@@ -77,7 +77,7 @@ function stampAnimationAttrs(
77
77
  fallbackIndex: number,
78
78
  animation?: ResolvedAnimation,
79
79
  ): void {
80
- if (!animation?.enabled) return;
80
+ if (!animation?.enter) return;
81
81
  const idx = mark.animationIndex ?? fallbackIndex;
82
82
  el.setAttribute('data-animation-index', String(idx));
83
83
  (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
@@ -560,18 +560,19 @@ export function renderSankeySVG(
560
560
  svg.setAttribute('aria-label', layout.a11y.altText);
561
561
 
562
562
  // Classes: oc-chart oc-sankey, plus oc-animate if animated
563
- const animate = animation?.enabled;
563
+ const enterPhase = animation?.enter;
564
+ const animate = !!enterPhase;
564
565
  const classes = animate ? 'oc-chart oc-sankey oc-animate' : 'oc-chart oc-sankey';
565
566
  svg.setAttribute('class', classes);
566
567
 
567
- // Set animation CSS custom properties when enabled
568
- if (animation?.enabled) {
568
+ // Set animation CSS custom properties when the enter phase is enabled
569
+ if (enterPhase) {
569
570
  const totalMarks = layout.nodes.length + layout.links.length;
570
- const stagger = clampStaggerDelay(animation.staggerDelay, totalMarks);
571
- svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
571
+ const stagger = clampStaggerDelay(enterPhase.staggerDelay, totalMarks);
572
+ svg.style.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
572
573
  svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
573
- svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
574
- const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
574
+ svg.style.setProperty('--oc-annotation-delay', `${animation!.annotationDelay}ms`);
575
+ const easeVar = EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth;
575
576
  svg.style.setProperty('--oc-animation-ease', easeVar);
576
577
  }
577
578
 
package/src/static.ts CHANGED
@@ -8,12 +8,14 @@ import type {
8
8
  LayerSpec,
9
9
  ResolvedTheme,
10
10
  ThemeConfig,
11
+ TileMapSpec,
11
12
  } from '@opendata-ai/openchart-core';
12
- import { adaptForLightLineStroke, isLayerSpec } from '@opendata-ai/openchart-core';
13
- import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
13
+ import { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';
14
+ import { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';
14
15
  import { SVG_NS } from './renderers/svg-dom';
15
16
  import { resetSvgIdCounter } from './svg-ids';
16
17
  import { renderChartSVG } from './svg-renderer';
18
+ import { renderTileMapSVG } from './tilemap-renderer';
17
19
 
18
20
  const esmRequire = createRequire(import.meta.url);
19
21
 
@@ -142,7 +144,7 @@ function stripInteractiveElements(svg: Element): void {
142
144
  * as no code schedules microtasks that outlive the call.
143
145
  */
144
146
  export function renderStaticSVG(
145
- spec: ChartSpec | LayerSpec,
147
+ spec: ChartSpec | LayerSpec | TileMapSpec,
146
148
  options?: StaticRenderOptions,
147
149
  ): string {
148
150
  if (rendering) {
@@ -174,37 +176,45 @@ export function renderStaticSVG(
174
176
  watermark: options?.watermark,
175
177
  };
176
178
 
177
- let layout: ChartLayout;
178
- if (isLayerSpec(spec)) {
179
- layout = compileLayer(spec, compileOpts);
179
+ let svg: SVGElement;
180
+ let themeForStyle: ResolvedTheme;
181
+
182
+ if (isTileMapSpec(spec)) {
183
+ const tileMapLayout = compileTileMap(spec, compileOpts);
184
+ svg = renderTileMapSVG(tileMapLayout, { animate: false });
185
+ themeForStyle = tileMapLayout.theme;
180
186
  } else {
181
- layout = compileChart(spec, compileOpts);
187
+ let layout: ChartLayout;
188
+ if (isLayerSpec(spec)) {
189
+ layout = compileLayer(spec, compileOpts);
190
+ } else {
191
+ layout = compileChart(spec, compileOpts);
192
+ }
193
+
194
+ const container = win.document.createElement('div');
195
+ Object.defineProperty(container, 'getBoundingClientRect', {
196
+ value: () => ({
197
+ width,
198
+ height,
199
+ top: 0,
200
+ left: 0,
201
+ right: width,
202
+ bottom: height,
203
+ x: 0,
204
+ y: 0,
205
+ toJSON: () => ({}),
206
+ }),
207
+ });
208
+
209
+ svg = renderChartSVG(layout, container as unknown as HTMLElement, {
210
+ animate: false,
211
+ crosshair: false,
212
+ });
213
+
214
+ stripInteractiveElements(svg);
215
+ themeForStyle = layout.theme;
182
216
  }
183
217
 
184
- const container = win.document.createElement('div');
185
- Object.defineProperty(container, 'getBoundingClientRect', {
186
- value: () => ({
187
- width,
188
- height,
189
- top: 0,
190
- left: 0,
191
- right: width,
192
- bottom: height,
193
- x: 0,
194
- y: 0,
195
- toJSON: () => ({}),
196
- }),
197
- });
198
-
199
- // renderChartSVG appends to container; the container and SVG are owned by
200
- // the happy-dom Window which is closed in the finally block.
201
- const svg = renderChartSVG(layout, container as unknown as HTMLElement, {
202
- animate: false,
203
- crosshair: false,
204
- });
205
-
206
- stripInteractiveElements(svg);
207
-
208
218
  const doc = win.document as unknown as Document;
209
219
  let defs = svg.querySelector('defs');
210
220
  if (!defs) {
@@ -212,7 +222,7 @@ export function renderStaticSVG(
212
222
  svg.insertBefore(defs as unknown as Node, svg.firstChild);
213
223
  }
214
224
  const styleEl = doc.createElementNS(SVG_NS, 'style');
215
- styleEl.textContent = buildThemeStyleBlock(layout.theme);
225
+ styleEl.textContent = buildThemeStyleBlock(themeForStyle);
216
226
  defs.insertBefore(styleEl as unknown as Node, defs.firstChild);
217
227
 
218
228
  const serializer = new (
@@ -82,14 +82,15 @@ export function renderChartSVG(
82
82
  const classes = opts?.animate ? 'oc-chart oc-animate' : 'oc-chart';
83
83
  svg.setAttribute('class', classes);
84
84
 
85
- // Set animation CSS custom properties when enabled
86
- if (animation?.enabled) {
85
+ // Set animation CSS custom properties when the enter phase is enabled
86
+ const enterPhase = animation?.enter;
87
+ if (enterPhase) {
87
88
  const markCount = layout.marks.length;
88
- const stagger = clampStaggerDelay(animation.staggerDelay, markCount);
89
- svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
89
+ const stagger = clampStaggerDelay(enterPhase.staggerDelay, markCount);
90
+ svg.style.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
90
91
  svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
91
- svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
92
- const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
92
+ svg.style.setProperty('--oc-annotation-delay', `${animation!.annotationDelay}ms`);
93
+ const easeVar = EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth;
93
94
  svg.style.setProperty('--oc-animation-ease', easeVar);
94
95
 
95
96
  // Compute per-segment duration for stacked bars so the total bar animation
@@ -105,7 +106,7 @@ export function renderChartSVG(
105
106
  }
106
107
  }
107
108
  if (maxSegments > 0) {
108
- const segDuration = Math.round(animation.duration / maxSegments);
109
+ const segDuration = Math.round(enterPhase.duration / maxSegments);
109
110
  svg.style.setProperty('--oc-stack-segment-duration', `${segDuration}ms`);
110
111
  }
111
112
  }
@@ -246,7 +246,7 @@ export function createTable(
246
246
  }
247
247
 
248
248
  currentLayout = compile();
249
- const shouldAnimate = isFirstRender && !!currentLayout.animation?.enabled;
249
+ const shouldAnimate = isFirstRender && !!currentLayout.animation?.enter;
250
250
  wrapperElement = renderTable(currentLayout, container, { animate: shouldAnimate });
251
251
 
252
252
  // Set up animation cleanup on first animated render
@@ -417,14 +417,14 @@ export function renderTable(
417
417
 
418
418
  // Animation: stamp CSS custom properties and add oc-animate class BEFORE
419
419
  // DOM insertion to avoid a flash of final state.
420
- if (opts?.animate && layout.animation?.enabled) {
421
- const anim = layout.animation;
420
+ if (opts?.animate && layout.animation?.enter) {
421
+ const enterPhase = layout.animation.enter;
422
422
  const rowCount = layout.rows.length;
423
- const stagger = clampStaggerDelay(anim.staggerDelay, rowCount);
423
+ const stagger = clampStaggerDelay(enterPhase.staggerDelay, rowCount);
424
424
  const s = wrapper.style;
425
- s.setProperty('--oc-animation-duration', `${anim.duration}ms`);
425
+ s.setProperty('--oc-animation-duration', `${enterPhase.duration}ms`);
426
426
  s.setProperty('--oc-animation-stagger', `${stagger}ms`);
427
- s.setProperty('--oc-animation-ease', EASE_VAR_MAP[anim.ease] || EASE_VAR_MAP.smooth);
427
+ s.setProperty('--oc-animation-ease', EASE_VAR_MAP[enterPhase.ease] || EASE_VAR_MAP.smooth);
428
428
  wrapper.classList.add('oc-animate');
429
429
  }
430
430
 
@@ -287,7 +287,7 @@ export function createTileMap(
287
287
  }
288
288
 
289
289
  // Setup animation cleanup only when actually animating
290
- if (animate && currentLayout.animation?.enabled) {
290
+ if (animate && currentLayout.animation?.enter) {
291
291
  animationCleanup = setupAnimationCleanup(newSvg, () => {
292
292
  // On animation complete, check if resize was pending
293
293
  if (pendingResize && !destroyed) {
@@ -13,6 +13,7 @@ import type {
13
13
  } from '@opendata-ai/openchart-core';
14
14
  import { textAscent } from '@opendata-ai/openchart-core';
15
15
  import { renderLegend } from './renderers/legend';
16
+ import { nextSvgId } from './svg-ids';
16
17
 
17
18
  const SVG_NS = 'http://www.w3.org/2000/svg';
18
19
  const XLINK_NS = 'http://www.w3.org/1999/xlink';
@@ -23,8 +24,6 @@ const EASE_VAR_MAP: Record<string, string> = {
23
24
  snappy: 'var(--oc-ease-snappy)',
24
25
  };
25
26
 
26
- let gradientIdCounter = 0;
27
-
28
27
  // ---------------------------------------------------------------------------
29
28
  // Helpers
30
29
  // ---------------------------------------------------------------------------
@@ -211,7 +210,7 @@ function renderTiles(
211
210
  // Base delay spreads tiles across ~800ms window, jitter adds +-40% variation
212
211
  // so some tiles pop in clusters while others have longer gaps.
213
212
  const tileDelays: number[] = [];
214
- if (animation?.enabled) {
213
+ if (animation?.enter) {
215
214
  const baseStagger = 800 / Math.max(tiles.length, 1);
216
215
  let seed = 17;
217
216
  for (let i = 0; i < tiles.length; i++) {
@@ -232,7 +231,7 @@ function renderTiles(
232
231
  tileGroup.setAttribute('aria-label', tile.aria.label);
233
232
  }
234
233
 
235
- if (animation?.enabled) {
234
+ if (animation?.enter) {
236
235
  const idx = tile.animationIndex ?? i;
237
236
  tileGroup.setAttribute('data-animation-index', String(idx));
238
237
  (tileGroup as SVGElement & ElementCSSInlineStyle).style.setProperty(
@@ -322,7 +321,7 @@ function renderGradientLegend(parent: SVGElement, layout: TileMapLayout): void {
322
321
  parent.insertBefore(defs, parent.firstChild);
323
322
  }
324
323
 
325
- const gradientId = `oc-tilemap-legend-gradient-${gradientIdCounter++}`;
324
+ const gradientId = nextSvgId('oc-tilemap-legend-gradient');
326
325
  const grad = createSVGElement('linearGradient');
327
326
  grad.id = gradientId;
328
327
  grad.setAttribute('x1', '0%');
@@ -407,7 +406,7 @@ export function renderTileMapSVG(
407
406
  opts?: { animate?: boolean },
408
407
  ): SVGSVGElement {
409
408
  const { width, height, tiles, a11y, watermark, animation } = layout;
410
- const animate = opts?.animate && animation?.enabled;
409
+ const animate = opts?.animate && !!animation?.enter;
411
410
 
412
411
  const svg = createSVGElement('svg') as SVGSVGElement;
413
412
  svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
@@ -419,13 +418,13 @@ export function renderTileMapSVG(
419
418
  const classes = animate ? 'oc-tilemap oc-animate' : 'oc-tilemap';
420
419
  svg.setAttribute('class', classes);
421
420
 
422
- if (animate && animation) {
421
+ if (animate && animation?.enter) {
423
422
  // Target ~1s total: stagger window ~800ms + per-tile pop ~200ms
424
423
  const stagger = Math.max(5, Math.round(800 / Math.max(tiles.length, 1)));
425
- svg.style.setProperty('--oc-animation-duration', `${animation.duration}ms`);
424
+ svg.style.setProperty('--oc-animation-duration', `${animation.enter.duration}ms`);
426
425
  svg.style.setProperty('--oc-animation-stagger', `${stagger}ms`);
427
426
  svg.style.setProperty('--oc-annotation-delay', `${animation.annotationDelay}ms`);
428
- const easeVar = EASE_VAR_MAP[animation.ease] || EASE_VAR_MAP.smooth;
427
+ const easeVar = EASE_VAR_MAP[animation.enter.ease] || EASE_VAR_MAP.smooth;
429
428
  svg.style.setProperty('--oc-animation-ease', easeVar);
430
429
  }
431
430