@opendata-ai/openchart-engine 6.4.1 → 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/dist/index.d.ts +22 -2
- package/dist/index.js +143 -8
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compile-animation.test.ts +161 -0
- package/src/charts/bar/compute.ts +3 -0
- package/src/charts/column/compute.ts +4 -0
- package/src/compile.ts +66 -0
- package/src/compiler/__tests__/animation.test.ts +121 -0
- package/src/compiler/animation.ts +122 -0
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/types.ts +2 -0
- package/src/index.ts +6 -0
- package/src/tables/__tests__/compile-table.test.ts +28 -0
- package/src/tables/compile-table.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
+
});
|
|
@@ -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
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -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
|
}
|