@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.
- package/dist/index.d.ts +63 -4
- package/dist/index.js +227 -55
- 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__/compile-animation.test.ts +8 -8
- package/src/__tests__/compile-chart.test.ts +5 -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/compile.ts +1 -1
- 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/tables/__tests__/compile-table.test.ts +5 -5
- package/src/tilemap/compile-tilemap.ts +10 -2
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '@opendata-ai/openchart-core';
|
|
21
21
|
import type { ScaleBand, ScaleLinear } 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, groupByField } from '../utils';
|
|
@@ -77,9 +78,11 @@ export function computeDotMarks(
|
|
|
77
78
|
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
78
79
|
const colorField = isSequentialColor ? undefined : colorEnc?.field;
|
|
79
80
|
|
|
81
|
+
let marks: (PointMark | RectMark)[];
|
|
82
|
+
|
|
80
83
|
// Multi-series (categorical): dumbbell chart with connecting bars
|
|
81
84
|
if (colorField) {
|
|
82
|
-
|
|
85
|
+
marks = computeDumbbellMarks(
|
|
83
86
|
spec.data,
|
|
84
87
|
xChannel.field,
|
|
85
88
|
yChannel.field,
|
|
@@ -89,20 +92,34 @@ export function computeDotMarks(
|
|
|
89
92
|
bandwidth,
|
|
90
93
|
scales,
|
|
91
94
|
);
|
|
95
|
+
} else {
|
|
96
|
+
// Single series: lollipop stems from baseline
|
|
97
|
+
marks = computeLollipopMarks(
|
|
98
|
+
spec.data,
|
|
99
|
+
xChannel.field,
|
|
100
|
+
yChannel.field,
|
|
101
|
+
xScale,
|
|
102
|
+
yScale,
|
|
103
|
+
bandwidth,
|
|
104
|
+
baseline,
|
|
105
|
+
scales,
|
|
106
|
+
isSequentialColor,
|
|
107
|
+
);
|
|
92
108
|
}
|
|
93
109
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
// Stamp keys on point marks (skip rect stems/connectors)
|
|
111
|
+
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
112
|
+
const rawKeys = pointMarks.map((m) => {
|
|
113
|
+
const cat = serializeKeyValue(m.data[yChannel.field]);
|
|
114
|
+
const series = colorField ? String(m.data[colorField] ?? '') : '';
|
|
115
|
+
return series ? `${series}|${cat}` : cat;
|
|
116
|
+
});
|
|
117
|
+
const keys = dedupeKeys(rawKeys);
|
|
118
|
+
for (let i = 0; i < pointMarks.length; i++) {
|
|
119
|
+
pointMarks[i].key = keys[i];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return marks;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
// ---------------------------------------------------------------------------
|
package/src/charts/line/area.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
stackOrderNone,
|
|
25
25
|
} from 'd3-shape';
|
|
26
26
|
|
|
27
|
+
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
27
28
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
28
29
|
import type { ResolvedScales } from '../../layout/scales';
|
|
29
30
|
import { getColor, scaleValue, sortByField } from '../utils';
|
|
@@ -235,8 +236,16 @@ function computeSingleArea(
|
|
|
235
236
|
fillOpacity = 1;
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
const keyField = encoding.key && 'field' in encoding.key ? encoding.key.field : undefined;
|
|
240
|
+
const rawPointKeys = validPoints.map((p) =>
|
|
241
|
+
keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field]),
|
|
242
|
+
);
|
|
243
|
+
|
|
238
244
|
marks.push({
|
|
239
245
|
type: 'area',
|
|
246
|
+
key: seriesKey === '__default__' ? 'area' : seriesKey,
|
|
247
|
+
pointKeys: dedupeKeys(rawPointKeys),
|
|
248
|
+
interpolate: spec.markDef.interpolate ?? 'monotone',
|
|
240
249
|
topPoints,
|
|
241
250
|
bottomPoints,
|
|
242
251
|
path: pathStr,
|
|
@@ -402,8 +411,13 @@ function computeStackedArea(
|
|
|
402
411
|
fillOpacity = 1;
|
|
403
412
|
}
|
|
404
413
|
|
|
414
|
+
const rawPointKeys = validPoints.map((_p, idx) => serializeKeyValue(layer[idx]?.data.__x__));
|
|
415
|
+
|
|
405
416
|
marks.push({
|
|
406
417
|
type: 'area',
|
|
418
|
+
key: seriesKey,
|
|
419
|
+
pointKeys: dedupeKeys(rawPointKeys),
|
|
420
|
+
interpolate: spec.markDef.interpolate ?? 'monotone',
|
|
407
421
|
topPoints,
|
|
408
422
|
bottomPoints,
|
|
409
423
|
path: pathStr,
|
|
@@ -464,6 +478,12 @@ export function computeAreaMarks(
|
|
|
464
478
|
marks = computeSingleArea(spec, scales, chartArea);
|
|
465
479
|
}
|
|
466
480
|
|
|
481
|
+
// Dedupe area mark keys
|
|
482
|
+
const areaKeys = dedupeKeys(marks.map((m) => m.key!));
|
|
483
|
+
marks.forEach((m, i) => {
|
|
484
|
+
m.key = areaKeys[i];
|
|
485
|
+
});
|
|
486
|
+
|
|
467
487
|
const highlight = spec.highlight ?? [];
|
|
468
488
|
if (highlight.length > 0) {
|
|
469
489
|
const highlightSet = new Set(highlight);
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
21
21
|
import { line } 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 { getColor, getSequentialColor, groupByField, scaleValue, sortByField } from '../utils';
|
|
@@ -183,8 +184,16 @@ export function computeLineMarks(
|
|
|
183
184
|
// Create the LineMark with the combined path points.
|
|
184
185
|
// The points array includes all valid points across all segments.
|
|
185
186
|
// dataPoints carries pixel coordinates + original data for voronoi tooltip overlay.
|
|
187
|
+
const keyField = encoding.key && 'field' in encoding.key ? encoding.key.field : undefined;
|
|
188
|
+
const rawPointKeys = pointsWithData.map((p) =>
|
|
189
|
+
keyField ? serializeKeyValue(p.row[keyField]) : serializeKeyValue(p.row[xChannel.field]),
|
|
190
|
+
);
|
|
191
|
+
|
|
186
192
|
const lineMark: LineMark = {
|
|
187
193
|
type: 'line',
|
|
194
|
+
key: seriesStyleKey ?? 'series',
|
|
195
|
+
pointKeys: dedupeKeys(rawPointKeys),
|
|
196
|
+
interpolate: spec.markDef.interpolate ?? 'monotone',
|
|
188
197
|
points: allPoints,
|
|
189
198
|
path: combinedPath,
|
|
190
199
|
stroke: strokeColor,
|
|
@@ -242,9 +251,15 @@ export function computeLineMarks(
|
|
|
242
251
|
// color — no white halo, no hollow ring. Marked decorative because the
|
|
243
252
|
// data point already exists on the line and gets described by the a11y
|
|
244
253
|
// data table; the dot is purely visual.
|
|
254
|
+
const pointKey = keyField
|
|
255
|
+
? serializeKeyValue(p.row[keyField])
|
|
256
|
+
: seriesStyleKey
|
|
257
|
+
? `${seriesStyleKey}|${serializeKeyValue(p.row[xChannel.field])}`
|
|
258
|
+
: serializeKeyValue(p.row[xChannel.field]);
|
|
245
259
|
if (isSingleEndpoint) {
|
|
246
260
|
bucket.push({
|
|
247
261
|
type: 'point',
|
|
262
|
+
key: pointKey,
|
|
248
263
|
cx: p.x,
|
|
249
264
|
cy: p.y,
|
|
250
265
|
r: 3.5,
|
|
@@ -259,6 +274,7 @@ export function computeLineMarks(
|
|
|
259
274
|
}
|
|
260
275
|
const pointMark: PointMark = {
|
|
261
276
|
type: 'point',
|
|
277
|
+
key: pointKey,
|
|
262
278
|
cx: p.x,
|
|
263
279
|
cy: p.y,
|
|
264
280
|
r: visible ? DEFAULT_POINT_RADIUS : 0,
|
|
@@ -276,7 +292,25 @@ export function computeLineMarks(
|
|
|
276
292
|
}
|
|
277
293
|
}
|
|
278
294
|
|
|
279
|
-
|
|
295
|
+
const allMarks = [...mutedMarks, ...highlightedMarks];
|
|
296
|
+
|
|
297
|
+
// Dedupe series-level keys across all line marks
|
|
298
|
+
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
299
|
+
const lineKeys = dedupeKeys(lineMarks.map((m) => m.key!));
|
|
300
|
+
lineMarks.forEach((m, i) => {
|
|
301
|
+
m.key = lineKeys[i];
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Dedupe point mark keys
|
|
305
|
+
const pointMarks = allMarks.filter((m): m is PointMark => m.type === 'point');
|
|
306
|
+
if (pointMarks.length > 0) {
|
|
307
|
+
const pointKeys = dedupeKeys(pointMarks.map((m) => m.key ?? ''));
|
|
308
|
+
pointMarks.forEach((m, i) => {
|
|
309
|
+
m.key = pointKeys[i];
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return allMarks;
|
|
280
314
|
}
|
|
281
315
|
|
|
282
316
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
|
package/src/compile.ts
CHANGED
|
@@ -967,7 +967,7 @@ function compileFaceted(
|
|
|
967
967
|
|
|
968
968
|
// Compute scales for this panel's area
|
|
969
969
|
const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
|
|
970
|
-
applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme);
|
|
970
|
+
applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
|
|
971
971
|
panelScales.defaultColor =
|
|
972
972
|
chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
|
|
973
973
|
|
|
@@ -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
|
+
});
|