@opendata-ai/openchart-engine 6.3.0 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.3.0",
3
+ "version": "6.5.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.3.0",
48
+ "@opendata-ai/openchart-core": "6.5.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Integration tests for animation in the chart compilation pipeline.
3
+ *
4
+ * Verifies that animation specs flow through compileChart() correctly:
5
+ * resolved animation on the layout, animationIndex on marks for value-based
6
+ * stagger ordering, and breakpoint override behavior.
7
+ */
8
+
9
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { compileChart } from '../compile';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Test data
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const barSpec: ChartSpec = {
18
+ mark: 'bar',
19
+ data: [
20
+ { name: 'A', value: 10 },
21
+ { name: 'B', value: 30 },
22
+ { name: 'C', value: 20 },
23
+ ],
24
+ encoding: {
25
+ x: { field: 'value', type: 'quantitative' },
26
+ y: { field: 'name', type: 'nominal' },
27
+ },
28
+ };
29
+
30
+ const columnSpec: ChartSpec = {
31
+ mark: 'bar',
32
+ data: [
33
+ { category: 'Q1', revenue: 100 },
34
+ { category: 'Q2', revenue: 300 },
35
+ { category: 'Q3', revenue: 200 },
36
+ ],
37
+ encoding: {
38
+ x: { field: 'category', type: 'nominal' },
39
+ y: { field: 'revenue', type: 'quantitative' },
40
+ },
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tests
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('compileChart with animation', () => {
48
+ it('includes resolved animation in layout when animation: true', () => {
49
+ const spec = { ...barSpec, animation: true } as ChartSpec;
50
+ const layout = compileChart(spec, { width: 600, height: 400 });
51
+
52
+ expect(layout.animation).toBeDefined();
53
+ expect(layout.animation!.enabled).toBe(true);
54
+ expect(layout.animation!.duration).toBe(500);
55
+ expect(layout.animation!.ease).toBe('smooth');
56
+ expect(layout.animation!.staggerDelay).toBe(80);
57
+ expect(layout.animation!.staggerOrder).toBe('index');
58
+ expect(layout.animation!.annotationDelay).toBe(200);
59
+ });
60
+
61
+ it('omits animation from layout when animation is not specified', () => {
62
+ const layout = compileChart(barSpec, { width: 600, height: 400 });
63
+ expect(layout.animation).toBeUndefined();
64
+ });
65
+
66
+ it('omits animation from layout when animation is false', () => {
67
+ const spec = { ...barSpec, animation: false } as ChartSpec;
68
+ const layout = compileChart(spec, { width: 600, height: 400 });
69
+ expect(layout.animation).toBeUndefined();
70
+ });
71
+
72
+ it('resolves custom animation config through compilation', () => {
73
+ const spec = {
74
+ ...barSpec,
75
+ animation: {
76
+ enter: { duration: 800, ease: 'smooth' as const },
77
+ annotationDelay: 500,
78
+ },
79
+ } as ChartSpec;
80
+ const layout = compileChart(spec, { width: 600, height: 400 });
81
+
82
+ expect(layout.animation).toBeDefined();
83
+ expect(layout.animation!.duration).toBe(800);
84
+ expect(layout.animation!.ease).toBe('smooth');
85
+ expect(layout.animation!.annotationDelay).toBe(500);
86
+ });
87
+
88
+ it('computes animationIndex on marks when stagger order is value', () => {
89
+ const spec = {
90
+ ...columnSpec,
91
+ animation: {
92
+ enter: { stagger: { order: 'value' as const } },
93
+ },
94
+ } as ChartSpec;
95
+ const layout = compileChart(spec, { width: 600, height: 400 });
96
+
97
+ // Should have rect marks for the 3 data points
98
+ const rectMarks = layout.marks.filter((m) => m.type === 'rect');
99
+ expect(rectMarks.length).toBe(3);
100
+
101
+ // Each mark should have an animationIndex assigned
102
+ const indices = rectMarks.map(
103
+ (m) => (m as unknown as { animationIndex?: number }).animationIndex,
104
+ );
105
+ for (const idx of indices) {
106
+ expect(idx).toBeDefined();
107
+ expect(typeof idx).toBe('number');
108
+ }
109
+
110
+ // Indices should be unique and sequential (0, 1, 2)
111
+ const sorted = [...indices].sort((a, b) => a! - b!);
112
+ expect(sorted).toEqual([0, 1, 2]);
113
+ });
114
+
115
+ it('does not assign animationIndex when stagger order is index (default)', () => {
116
+ const spec = { ...columnSpec, animation: true } as ChartSpec;
117
+ const layout = compileChart(spec, { width: 600, height: 400 });
118
+
119
+ // With staggerOrder='index' (default), animationIndex is not explicitly set
120
+ const rectMarks = layout.marks.filter((m) => m.type === 'rect');
121
+ const indices = rectMarks.map(
122
+ (m) => (m as unknown as { animationIndex?: number }).animationIndex,
123
+ );
124
+ // Should all be undefined since value ordering isn't enabled
125
+ for (const idx of indices) {
126
+ expect(idx).toBeUndefined();
127
+ }
128
+ });
129
+
130
+ it('applies breakpoint animation override', () => {
131
+ // Compact breakpoint is < 400px width
132
+ const spec = {
133
+ ...barSpec,
134
+ animation: true,
135
+ overrides: {
136
+ compact: { animation: false },
137
+ },
138
+ } as ChartSpec;
139
+
140
+ // At compact width (< 400), the breakpoint override should disable animation.
141
+ // Breakpoint overrides take precedence over spec-level animation (matching
142
+ // how chrome, labels, legend, and annotation overrides work).
143
+ const layout = compileChart(spec, { width: 350, height: 400 });
144
+ expect(layout.animation).toBeUndefined();
145
+
146
+ // Test the case where spec-level animation is not set and override provides it
147
+ const specNoAnim = {
148
+ ...barSpec,
149
+ overrides: {
150
+ compact: { animation: true },
151
+ },
152
+ } as ChartSpec;
153
+ const layoutCompact = compileChart(specNoAnim, { width: 350, height: 400 });
154
+ expect(layoutCompact.animation).toBeDefined();
155
+ expect(layoutCompact.animation!.enabled).toBe(true);
156
+
157
+ // At full width (> 700), no override applies, so no animation
158
+ const layoutFull = compileChart(specNoAnim, { width: 800, height: 400 });
159
+ expect(layoutFull.animation).toBeUndefined();
160
+ });
161
+ });
@@ -363,6 +363,7 @@ function resolveTextAnnotation(
363
363
 
364
364
  return {
365
365
  type: 'text',
366
+ id: annotation.id,
366
367
  label,
367
368
  stroke: annotation.stroke,
368
369
  fill: annotation.fill,
@@ -447,6 +448,7 @@ function resolveRangeAnnotation(
447
448
 
448
449
  return {
449
450
  type: 'range',
451
+ id: annotation.id,
450
452
  rect,
451
453
  label,
452
454
  fill: annotation.fill ?? DEFAULT_RANGE_FILL,
@@ -528,6 +530,7 @@ function resolveRefLineAnnotation(
528
530
 
529
531
  return {
530
532
  type: 'refline',
533
+ id: annotation.id,
531
534
  line: { start, end },
532
535
  label,
533
536
  stroke: annotation.stroke ?? defaultStroke,
@@ -155,6 +155,8 @@ function computeStackedBars(
155
155
  cornerRadius: 0,
156
156
  data: row as Record<string, unknown>,
157
157
  aria,
158
+ orient: 'horizontal',
159
+ stackGroup: category,
158
160
  });
159
161
 
160
162
  cumulativeValue += value;
@@ -214,6 +216,7 @@ function computeSimpleBars(
214
216
  cornerRadius: 2,
215
217
  data: row as Record<string, unknown>,
216
218
  aria,
219
+ orient: 'horizontal',
217
220
  });
218
221
  }
219
222
 
@@ -183,6 +183,7 @@ function computeSimpleColumns(
183
183
  cornerRadius: 2,
184
184
  data: row as Record<string, unknown>,
185
185
  aria,
186
+ orient: 'vertical',
186
187
  });
187
188
  }
188
189
 
@@ -233,6 +234,7 @@ function computeColoredColumns(
233
234
  cornerRadius: 2,
234
235
  data: row as Record<string, unknown>,
235
236
  aria,
237
+ orient: 'vertical',
236
238
  });
237
239
  }
238
240
 
@@ -287,6 +289,8 @@ function computeStackedColumns(
287
289
  cornerRadius: 0,
288
290
  data: row as Record<string, unknown>,
289
291
  aria,
292
+ orient: 'vertical',
293
+ stackGroup: category,
290
294
  });
291
295
 
292
296
  cumulativeValue += value;
package/src/compile.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type {
15
+ AnimationSpec,
15
16
  ChartLayout,
16
17
  ChartSpec,
17
18
  CompileOptions,
@@ -82,6 +83,7 @@ for (const [type, renderer] of Object.entries(builtinRenderers)) {
82
83
  registerChartRenderer(type, renderer);
83
84
  }
84
85
 
86
+ import { resolveAnimation } from './compiler/animation';
85
87
  import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
86
88
  import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
87
89
  import type { GraphCompilation } from './graphs/types';
@@ -275,6 +277,12 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
275
277
  }
276
278
  }
277
279
 
280
+ // Resolve animation spec. Breakpoint override wins over base spec (matching
281
+ // chrome, labels, legend, and annotation override precedence).
282
+ const rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
283
+ ?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
284
+ const resolvedAnimation = resolveAnimation(rawAnimationSpec);
285
+
278
286
  // Resolve theme: merge spec-level theme with options-level overrides
279
287
  const mergedThemeConfig = options.theme
280
288
  ? { ...chartSpec.theme, ...options.theme }
@@ -468,6 +476,46 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
468
476
  chartSpec.data,
469
477
  );
470
478
 
479
+ // Assign animationIndex for stagger ordering when animation is enabled
480
+ // Assign animationIndex for value-based stagger ordering. Skip stacked rects
481
+ // since they get group-based indices below (avoids wasted work that gets overwritten).
482
+ if (resolvedAnimation?.enabled && resolvedAnimation.staggerOrder === 'value') {
483
+ const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
484
+ indexed.sort((a, b) => {
485
+ const av = getMarkPrimaryValue(a.mark);
486
+ const bv = getMarkPrimaryValue(b.mark);
487
+ return av - bv;
488
+ });
489
+ for (let i = 0; i < indexed.length; i++) {
490
+ const m = indexed[i].mark;
491
+ if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
492
+ m.animationIndex = i;
493
+ }
494
+ }
495
+
496
+ // For stacked bars/columns, assign the same animationIndex to all segments
497
+ // sharing a stackGroup so they animate as one contiguous bar per category.
498
+ // Also compute stackPos (segment position within each group: 0, 1, 2...)
499
+ // so the renderer can chain segment animations sequentially.
500
+ if (resolvedAnimation?.enabled) {
501
+ const groupIndexMap = new Map<string, number>();
502
+ const groupStackPos = new Map<string, number>();
503
+ let nextGroupIndex = 0;
504
+ for (const mark of marks) {
505
+ if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
506
+ const rect = mark as RectMark;
507
+ const group = rect.stackGroup!;
508
+ if (!groupIndexMap.has(group)) {
509
+ groupIndexMap.set(group, nextGroupIndex++);
510
+ }
511
+ rect.animationIndex = groupIndexMap.get(group)!;
512
+ const pos = groupStackPos.get(group) ?? 0;
513
+ rect.stackPos = pos;
514
+ groupStackPos.set(group, pos + 1);
515
+ }
516
+ }
517
+ }
518
+
471
519
  return {
472
520
  area: chartArea,
473
521
  chrome: dims.chrome,
@@ -490,9 +538,27 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
490
538
  width: options.width,
491
539
  height: options.height,
492
540
  },
541
+ animation: resolvedAnimation,
493
542
  };
494
543
  }
495
544
 
545
+ /** Extract the primary quantitative value from a mark for value-based stagger ordering. */
546
+ function getMarkPrimaryValue(mark: Mark): number {
547
+ switch (mark.type) {
548
+ case 'rect':
549
+ return mark.height; // bar height is the primary value encoding
550
+ case 'point':
551
+ return mark.cy; // y position for scatter
552
+ case 'arc':
553
+ return mark.endAngle - mark.startAngle; // arc angle extent
554
+ case 'line':
555
+ case 'area':
556
+ return 0; // series marks don't have individual values
557
+ default:
558
+ return 0;
559
+ }
560
+ }
561
+
496
562
  // ---------------------------------------------------------------------------
497
563
  // Layer compilation
498
564
  // ---------------------------------------------------------------------------
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Tests for the animation resolver: resolveAnimation() and clampStaggerDelay().
3
+ *
4
+ * These are pure functions that normalize the various AnimationSpec shorthand
5
+ * forms into a fully resolved config with all defaults filled in.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { clampStaggerDelay, resolveAnimation } from '../animation';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // resolveAnimation
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe('resolveAnimation', () => {
16
+ it('returns undefined for false', () => {
17
+ expect(resolveAnimation(false)).toBeUndefined();
18
+ });
19
+
20
+ it('returns undefined for undefined', () => {
21
+ expect(resolveAnimation(undefined)).toBeUndefined();
22
+ });
23
+
24
+ it('returns defaults for true', () => {
25
+ const result = resolveAnimation(true);
26
+ expect(result).toEqual({
27
+ enabled: true,
28
+ duration: 500,
29
+ ease: 'smooth',
30
+ staggerDelay: 80,
31
+ staggerOrder: 'index',
32
+ annotationDelay: 200,
33
+ });
34
+ });
35
+
36
+ it('resolves AnimationConfig with enter: true', () => {
37
+ const result = resolveAnimation({ enter: true });
38
+ expect(result).toBeDefined();
39
+ expect(result!.enabled).toBe(true);
40
+ expect(result!.duration).toBe(500);
41
+ expect(result!.ease).toBe('smooth');
42
+ expect(result!.staggerDelay).toBe(80);
43
+ expect(result!.staggerOrder).toBe('index');
44
+ expect(result!.annotationDelay).toBe(200);
45
+ });
46
+
47
+ it('resolves custom enter config', () => {
48
+ const result = resolveAnimation({
49
+ enter: {
50
+ duration: 800,
51
+ ease: 'smooth',
52
+ stagger: { delay: 40, order: 'value' },
53
+ },
54
+ });
55
+ expect(result).toBeDefined();
56
+ expect(result!.duration).toBe(800);
57
+ expect(result!.ease).toBe('smooth');
58
+ expect(result!.staggerDelay).toBe(40);
59
+ expect(result!.staggerOrder).toBe('value');
60
+ });
61
+
62
+ it('returns undefined when enter is false', () => {
63
+ expect(resolveAnimation({ enter: false })).toBeUndefined();
64
+ });
65
+
66
+ it('resolves stagger: false to staggerDelay: 0', () => {
67
+ const result = resolveAnimation({ enter: { stagger: false } });
68
+ expect(result).toBeDefined();
69
+ expect(result!.staggerDelay).toBe(0);
70
+ });
71
+
72
+ it('uses custom annotationDelay', () => {
73
+ const result = resolveAnimation({ enter: true, annotationDelay: 500 });
74
+ expect(result).toBeDefined();
75
+ expect(result!.annotationDelay).toBe(500);
76
+ });
77
+
78
+ it('returns undefined when no phase is specified', () => {
79
+ // Empty config with no enter/update/exit should not produce animation
80
+ expect(resolveAnimation({})).toBeUndefined();
81
+ });
82
+
83
+ it('preserves default annotationDelay when not overridden', () => {
84
+ const result = resolveAnimation({ enter: { duration: 1000 } });
85
+ expect(result!.annotationDelay).toBe(200);
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // clampStaggerDelay
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe('clampStaggerDelay', () => {
94
+ it('returns 0 for single element', () => {
95
+ expect(clampStaggerDelay(30, 1)).toBe(0);
96
+ });
97
+
98
+ it('returns 0 for zero elements', () => {
99
+ expect(clampStaggerDelay(30, 0)).toBe(0);
100
+ });
101
+
102
+ it('returns delay unchanged for small counts', () => {
103
+ // 30 * 10 = 300, well under 2000ms cap
104
+ expect(clampStaggerDelay(30, 10)).toBe(30);
105
+ });
106
+
107
+ it('clamps delay for large counts', () => {
108
+ // 30 * 200 = 6000 > 2000, so clamp to 2000/200 = 10
109
+ expect(clampStaggerDelay(30, 200)).toBe(10);
110
+ });
111
+
112
+ it('clamps to cap total at 2000ms', () => {
113
+ // 50 * 100 = 5000 > 2000, so clamp to 2000/100 = 20
114
+ expect(clampStaggerDelay(50, 100)).toBe(20);
115
+ });
116
+
117
+ it('does not increase delay when already under the cap', () => {
118
+ // 5 * 50 = 250 < 2000, keeps at 5
119
+ expect(clampStaggerDelay(5, 50)).toBe(5);
120
+ });
121
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Animation resolver: normalizes AnimationSpec into fully resolved config.
3
+ *
4
+ * Handles the shorthand forms:
5
+ * - true -> { enter: true } -> full defaults
6
+ * - { enter: { duration: 800 } } -> merge with defaults
7
+ * - false/undefined -> undefined (no animation)
8
+ */
9
+
10
+ import type {
11
+ AnimationConfig,
12
+ AnimationEase,
13
+ AnimationPhaseConfig,
14
+ AnimationSpec,
15
+ AnimationStagger,
16
+ ResolvedAnimation,
17
+ } from '@opendata-ai/openchart-core';
18
+
19
+ /** Default values for entrance animation. */
20
+ const ENTER_DEFAULTS = {
21
+ duration: 500,
22
+ ease: 'smooth' as AnimationEase,
23
+ staggerDelay: 80,
24
+ staggerOrder: 'index' as const,
25
+ annotationDelay: 200,
26
+ } as const;
27
+
28
+ /** Maximum total stagger time in ms. Prevents 200-bar charts from taking 6s. */
29
+ const MAX_TOTAL_STAGGER_MS = 2000;
30
+
31
+ /**
32
+ * Resolve an AnimationSpec into a fully resolved config with all defaults filled.
33
+ * Returns undefined if animation is disabled (false or omitted).
34
+ */
35
+ export function resolveAnimation(spec: AnimationSpec | undefined): ResolvedAnimation | undefined {
36
+ if (spec === undefined || spec === false) return undefined;
37
+
38
+ // true -> default enter animation
39
+ if (spec === true) {
40
+ return {
41
+ enabled: true,
42
+ duration: ENTER_DEFAULTS.duration,
43
+ ease: ENTER_DEFAULTS.ease,
44
+ staggerDelay: ENTER_DEFAULTS.staggerDelay,
45
+ staggerOrder: ENTER_DEFAULTS.staggerOrder,
46
+ annotationDelay: ENTER_DEFAULTS.annotationDelay,
47
+ };
48
+ }
49
+
50
+ // AnimationConfig object
51
+ const config = spec as AnimationConfig;
52
+
53
+ // If no enter phase specified or enter is false, no animation
54
+ if (config.enter === false || (config.enter === undefined && !hasAnyPhase(config))) {
55
+ return undefined;
56
+ }
57
+
58
+ const enterConfig = resolvePhaseConfig(config.enter);
59
+
60
+ return {
61
+ enabled: true,
62
+ duration: enterConfig.duration,
63
+ ease: enterConfig.ease,
64
+ staggerDelay: enterConfig.staggerDelay,
65
+ staggerOrder: enterConfig.staggerOrder,
66
+ annotationDelay: config.annotationDelay ?? ENTER_DEFAULTS.annotationDelay,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Clamp stagger delay so total stagger time doesn't exceed MAX_TOTAL_STAGGER_MS.
72
+ */
73
+ export function clampStaggerDelay(delay: number, elementCount: number): number {
74
+ if (elementCount <= 1) return 0;
75
+ return Math.min(delay, MAX_TOTAL_STAGGER_MS / elementCount);
76
+ }
77
+
78
+ function hasAnyPhase(config: AnimationConfig): boolean {
79
+ return config.enter !== undefined || config.update !== undefined || config.exit !== undefined;
80
+ }
81
+
82
+ interface ResolvedPhase {
83
+ duration: number;
84
+ ease: AnimationEase;
85
+ staggerDelay: number;
86
+ staggerOrder: 'index' | 'value' | 'reverse';
87
+ }
88
+
89
+ function resolvePhaseConfig(phase: AnimationPhaseConfig | boolean | undefined): ResolvedPhase {
90
+ if (phase === undefined || phase === true) {
91
+ return {
92
+ duration: ENTER_DEFAULTS.duration,
93
+ ease: ENTER_DEFAULTS.ease,
94
+ staggerDelay: ENTER_DEFAULTS.staggerDelay,
95
+ staggerOrder: ENTER_DEFAULTS.staggerOrder,
96
+ };
97
+ }
98
+
99
+ const cfg = phase as AnimationPhaseConfig;
100
+ const stagger = resolveStagger(cfg.stagger);
101
+
102
+ return {
103
+ duration: cfg.duration ?? ENTER_DEFAULTS.duration,
104
+ ease: cfg.ease ?? ENTER_DEFAULTS.ease,
105
+ staggerDelay: stagger.delay,
106
+ staggerOrder: stagger.order,
107
+ };
108
+ }
109
+
110
+ function resolveStagger(stagger: AnimationStagger | boolean | undefined): {
111
+ delay: number;
112
+ order: 'index' | 'value' | 'reverse';
113
+ } {
114
+ if (stagger === false) return { delay: 0, order: 'index' };
115
+ if (stagger === undefined || stagger === true) {
116
+ return { delay: ENTER_DEFAULTS.staggerDelay, order: ENTER_DEFAULTS.staggerOrder };
117
+ }
118
+ return {
119
+ delay: stagger.delay ?? ENTER_DEFAULTS.staggerDelay,
120
+ order: stagger.order ?? ENTER_DEFAULTS.staggerOrder,
121
+ };
122
+ }
@@ -229,6 +229,7 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab
229
229
  stickyFirstColumn: spec.stickyFirstColumn ?? false,
230
230
  compact: spec.compact ?? false,
231
231
  responsive: spec.responsive ?? true,
232
+ animation: spec.animation,
232
233
  };
233
234
  }
234
235
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  AggregateOp,
11
+ AnimationSpec,
11
12
  Annotation,
12
13
  AxisConfig,
13
14
  ChromeText,
@@ -95,6 +96,7 @@ export interface NormalizedTableSpec {
95
96
  stickyFirstColumn: boolean;
96
97
  compact: boolean;
97
98
  responsive: boolean;
99
+ animation?: AnimationSpec;
98
100
  }
99
101
 
100
102
  /** A GraphSpec with all optional fields filled with sensible defaults. */
package/src/index.ts CHANGED
@@ -14,6 +14,12 @@
14
14
 
15
15
  export { compileChart, compileGraph, compileLayer, compileTable } from './compile';
16
16
 
17
+ // ---------------------------------------------------------------------------
18
+ // Animation resolution
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export { clampStaggerDelay, resolveAnimation } from './compiler/animation';
22
+
17
23
  // ---------------------------------------------------------------------------
18
24
  // Graph compilation types
19
25
  // ---------------------------------------------------------------------------
@@ -205,4 +205,32 @@ describe('compileTable', () => {
205
205
  const layout = compileTable(baseSpec, { ...baseOptions, darkMode: true });
206
206
  expect(layout.theme.isDark).toBe(true);
207
207
  });
208
+
209
+ it('includes resolved animation when spec has animation: true', () => {
210
+ const layout = compileTable({ ...baseSpec, animation: true }, baseOptions);
211
+ expect(layout.animation).toBeDefined();
212
+ expect(layout.animation!.enabled).toBe(true);
213
+ expect(layout.animation!.duration).toBe(500);
214
+ expect(layout.animation!.staggerDelay).toBe(80);
215
+ });
216
+
217
+ it('includes resolved animation with custom config', () => {
218
+ const layout = compileTable(
219
+ { ...baseSpec, animation: { enter: { duration: 800, ease: 'snappy' } } },
220
+ baseOptions,
221
+ );
222
+ expect(layout.animation).toBeDefined();
223
+ expect(layout.animation!.duration).toBe(800);
224
+ expect(layout.animation!.ease).toBe('snappy');
225
+ });
226
+
227
+ it('does not include animation when spec omits it', () => {
228
+ const layout = compileTable(baseSpec, baseOptions);
229
+ expect(layout.animation).toBeUndefined();
230
+ });
231
+
232
+ it('does not include animation when spec has animation: false', () => {
233
+ const layout = compileTable({ ...baseSpec, animation: false }, baseOptions);
234
+ expect(layout.animation).toBeUndefined();
235
+ });
208
236
  });
@@ -19,6 +19,7 @@ import type {
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
21
21
 
22
+ import { resolveAnimation } from '../compiler/animation';
22
23
  import type { NormalizedTableSpec } from '../compiler/types';
23
24
  import { computeBarCell, computeColumnMax, computeColumnMin } from './bar-column';
24
25
  import { computeCategoryColors } from './category-colors';
@@ -416,5 +417,6 @@ export function compileTableLayout(
416
417
  summary: `${resolvedColumns.length} columns, ${totalFiltered} rows`,
417
418
  },
418
419
  theme,
420
+ animation: resolveAnimation(spec.animation),
419
421
  };
420
422
  }