@opendata-ai/openchart-engine 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.
@@ -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', () => {
@@ -38,6 +38,7 @@ import {
38
38
  computeChrome,
39
39
  estimateTextWidth,
40
40
  formatNumber,
41
+ pickLabelColor,
41
42
  resolveTheme,
42
43
  SEQUENTIAL_PALETTES,
43
44
  } from '@opendata-ai/openchart-core';
@@ -226,6 +227,7 @@ function compileQuantitative(
226
227
  formattedValue,
227
228
  hasData,
228
229
  theme,
230
+ isDarkMode: !!isDarkMode,
229
231
  }),
230
232
  );
231
233
  }
@@ -397,6 +399,7 @@ function compileCategorical(
397
399
  formattedValue,
398
400
  hasData,
399
401
  theme,
402
+ isDarkMode: !!isDarkMode,
400
403
  category: hasData ? category : undefined,
401
404
  }),
402
405
  );
@@ -488,6 +491,7 @@ interface TileMarkOptions {
488
491
  formattedValue: string;
489
492
  hasData: boolean;
490
493
  theme: ResolvedTheme;
494
+ isDarkMode: boolean;
491
495
  category?: string | null;
492
496
  }
493
497
 
@@ -505,16 +509,20 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
505
509
  formattedValue,
506
510
  hasData,
507
511
  theme,
512
+ isDarkMode,
508
513
  category,
509
514
  } = opts;
510
515
  const tileCenterX = gridOffsetX + pos.x + tileSize / 2;
511
516
  const tileTopY = gridOffsetY + pos.y;
512
517
 
518
+ const textColor = hasData ? pickLabelColor(fill, isDarkMode) : '#ffffff';
519
+ const isLightText = textColor === '#ffffff';
520
+
513
521
  const labelStyle: TextStyle = {
514
522
  fontFamily: theme.fonts.family,
515
523
  fontSize: tileSize > 24 ? 10 : 7,
516
524
  fontWeight: 700,
517
- fill: '#ffffff',
525
+ fill: textColor,
518
526
  lineHeight: 1.2,
519
527
  };
520
528
 
@@ -522,7 +530,7 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
522
530
  fontFamily: theme.fonts.family,
523
531
  fontSize: tileSize > 24 ? 10 : 7,
524
532
  fontWeight: 300,
525
- fill: 'rgba(255,255,255,0.6)',
533
+ fill: isLightText ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)',
526
534
  lineHeight: 1.2,
527
535
  };
528
536