@opendata-ai/openchart-engine 7.8.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.
@@ -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
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Mark key utilities for data-update transitions.
3
+ *
4
+ * Keys uniquely identify marks across data updates so the renderer can
5
+ * match old marks to new marks and animate the difference. Each chart
6
+ * type stamps keys during its compute phase; these helpers provide the
7
+ * shared serialization and deduplication logic.
8
+ */
9
+
10
+ /** Sentinel string for null/undefined values. Uses a Unicode symbol
11
+ * that won't collide with real data values. */
12
+ const NULL_KEY = '∅';
13
+
14
+ /**
15
+ * Serialize a datum value into a stable key fragment.
16
+ * - Date -> epoch ms string (avoids locale-dependent Date.toString)
17
+ * - number/string/boolean -> String(v)
18
+ * - null/undefined -> null sentinel
19
+ */
20
+ export function serializeKeyValue(v: unknown): string {
21
+ if (v === null || v === undefined) return NULL_KEY;
22
+ if (v instanceof Date) return String(v.getTime());
23
+ return String(v);
24
+ }
25
+
26
+ /**
27
+ * Append occurrence suffixes to duplicate keys, in order.
28
+ *
29
+ * Only keys that appear more than once get suffixed. Unique keys pass
30
+ * through unchanged so keys remain human-readable when possible.
31
+ * Uses SOH (\x01) as separator to avoid collisions with real data values.
32
+ */
33
+ export function dedupeKeys(keys: string[]): string[] {
34
+ // Count occurrences
35
+ const counts = new Map<string, number>();
36
+ for (const k of keys) {
37
+ counts.set(k, (counts.get(k) ?? 0) + 1);
38
+ }
39
+
40
+ // Assign suffixes only to duplicates
41
+ const seen = new Map<string, number>();
42
+ const result: string[] = [];
43
+ for (const k of keys) {
44
+ if (counts.get(k)! > 1) {
45
+ const idx = seen.get(k) ?? 0;
46
+ result.push(`${k}\x01${idx}`);
47
+ seen.set(k, idx + 1);
48
+ } else {
49
+ result.push(k);
50
+ }
51
+ }
52
+
53
+ return result;
54
+ }
@@ -132,7 +132,7 @@ function inferFieldType(data: DataRow[], field: string): FieldType {
132
132
  function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: string[]): Encoding {
133
133
  const result = { ...encoding };
134
134
 
135
- for (const channel of ['x', 'y', 'color', 'size', 'detail'] as const) {
135
+ for (const channel of ['x', 'y', 'color', 'size', 'detail', 'key'] as const) {
136
136
  const spec = result[channel];
137
137
  if (!spec) continue;
138
138
 
package/src/index.ts CHANGED
@@ -26,7 +26,25 @@ export {
26
26
  // Animation resolution
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
- export { clampStaggerDelay, resolveAnimation } from './compiler/animation';
29
+ export {
30
+ clampStaggerDelay,
31
+ ENTER_DEFAULTS,
32
+ EXIT_DEFAULTS,
33
+ resolveAnimation,
34
+ UPDATE_DEFAULTS,
35
+ } from './compiler/animation';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Mark keying for data-update transitions
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export { dedupeKeys, serializeKeyValue } from './compiler/keys';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Geometry helpers for path reconstruction
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export { buildAreaPath, buildLinePath } from './charts/line/curves';
30
48
 
31
49
  // ---------------------------------------------------------------------------
32
50
  // Graph compilation types
@@ -209,9 +209,9 @@ describe('compileTable', () => {
209
209
  it('includes resolved animation when spec has animation: true', () => {
210
210
  const layout = compileTable({ ...baseSpec, animation: true }, baseOptions);
211
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);
212
+ expect(layout.animation!.enter).toBeDefined();
213
+ expect(layout.animation!.enter!.duration).toBe(500);
214
+ expect(layout.animation!.enter!.staggerDelay).toBe(80);
215
215
  });
216
216
 
217
217
  it('includes resolved animation with custom config', () => {
@@ -220,8 +220,8 @@ describe('compileTable', () => {
220
220
  baseOptions,
221
221
  );
222
222
  expect(layout.animation).toBeDefined();
223
- expect(layout.animation!.duration).toBe(800);
224
- expect(layout.animation!.ease).toBe('snappy');
223
+ expect(layout.animation!.enter!.duration).toBe(800);
224
+ expect(layout.animation!.enter!.ease).toBe('snappy');
225
225
  });
226
226
 
227
227
  it('does not include animation when spec omits it', () => {