@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.
- package/dist/index.d.ts +63 -4
- package/dist/index.js +1644 -1428
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -0
- package/src/__tests__/axes.test.ts +76 -0
- package/src/__tests__/compile-animation.test.ts +8 -8
- package/src/__tests__/compile-chart.test.ts +5 -5
- package/src/__tests__/dimensions.test.ts +116 -5
- package/src/__tests__/mark-keys.test.ts +294 -0
- package/src/charts/__tests__/post-process.test.ts +5 -26
- package/src/charts/bar/compute.ts +18 -0
- package/src/charts/column/compute.ts +18 -0
- package/src/charts/dot/compute.ts +30 -13
- package/src/charts/line/area.ts +20 -0
- package/src/charts/line/compute.ts +35 -1
- package/src/charts/line/curves.ts +47 -2
- package/src/charts/pie/compute.ts +13 -0
- package/src/charts/post-process.ts +2 -2
- package/src/charts/rule/index.ts +9 -0
- package/src/charts/scatter/compute.ts +15 -0
- package/src/charts/text/index.ts +9 -0
- package/src/charts/tick/index.ts +9 -0
- package/src/compiler/__tests__/animation.test.ts +78 -22
- package/src/compiler/__tests__/keys.test.ts +89 -0
- package/src/compiler/animation.ts +70 -50
- package/src/compiler/keys.ts +54 -0
- package/src/compiler/normalize.ts +1 -1
- package/src/index.ts +19 -1
- package/src/layout/axes.ts +125 -34
- package/src/layout/dimensions.ts +10 -11
- package/src/layout/plan.ts +11 -12
- package/src/tables/__tests__/compile-table.test.ts +5 -5
- package/src/tilemap/compile-tilemap.ts +93 -21
|
@@ -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?.
|
|
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);
|
package/src/charts/rule/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/charts/text/index.ts
CHANGED
|
@@ -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
|
|
package/src/charts/tick/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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!.
|
|
40
|
-
expect(result!.
|
|
41
|
-
expect(result!.
|
|
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!.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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('
|
|
63
|
-
|
|
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('
|
|
79
|
-
|
|
80
|
-
expect(
|
|
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 ->
|
|
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'
|
|
29
|
+
ease: 'smooth',
|
|
23
30
|
staggerDelay: 80,
|
|
24
|
-
staggerOrder: 'index'
|
|
25
|
-
|
|
26
|
-
|
|
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 ->
|
|
63
|
+
// true -> all three phases with defaults
|
|
39
64
|
if (spec === true) {
|
|
40
65
|
return {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
80
|
+
// Return undefined only when ALL phases resolve absent
|
|
81
|
+
if (!enter && !update && !exit) return undefined;
|
|
59
82
|
|
|
60
83
|
return {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 ??
|
|
104
|
-
ease: cfg.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(
|
|
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:
|
|
136
|
+
return { delay: defaults.staggerDelay, order: defaults.staggerOrder };
|
|
117
137
|
}
|
|
118
138
|
return {
|
|
119
|
-
delay: stagger.delay ??
|
|
120
|
-
order: stagger.order ??
|
|
139
|
+
delay: stagger.delay ?? defaults.staggerDelay,
|
|
140
|
+
order: stagger.order ?? defaults.staggerOrder,
|
|
121
141
|
};
|
|
122
142
|
}
|