@opendata-ai/openchart-engine 7.8.0 → 7.9.1

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.
@@ -1,13 +1,18 @@
1
1
  /**
2
- * Curve interpolation mapping.
2
+ * Curve interpolation mapping and path builders.
3
3
  *
4
4
  * Maps Vega-Lite-style interpolation strings to d3-shape curve factories.
5
5
  * Used by both line and area chart computations.
6
+ *
7
+ * Also exports pure path-builder helpers (`buildLinePath`, `buildAreaPath`)
8
+ * so downstream consumers (e.g. transition interpolators) can reconstruct
9
+ * SVG paths from point arrays without pulling in chart-specific code.
6
10
  */
7
11
 
8
- import type { MarkDef } from '@opendata-ai/openchart-core';
12
+ import type { MarkDef, Point } from '@opendata-ai/openchart-core';
9
13
  import type { CurveFactory } from 'd3-shape';
10
14
  import {
15
+ area,
11
16
  curveBasis,
12
17
  curveCardinal,
13
18
  curveLinear,
@@ -16,6 +21,7 @@ import {
16
21
  curveStep,
17
22
  curveStepAfter,
18
23
  curveStepBefore,
24
+ line,
19
25
  } from 'd3-shape';
20
26
 
21
27
  /** Map of interpolation string names to d3 curve factories. */
@@ -38,3 +44,42 @@ export function resolveCurve(interpolate?: MarkDef['interpolate']): CurveFactory
38
44
  if (!interpolate) return curveMonotoneX;
39
45
  return CURVE_MAP[interpolate] ?? curveMonotoneX;
40
46
  }
47
+
48
+ /**
49
+ * Build an SVG path string from an array of points using d3 line generator.
50
+ * Accepts an optional interpolation string (defaults to 'monotone').
51
+ */
52
+ export function buildLinePath(points: Point[], interpolate?: string): string {
53
+ if (points.length === 0) return '';
54
+ const curve = resolveCurve(interpolate as MarkDef['interpolate']);
55
+ const generator = line<Point>()
56
+ .x((d) => d.x)
57
+ .y((d) => d.y)
58
+ .curve(curve);
59
+ return generator(points) ?? '';
60
+ }
61
+
62
+ /**
63
+ * Build an SVG path string for a filled area from top and bottom point arrays.
64
+ * Accepts an optional interpolation string (defaults to 'monotone').
65
+ */
66
+ export function buildAreaPath(
67
+ topPoints: Point[],
68
+ bottomPoints: Point[],
69
+ interpolate?: string,
70
+ ): string {
71
+ if (topPoints.length === 0) return '';
72
+ const curve = resolveCurve(interpolate as MarkDef['interpolate']);
73
+ // Pair top and bottom points for the area generator
74
+ const paired = topPoints.map((tp, i) => ({
75
+ x: tp.x,
76
+ yTop: tp.y,
77
+ yBottom: bottomPoints[i]?.y ?? 0,
78
+ }));
79
+ const generator = area<{ x: number; yTop: number; yBottom: number }>()
80
+ .x((d) => d.x)
81
+ .y0((d) => d.yBottom)
82
+ .y1((d) => d.yTop)
83
+ .curve(curve);
84
+ return generator(paired) ?? '';
85
+ }
@@ -20,6 +20,7 @@ import { isConditionalDef, isGradientDef } from '@opendata-ai/openchart-core';
20
20
  import type { PieArcDatum } from 'd3-shape';
21
21
  import { arc as d3Arc, pie as d3Pie } from 'd3-shape';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { resolveConditionalValue } from '../../transforms/conditional';
@@ -252,5 +253,17 @@ export function computePieMarks(
252
253
  });
253
254
  }
254
255
 
256
+ // Stamp keys: slice label (category name) as the natural identity
257
+ const rawKeys = marks.map((m) => {
258
+ const label = categoryField
259
+ ? String(m.data[categoryField] ?? '')
260
+ : String(m.data.label ?? m.data.name ?? m.data.category ?? '');
261
+ return serializeKeyValue(label);
262
+ });
263
+ const keys = dedupeKeys(rawKeys);
264
+ for (let i = 0; i < marks.length; i++) {
265
+ marks[i].key = keys[i];
266
+ }
267
+
255
268
  return marks;
256
269
  }
@@ -188,11 +188,11 @@ export function assignAnimationIndices(
188
188
  marks: Mark[],
189
189
  animation: ResolvedAnimation | undefined,
190
190
  ): void {
191
- if (!animation?.enabled) return;
191
+ if (!animation?.enter) return;
192
192
 
193
193
  // Phase 1: Value-based stagger ordering. Skip stacked rects
194
194
  // since they get group-based indices below (avoids wasted work that gets overwritten).
195
- if (animation.staggerOrder === 'value') {
195
+ if (animation.enter.staggerOrder === 'value') {
196
196
  const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
197
197
  indexed.sort((a, b) => {
198
198
  const av = getMarkPrimaryValue(a.mark);
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -119,6 +120,14 @@ export function computeRuleMarks(
119
120
  });
120
121
  }
121
122
 
123
+ // Stamp keys: position-defining values, fall back to index
124
+ const posField = xChannel?.field ?? yChannel?.field;
125
+ const rawKeys = marks.map((m, i) => (posField ? serializeKeyValue(m.data[posField]) : String(i)));
126
+ const keys = dedupeKeys(rawKeys);
127
+ for (let i = 0; i < marks.length; i++) {
128
+ marks[i].key = keys[i];
129
+ }
130
+
122
131
  return marks;
123
132
  }
124
133
 
@@ -20,6 +20,7 @@ import { max, min } from 'd3-array';
20
20
  import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
21
21
  import { scaleSqrt } from 'd3-scale';
22
22
 
23
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
24
  import type { NormalizedChartSpec } from '../../compiler/types';
24
25
  import type { ResolvedScales } from '../../layout/scales';
25
26
  import { getColor, getSequentialColor } from '../utils';
@@ -133,6 +134,8 @@ export function computeScatterMarks(
133
134
  sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
134
135
  }
135
136
 
137
+ const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
138
+ const keyField = keyEnc?.field;
136
139
  const marks: PointMark[] = [];
137
140
 
138
141
  for (const row of spec.data) {
@@ -187,5 +190,17 @@ export function computeScatterMarks(
187
190
  });
188
191
  }
189
192
 
193
+ // Stamp keys: encoding.key field when set, else seriesKey|x composite
194
+ const rawKeys = marks.map((m) => {
195
+ if (keyField) return serializeKeyValue(m.data[keyField]);
196
+ const cat = colorField ? String(m.data[colorField] ?? '') : '';
197
+ const xVal = serializeKeyValue(m.data[xChannel.field]);
198
+ return cat ? `${cat}|${xVal}` : xVal;
199
+ });
200
+ const keys = dedupeKeys(rawKeys);
201
+ for (let i = 0; i < marks.length; i++) {
202
+ marks[i].key = keys[i];
203
+ }
204
+
190
205
  return marks;
191
206
  }
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -84,6 +85,14 @@ export function computeTextMarks(
84
85
  });
85
86
  }
86
87
 
88
+ // Stamp keys: position-defining value, fall back to index
89
+ const posField = xChannel?.field ?? yChannel?.field;
90
+ const rawKeys = marks.map((m, i) => (posField ? serializeKeyValue(m.data[posField]) : String(i)));
91
+ const keys = dedupeKeys(rawKeys);
92
+ for (let i = 0; i < marks.length; i++) {
93
+ marks[i].key = keys[i];
94
+ }
95
+
87
96
  return marks;
88
97
  }
89
98
 
@@ -9,6 +9,7 @@
9
9
  import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
 
12
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
12
13
  import type { NormalizedChartSpec } from '../../compiler/types';
13
14
  import type { ResolvedScales } from '../../layout/scales';
14
15
  import type { ChartRenderer } from '../registry';
@@ -76,6 +77,14 @@ export function computeTickMarks(
76
77
  });
77
78
  }
78
79
 
80
+ // Stamp keys: position-defining value, fall back to index
81
+ const posField = isHorizontal ? xChannel.field : yChannel.field;
82
+ const rawKeys = marks.map((m) => serializeKeyValue(m.data[posField]));
83
+ const keys = dedupeKeys(rawKeys);
84
+ for (let i = 0; i < marks.length; i++) {
85
+ marks[i].key = keys[i];
86
+ }
87
+
79
88
  return marks;
80
89
  }
81
90
 
@@ -6,7 +6,13 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
- import { clampStaggerDelay, resolveAnimation } from '../animation';
9
+ import {
10
+ clampStaggerDelay,
11
+ ENTER_DEFAULTS,
12
+ EXIT_DEFAULTS,
13
+ resolveAnimation,
14
+ UPDATE_DEFAULTS,
15
+ } from '../animation';
10
16
 
11
17
  // ---------------------------------------------------------------------------
12
18
  // resolveAnimation
@@ -21,14 +27,12 @@ describe('resolveAnimation', () => {
21
27
  expect(resolveAnimation(undefined)).toBeUndefined();
22
28
  });
23
29
 
24
- it('returns defaults for true', () => {
30
+ it('returns all three phases with defaults for true', () => {
25
31
  const result = resolveAnimation(true);
26
32
  expect(result).toEqual({
27
- enabled: true,
28
- duration: 500,
29
- ease: 'smooth',
30
- staggerDelay: 80,
31
- staggerOrder: 'index',
33
+ enter: { ...ENTER_DEFAULTS },
34
+ update: { ...UPDATE_DEFAULTS },
35
+ exit: { ...EXIT_DEFAULTS },
32
36
  annotationDelay: 200,
33
37
  });
34
38
  });
@@ -36,11 +40,9 @@ describe('resolveAnimation', () => {
36
40
  it('resolves AnimationConfig with enter: true', () => {
37
41
  const result = resolveAnimation({ enter: true });
38
42
  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');
43
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
44
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
45
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
44
46
  expect(result!.annotationDelay).toBe(200);
45
47
  });
46
48
 
@@ -53,20 +55,41 @@ describe('resolveAnimation', () => {
53
55
  },
54
56
  });
55
57
  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');
58
+ expect(result!.enter).toEqual({
59
+ duration: 800,
60
+ ease: 'smooth',
61
+ staggerDelay: 40,
62
+ staggerOrder: 'value',
63
+ });
64
+ // Other phases still present with defaults
65
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
66
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
60
67
  });
61
68
 
62
- it('returns undefined when enter is false', () => {
63
- expect(resolveAnimation({ enter: false })).toBeUndefined();
69
+ it('enter: false, update: true produces update present, no enter', () => {
70
+ const result = resolveAnimation({ enter: false, update: true });
71
+ expect(result).toBeDefined();
72
+ expect(result!.enter).toBeUndefined();
73
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
74
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
75
+ });
76
+
77
+ it('update: false produces enter + exit present, update absent', () => {
78
+ const result = resolveAnimation({ update: false });
79
+ expect(result).toBeDefined();
80
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
81
+ expect(result!.update).toBeUndefined();
82
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
83
+ });
84
+
85
+ it('all phases false returns undefined', () => {
86
+ expect(resolveAnimation({ enter: false, update: false, exit: false })).toBeUndefined();
64
87
  });
65
88
 
66
89
  it('resolves stagger: false to staggerDelay: 0', () => {
67
90
  const result = resolveAnimation({ enter: { stagger: false } });
68
91
  expect(result).toBeDefined();
69
- expect(result!.staggerDelay).toBe(0);
92
+ expect(result!.enter!.staggerDelay).toBe(0);
70
93
  });
71
94
 
72
95
  it('uses custom annotationDelay', () => {
@@ -75,15 +98,48 @@ describe('resolveAnimation', () => {
75
98
  expect(result!.annotationDelay).toBe(500);
76
99
  });
77
100
 
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();
101
+ it('empty config resolves all phases with defaults (behaves like true)', () => {
102
+ const result = resolveAnimation({});
103
+ expect(result).toBeDefined();
104
+ expect(result!.enter).toEqual(ENTER_DEFAULTS);
105
+ expect(result!.update).toEqual(UPDATE_DEFAULTS);
106
+ expect(result!.exit).toEqual(EXIT_DEFAULTS);
81
107
  });
82
108
 
83
109
  it('preserves default annotationDelay when not overridden', () => {
84
110
  const result = resolveAnimation({ enter: { duration: 1000 } });
85
111
  expect(result!.annotationDelay).toBe(200);
86
112
  });
113
+
114
+ it('enter phase uses ENTER_DEFAULTS for unspecified fields', () => {
115
+ const result = resolveAnimation({ enter: { duration: 1000 } });
116
+ expect(result!.enter).toEqual({
117
+ duration: 1000,
118
+ ease: 'smooth',
119
+ staggerDelay: 80,
120
+ staggerOrder: 'index',
121
+ });
122
+ });
123
+
124
+ it('update phase uses UPDATE_DEFAULTS (no stagger by default)', () => {
125
+ const result = resolveAnimation(true);
126
+ expect(result!.update).toEqual({
127
+ duration: 500,
128
+ ease: 'smooth',
129
+ staggerDelay: 0,
130
+ staggerOrder: 'index',
131
+ });
132
+ });
133
+
134
+ it('exit phase uses EXIT_DEFAULTS (shorter duration)', () => {
135
+ const result = resolveAnimation(true);
136
+ expect(result!.exit).toEqual({
137
+ duration: 300,
138
+ ease: 'smooth',
139
+ staggerDelay: 0,
140
+ staggerOrder: 'index',
141
+ });
142
+ });
87
143
  });
88
144
 
89
145
  // ---------------------------------------------------------------------------
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tests for mark key utilities: serializeKeyValue() and dedupeKeys().
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+ import { dedupeKeys, serializeKeyValue } from '../keys';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // serializeKeyValue
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe('serializeKeyValue', () => {
13
+ it('serializes strings as-is', () => {
14
+ expect(serializeKeyValue('hello')).toBe('hello');
15
+ expect(serializeKeyValue('')).toBe('');
16
+ });
17
+
18
+ it('serializes numbers to string', () => {
19
+ expect(serializeKeyValue(42)).toBe('42');
20
+ expect(serializeKeyValue(0)).toBe('0');
21
+ expect(serializeKeyValue(-3.14)).toBe('-3.14');
22
+ });
23
+
24
+ it('serializes booleans to string', () => {
25
+ expect(serializeKeyValue(true)).toBe('true');
26
+ expect(serializeKeyValue(false)).toBe('false');
27
+ });
28
+
29
+ it('serializes null to sentinel', () => {
30
+ expect(serializeKeyValue(null)).toBe('∅');
31
+ });
32
+
33
+ it('serializes undefined to sentinel', () => {
34
+ expect(serializeKeyValue(undefined)).toBe('∅');
35
+ });
36
+
37
+ it('serializes Date to epoch ms (not locale string)', () => {
38
+ const d = new Date('2024-01-15T00:00:00Z');
39
+ const result = serializeKeyValue(d);
40
+ expect(result).toBe(String(d.getTime()));
41
+ // Confirm it's purely numeric
42
+ expect(Number(result)).toBe(d.getTime());
43
+ // Confirm it does NOT use Date.toString
44
+ expect(result).not.toContain('Jan');
45
+ expect(result).not.toContain('Mon');
46
+ });
47
+
48
+ it('two Dates with same epoch produce same key', () => {
49
+ const d1 = new Date('2024-06-01T12:00:00Z');
50
+ const d2 = new Date(d1.getTime());
51
+ expect(serializeKeyValue(d1)).toBe(serializeKeyValue(d2));
52
+ });
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // dedupeKeys
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('dedupeKeys', () => {
60
+ it('passes unique keys through unchanged', () => {
61
+ expect(dedupeKeys(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
62
+ });
63
+
64
+ it('suffixes duplicates with occurrence index', () => {
65
+ expect(dedupeKeys(['a', 'a', 'a'])).toEqual(['a\x010', 'a\x011', 'a\x012']);
66
+ });
67
+
68
+ it('only suffixes keys that appear more than once', () => {
69
+ expect(dedupeKeys(['a', 'b', 'a'])).toEqual(['a\x010', 'b', 'a\x011']);
70
+ });
71
+
72
+ it('handles empty array', () => {
73
+ expect(dedupeKeys([])).toEqual([]);
74
+ });
75
+
76
+ it('handles single element', () => {
77
+ expect(dedupeKeys(['x'])).toEqual(['x']);
78
+ });
79
+
80
+ it('handles multiple duplicate groups', () => {
81
+ expect(dedupeKeys(['x', 'y', 'x', 'y', 'z'])).toEqual([
82
+ 'x\x010',
83
+ 'y\x010',
84
+ 'x\x011',
85
+ 'y\x011',
86
+ 'z',
87
+ ]);
88
+ });
89
+ });
@@ -2,28 +2,53 @@
2
2
  * Animation resolver: normalizes AnimationSpec into fully resolved config.
3
3
  *
4
4
  * Handles the shorthand forms:
5
- * - true -> { enter: true } -> full defaults
6
- * - { enter: { duration: 800 } } -> merge with defaults
5
+ * - true -> all three phases with defaults
6
+ * - { enter: { duration: 800 } } -> merge with defaults per phase
7
7
  * - false/undefined -> undefined (no animation)
8
+ *
9
+ * Each phase resolves independently:
10
+ * - true/config-object -> resolved phase merged onto that phase's defaults
11
+ * - false -> phase absent
12
+ * - undefined -> phase present with defaults (animation: {} behaves like true)
13
+ *
14
+ * Returns undefined only when ALL phases resolve absent.
8
15
  */
9
16
 
10
17
  import type {
11
18
  AnimationConfig,
12
- AnimationEase,
13
19
  AnimationPhaseConfig,
14
20
  AnimationSpec,
15
21
  AnimationStagger,
16
22
  ResolvedAnimation,
23
+ ResolvedAnimationPhase,
17
24
  } from '@opendata-ai/openchart-core';
18
25
 
19
26
  /** Default values for entrance animation. */
20
- const ENTER_DEFAULTS = {
27
+ export const ENTER_DEFAULTS: ResolvedAnimationPhase = {
21
28
  duration: 500,
22
- ease: 'smooth' as AnimationEase,
29
+ ease: 'smooth',
23
30
  staggerDelay: 80,
24
- staggerOrder: 'index' as const,
25
- annotationDelay: 200,
26
- } as const;
31
+ staggerOrder: 'index',
32
+ };
33
+
34
+ /** Default values for update (data transition) animation. */
35
+ export const UPDATE_DEFAULTS: ResolvedAnimationPhase = {
36
+ duration: 500,
37
+ ease: 'smooth',
38
+ staggerDelay: 0,
39
+ staggerOrder: 'index',
40
+ };
41
+
42
+ /** Default values for exit animation. */
43
+ export const EXIT_DEFAULTS: ResolvedAnimationPhase = {
44
+ duration: 300,
45
+ ease: 'smooth',
46
+ staggerDelay: 0,
47
+ staggerOrder: 'index',
48
+ };
49
+
50
+ /** Default annotation delay in ms after marks finish. */
51
+ const DEFAULT_ANNOTATION_DELAY = 200;
27
52
 
28
53
  /** Maximum total stagger time in ms. Prevents 200-bar charts from taking 6s. */
29
54
  const MAX_TOTAL_STAGGER_MS = 2000;
@@ -35,35 +60,31 @@ const MAX_TOTAL_STAGGER_MS = 2000;
35
60
  export function resolveAnimation(spec: AnimationSpec | undefined): ResolvedAnimation | undefined {
36
61
  if (spec === undefined || spec === false) return undefined;
37
62
 
38
- // true -> default enter animation
63
+ // true -> all three phases with defaults
39
64
  if (spec === true) {
40
65
  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,
66
+ enter: { ...ENTER_DEFAULTS },
67
+ update: { ...UPDATE_DEFAULTS },
68
+ exit: { ...EXIT_DEFAULTS },
69
+ annotationDelay: DEFAULT_ANNOTATION_DELAY,
47
70
  };
48
71
  }
49
72
 
50
73
  // AnimationConfig object
51
74
  const config = spec as AnimationConfig;
52
75
 
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
- }
76
+ const enter = resolvePhase(config.enter, ENTER_DEFAULTS);
77
+ const update = resolvePhase(config.update, UPDATE_DEFAULTS);
78
+ const exit = resolvePhase(config.exit, EXIT_DEFAULTS);
57
79
 
58
- const enterConfig = resolvePhaseConfig(config.enter);
80
+ // Return undefined only when ALL phases resolve absent
81
+ if (!enter && !update && !exit) return undefined;
59
82
 
60
83
  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,
84
+ enter: enter ?? undefined,
85
+ update: update ?? undefined,
86
+ exit: exit ?? undefined,
87
+ annotationDelay: config.annotationDelay ?? DEFAULT_ANNOTATION_DELAY,
67
88
  };
68
89
  }
69
90
 
@@ -75,48 +96,47 @@ export function clampStaggerDelay(delay: number, elementCount: number): number {
75
96
  return Math.min(delay, MAX_TOTAL_STAGGER_MS / elementCount);
76
97
  }
77
98
 
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
- }
99
+ /**
100
+ * Resolve a single animation phase.
101
+ * - false -> null (phase absent)
102
+ * - undefined -> phase present with defaults
103
+ * - true -> phase present with defaults
104
+ * - config object -> merge with defaults
105
+ */
106
+ function resolvePhase(
107
+ phase: AnimationPhaseConfig | boolean | undefined,
108
+ defaults: ResolvedAnimationPhase,
109
+ ): ResolvedAnimationPhase | null {
110
+ if (phase === false) return null;
88
111
 
89
- function resolvePhaseConfig(phase: AnimationPhaseConfig | boolean | undefined): ResolvedPhase {
90
112
  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
- };
113
+ return { ...defaults };
97
114
  }
98
115
 
99
116
  const cfg = phase as AnimationPhaseConfig;
100
- const stagger = resolveStagger(cfg.stagger);
117
+ const stagger = resolveStagger(cfg.stagger, defaults);
101
118
 
102
119
  return {
103
- duration: cfg.duration ?? ENTER_DEFAULTS.duration,
104
- ease: cfg.ease ?? ENTER_DEFAULTS.ease,
120
+ duration: cfg.duration ?? defaults.duration,
121
+ ease: cfg.ease ?? defaults.ease,
105
122
  staggerDelay: stagger.delay,
106
123
  staggerOrder: stagger.order,
107
124
  };
108
125
  }
109
126
 
110
- function resolveStagger(stagger: AnimationStagger | boolean | undefined): {
127
+ function resolveStagger(
128
+ stagger: AnimationStagger | boolean | undefined,
129
+ defaults: ResolvedAnimationPhase,
130
+ ): {
111
131
  delay: number;
112
132
  order: 'index' | 'value' | 'reverse';
113
133
  } {
114
134
  if (stagger === false) return { delay: 0, order: 'index' };
115
135
  if (stagger === undefined || stagger === true) {
116
- return { delay: ENTER_DEFAULTS.staggerDelay, order: ENTER_DEFAULTS.staggerOrder };
136
+ return { delay: defaults.staggerDelay, order: defaults.staggerOrder };
117
137
  }
118
138
  return {
119
- delay: stagger.delay ?? ENTER_DEFAULTS.staggerDelay,
120
- order: stagger.order ?? ENTER_DEFAULTS.staggerOrder,
139
+ delay: stagger.delay ?? defaults.staggerDelay,
140
+ order: stagger.order ?? defaults.staggerOrder,
121
141
  };
122
142
  }