@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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for mark key stamping across chart types.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that compileChart() stamps stable `key` fields on marks
|
|
5
|
+
* for data-update transitions. Each chart type has its own keying
|
|
6
|
+
* strategy; these tests verify the end-to-end pipeline.
|
|
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
|
+
const dims = { width: 600, height: 400 };
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Column chart keys
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe('column chart mark keys', () => {
|
|
20
|
+
it('stamps unique keys on simple column marks', () => {
|
|
21
|
+
const spec: ChartSpec = {
|
|
22
|
+
mark: 'bar',
|
|
23
|
+
data: [
|
|
24
|
+
{ category: 'A', value: 10 },
|
|
25
|
+
{ category: 'B', value: 20 },
|
|
26
|
+
{ category: 'C', value: 30 },
|
|
27
|
+
],
|
|
28
|
+
encoding: {
|
|
29
|
+
x: { field: 'category', type: 'nominal' },
|
|
30
|
+
y: { field: 'value', type: 'quantitative' },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const layout = compileChart(spec, dims);
|
|
34
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
35
|
+
expect(rects.length).toBe(3);
|
|
36
|
+
|
|
37
|
+
for (const m of rects) {
|
|
38
|
+
expect((m as { key?: string }).key).toBeDefined();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const keys = rects.map((m) => (m as { key?: string }).key);
|
|
42
|
+
expect(new Set(keys).size).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('deduplicates keys for duplicate category values', () => {
|
|
46
|
+
const spec: ChartSpec = {
|
|
47
|
+
mark: 'bar',
|
|
48
|
+
data: [
|
|
49
|
+
{ category: 'A', value: 10 },
|
|
50
|
+
{ category: 'A', value: 20 },
|
|
51
|
+
{ category: 'B', value: 30 },
|
|
52
|
+
],
|
|
53
|
+
encoding: {
|
|
54
|
+
x: { field: 'category', type: 'nominal' },
|
|
55
|
+
y: { field: 'value', type: 'quantitative' },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const layout = compileChart(spec, dims);
|
|
59
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
60
|
+
const keys = rects.map((m) => (m as { key?: string }).key);
|
|
61
|
+
// All keys must be unique even with duplicate categories
|
|
62
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('stamps keys on grouped columns', () => {
|
|
66
|
+
const spec: ChartSpec = {
|
|
67
|
+
mark: 'bar',
|
|
68
|
+
data: [
|
|
69
|
+
{ category: 'Q1', series: 'Revenue', value: 100 },
|
|
70
|
+
{ category: 'Q1', series: 'Cost', value: 60 },
|
|
71
|
+
{ category: 'Q2', series: 'Revenue', value: 150 },
|
|
72
|
+
{ category: 'Q2', series: 'Cost', value: 90 },
|
|
73
|
+
],
|
|
74
|
+
encoding: {
|
|
75
|
+
x: { field: 'category', type: 'nominal' },
|
|
76
|
+
y: { field: 'value', type: 'quantitative' },
|
|
77
|
+
color: { field: 'series', type: 'nominal' },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const layout = compileChart(spec, dims);
|
|
81
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
82
|
+
expect(rects.length).toBeGreaterThanOrEqual(4);
|
|
83
|
+
|
|
84
|
+
const keys = rects.map((m) => (m as { key?: string }).key);
|
|
85
|
+
for (const k of keys) {
|
|
86
|
+
expect(k).toBeDefined();
|
|
87
|
+
}
|
|
88
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Bar chart keys
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
describe('bar chart mark keys', () => {
|
|
97
|
+
it('stamps unique keys on horizontal bar marks', () => {
|
|
98
|
+
const spec: ChartSpec = {
|
|
99
|
+
mark: 'bar',
|
|
100
|
+
data: [
|
|
101
|
+
{ name: 'Alpha', value: 10 },
|
|
102
|
+
{ name: 'Beta', value: 30 },
|
|
103
|
+
{ name: 'Gamma', value: 20 },
|
|
104
|
+
],
|
|
105
|
+
encoding: {
|
|
106
|
+
x: { field: 'value', type: 'quantitative' },
|
|
107
|
+
y: { field: 'name', type: 'nominal' },
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const layout = compileChart(spec, dims);
|
|
111
|
+
const rects = layout.marks.filter((m) => m.type === 'rect');
|
|
112
|
+
const keys = rects.map((m) => (m as { key?: string }).key);
|
|
113
|
+
for (const k of keys) {
|
|
114
|
+
expect(k).toBeDefined();
|
|
115
|
+
}
|
|
116
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Line chart keys
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('line chart mark keys', () => {
|
|
125
|
+
it('stamps key on line marks for multi-series', () => {
|
|
126
|
+
const spec: ChartSpec = {
|
|
127
|
+
mark: 'line',
|
|
128
|
+
data: [
|
|
129
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
130
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
131
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
132
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
133
|
+
],
|
|
134
|
+
encoding: {
|
|
135
|
+
x: { field: 'date', type: 'temporal' },
|
|
136
|
+
y: { field: 'value', type: 'quantitative' },
|
|
137
|
+
color: { field: 'country', type: 'nominal' },
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
const layout = compileChart(spec, dims);
|
|
141
|
+
const lines = layout.marks.filter((m) => m.type === 'line');
|
|
142
|
+
expect(lines.length).toBe(2);
|
|
143
|
+
|
|
144
|
+
const keys = lines.map((m) => (m as { key?: string }).key);
|
|
145
|
+
for (const k of keys) {
|
|
146
|
+
expect(k).toBeDefined();
|
|
147
|
+
}
|
|
148
|
+
expect(new Set(keys).size).toBe(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('stamps pointKeys on line marks with temporal x', () => {
|
|
152
|
+
const spec: ChartSpec = {
|
|
153
|
+
mark: 'line',
|
|
154
|
+
data: [
|
|
155
|
+
{ date: '2020-01-01', value: 10 },
|
|
156
|
+
{ date: '2021-01-01', value: 40 },
|
|
157
|
+
{ date: '2022-01-01', value: 25 },
|
|
158
|
+
],
|
|
159
|
+
encoding: {
|
|
160
|
+
x: { field: 'date', type: 'temporal' },
|
|
161
|
+
y: { field: 'value', type: 'quantitative' },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const layout = compileChart(spec, dims);
|
|
165
|
+
const lines = layout.marks.filter((m) => m.type === 'line');
|
|
166
|
+
expect(lines.length).toBe(1);
|
|
167
|
+
|
|
168
|
+
const line = lines[0] as { pointKeys?: string[] };
|
|
169
|
+
expect(line.pointKeys).toBeDefined();
|
|
170
|
+
expect(line.pointKeys!.length).toBe(3);
|
|
171
|
+
|
|
172
|
+
// Each point key should be a unique, defined string
|
|
173
|
+
for (const pk of line.pointKeys!) {
|
|
174
|
+
expect(pk).toBeDefined();
|
|
175
|
+
expect(typeof pk).toBe('string');
|
|
176
|
+
}
|
|
177
|
+
expect(new Set(line.pointKeys!).size).toBe(3);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Scatter chart keys
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe('scatter chart mark keys', () => {
|
|
186
|
+
it('stamps keys on scatter point marks', () => {
|
|
187
|
+
const spec: ChartSpec = {
|
|
188
|
+
mark: 'point',
|
|
189
|
+
data: [
|
|
190
|
+
{ x: 10, y: 20, label: 'A' },
|
|
191
|
+
{ x: 30, y: 40, label: 'B' },
|
|
192
|
+
{ x: 50, y: 60, label: 'C' },
|
|
193
|
+
],
|
|
194
|
+
encoding: {
|
|
195
|
+
x: { field: 'x', type: 'quantitative' },
|
|
196
|
+
y: { field: 'y', type: 'quantitative' },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const layout = compileChart(spec, dims);
|
|
200
|
+
const points = layout.marks.filter((m) => m.type === 'point');
|
|
201
|
+
expect(points.length).toBe(3);
|
|
202
|
+
|
|
203
|
+
const keys = points.map((m) => (m as { key?: string }).key);
|
|
204
|
+
for (const k of keys) {
|
|
205
|
+
expect(k).toBeDefined();
|
|
206
|
+
}
|
|
207
|
+
expect(new Set(keys).size).toBe(3);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('uses encoding.key field when specified', () => {
|
|
211
|
+
const spec: ChartSpec = {
|
|
212
|
+
mark: 'point',
|
|
213
|
+
data: [
|
|
214
|
+
{ x: 10, y: 20, id: 'item-1' },
|
|
215
|
+
{ x: 30, y: 40, id: 'item-2' },
|
|
216
|
+
{ x: 50, y: 60, id: 'item-3' },
|
|
217
|
+
],
|
|
218
|
+
encoding: {
|
|
219
|
+
x: { field: 'x', type: 'quantitative' },
|
|
220
|
+
y: { field: 'y', type: 'quantitative' },
|
|
221
|
+
key: { field: 'id' },
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
const layout = compileChart(spec, dims);
|
|
225
|
+
const points = layout.marks.filter((m) => m.type === 'point');
|
|
226
|
+
const keys = points.map((m) => (m as { key?: string }).key);
|
|
227
|
+
|
|
228
|
+
expect(keys).toContain('item-1');
|
|
229
|
+
expect(keys).toContain('item-2');
|
|
230
|
+
expect(keys).toContain('item-3');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Pie chart keys
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe('pie chart mark keys', () => {
|
|
239
|
+
it('stamps keys on arc marks', () => {
|
|
240
|
+
const spec: ChartSpec = {
|
|
241
|
+
mark: 'arc',
|
|
242
|
+
data: [
|
|
243
|
+
{ category: 'Desktop', share: 60 },
|
|
244
|
+
{ category: 'Mobile', share: 30 },
|
|
245
|
+
{ category: 'Tablet', share: 10 },
|
|
246
|
+
],
|
|
247
|
+
encoding: {
|
|
248
|
+
y: { field: 'share', type: 'quantitative' },
|
|
249
|
+
color: { field: 'category', type: 'nominal' },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const layout = compileChart(spec, dims);
|
|
253
|
+
const arcs = layout.marks.filter((m) => m.type === 'arc');
|
|
254
|
+
expect(arcs.length).toBe(3);
|
|
255
|
+
|
|
256
|
+
const keys = arcs.map((m) => (m as { key?: string }).key);
|
|
257
|
+
for (const k of keys) {
|
|
258
|
+
expect(k).toBeDefined();
|
|
259
|
+
}
|
|
260
|
+
expect(new Set(keys).size).toBe(3);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Area chart keys
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
describe('area chart mark keys', () => {
|
|
269
|
+
it('stamps key on area marks', () => {
|
|
270
|
+
const spec: ChartSpec = {
|
|
271
|
+
mark: 'area',
|
|
272
|
+
data: [
|
|
273
|
+
{ date: '2020-01-01', value: 10, series: 'A' },
|
|
274
|
+
{ date: '2021-01-01', value: 40, series: 'A' },
|
|
275
|
+
{ date: '2020-01-01', value: 15, series: 'B' },
|
|
276
|
+
{ date: '2021-01-01', value: 35, series: 'B' },
|
|
277
|
+
],
|
|
278
|
+
encoding: {
|
|
279
|
+
x: { field: 'date', type: 'temporal' },
|
|
280
|
+
y: { field: 'value', type: 'quantitative' },
|
|
281
|
+
color: { field: 'series', type: 'nominal' },
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const layout = compileChart(spec, dims);
|
|
285
|
+
const areas = layout.marks.filter((m) => m.type === 'area');
|
|
286
|
+
expect(areas.length).toBe(2);
|
|
287
|
+
|
|
288
|
+
const keys = areas.map((m) => (m as { key?: string }).key);
|
|
289
|
+
for (const k of keys) {
|
|
290
|
+
expect(k).toBeDefined();
|
|
291
|
+
}
|
|
292
|
+
expect(new Set(keys).size).toBe(2);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -120,11 +120,7 @@ describe('assignAnimationIndices', () => {
|
|
|
120
120
|
{ type: 'rect', x: 0, y: 0, width: 10, height: 50, fill: '#000' } as RectMark,
|
|
121
121
|
];
|
|
122
122
|
const animation: ResolvedAnimation = {
|
|
123
|
-
|
|
124
|
-
duration: 500,
|
|
125
|
-
ease: 'smooth',
|
|
126
|
-
staggerDelay: 50,
|
|
127
|
-
staggerOrder: 'value',
|
|
123
|
+
enter: { duration: 500, ease: 'smooth', staggerDelay: 50, staggerOrder: 'value' },
|
|
128
124
|
annotationDelay: 0,
|
|
129
125
|
};
|
|
130
126
|
assignAnimationIndices(marks, animation);
|
|
@@ -165,11 +161,7 @@ describe('assignAnimationIndices', () => {
|
|
|
165
161
|
} as RectMark,
|
|
166
162
|
];
|
|
167
163
|
const animation: ResolvedAnimation = {
|
|
168
|
-
|
|
169
|
-
duration: 500,
|
|
170
|
-
ease: 'smooth',
|
|
171
|
-
staggerDelay: 50,
|
|
172
|
-
staggerOrder: 'value',
|
|
164
|
+
enter: { duration: 500, ease: 'smooth', staggerDelay: 50, staggerOrder: 'value' },
|
|
173
165
|
annotationDelay: 0,
|
|
174
166
|
};
|
|
175
167
|
assignAnimationIndices(marks, animation);
|
|
@@ -205,11 +197,7 @@ describe('assignAnimationIndices', () => {
|
|
|
205
197
|
} as RectMark,
|
|
206
198
|
];
|
|
207
199
|
const animation: ResolvedAnimation = {
|
|
208
|
-
|
|
209
|
-
duration: 500,
|
|
210
|
-
ease: 'smooth',
|
|
211
|
-
staggerDelay: 50,
|
|
212
|
-
staggerOrder: 'value',
|
|
200
|
+
enter: { duration: 500, ease: 'smooth', staggerDelay: 50, staggerOrder: 'value' },
|
|
213
201
|
annotationDelay: 0,
|
|
214
202
|
};
|
|
215
203
|
assignAnimationIndices(marks, animation);
|
|
@@ -226,16 +214,11 @@ describe('assignAnimationIndices', () => {
|
|
|
226
214
|
expect(marks[0].animationIndex).toBeUndefined();
|
|
227
215
|
});
|
|
228
216
|
|
|
229
|
-
it('is a no-op when animation
|
|
217
|
+
it('is a no-op when animation has no enter phase', () => {
|
|
230
218
|
const marks: Mark[] = [
|
|
231
219
|
{ type: 'rect', x: 0, y: 0, width: 10, height: 30, fill: '#000' } as RectMark,
|
|
232
220
|
];
|
|
233
221
|
const animation: ResolvedAnimation = {
|
|
234
|
-
enabled: false,
|
|
235
|
-
duration: 500,
|
|
236
|
-
ease: 'smooth',
|
|
237
|
-
staggerDelay: 50,
|
|
238
|
-
staggerOrder: 'value',
|
|
239
222
|
annotationDelay: 0,
|
|
240
223
|
};
|
|
241
224
|
assignAnimationIndices(marks, animation);
|
|
@@ -244,11 +227,7 @@ describe('assignAnimationIndices', () => {
|
|
|
244
227
|
|
|
245
228
|
it('handles empty marks array', () => {
|
|
246
229
|
const animation: ResolvedAnimation = {
|
|
247
|
-
|
|
248
|
-
duration: 500,
|
|
249
|
-
ease: 'smooth',
|
|
250
|
-
staggerDelay: 50,
|
|
251
|
-
staggerOrder: 'value',
|
|
230
|
+
enter: { duration: 500, ease: 'smooth', staggerDelay: 50, staggerOrder: 'value' },
|
|
252
231
|
annotationDelay: 0,
|
|
253
232
|
};
|
|
254
233
|
const marks: Mark[] = [];
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
20
|
import { isGradientDef, NARROW_VIEWPORT_MAX } from '@opendata-ai/openchart-core';
|
|
21
21
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
22
|
+
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
22
23
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
24
|
import type { ResolvedScales } from '../../layout/scales';
|
|
24
25
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
@@ -194,9 +195,26 @@ export function computeBarMarks(
|
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
// Stamp identity keys for data-update transitions
|
|
199
|
+
const categoryField = yChannel.field;
|
|
200
|
+
stampBarKeys(marks, categoryField, colorField);
|
|
201
|
+
|
|
197
202
|
return applyMarkDefOverrides(marks, spec, bandwidth);
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
/** Stamp stable identity keys on bar marks for data-update transitions. */
|
|
206
|
+
function stampBarKeys(marks: RectMark[], categoryField: string, colorField?: string): void {
|
|
207
|
+
const rawKeys = marks.map((m) => {
|
|
208
|
+
const series = colorField ? String(m.data[colorField] ?? '') : '';
|
|
209
|
+
const cat = serializeKeyValue(m.data[categoryField]);
|
|
210
|
+
return `${series}|${cat}`;
|
|
211
|
+
});
|
|
212
|
+
const keys = dedupeKeys(rawKeys);
|
|
213
|
+
for (let i = 0; i < marks.length; i++) {
|
|
214
|
+
marks[i].key = keys[i];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
200
218
|
/** Compute stacked horizontal bars with support for zero/normalize/center modes. */
|
|
201
219
|
function computeStackedBars(
|
|
202
220
|
data: DataRow[],
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from '@opendata-ai/openchart-core';
|
|
23
23
|
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
24
24
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
25
|
+
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
25
26
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
26
27
|
import type { ResolvedScales } from '../../layout/scales';
|
|
27
28
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
@@ -163,9 +164,26 @@ export function computeColumnMarks(
|
|
|
163
164
|
);
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
// Stamp identity keys for data-update transitions
|
|
168
|
+
const categoryField = xChannel.field;
|
|
169
|
+
stampColumnKeys(marks, categoryField, colorField);
|
|
170
|
+
|
|
166
171
|
return applyMarkDefOverrides(marks, spec, bandwidth);
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
/** Stamp stable identity keys on column marks for data-update transitions. */
|
|
175
|
+
function stampColumnKeys(marks: RectMark[], categoryField: string, colorField?: string): void {
|
|
176
|
+
const rawKeys = marks.map((m) => {
|
|
177
|
+
const series = colorField ? String(m.data[colorField] ?? '') : '';
|
|
178
|
+
const cat = serializeKeyValue(m.data[categoryField]);
|
|
179
|
+
return `${series}|${cat}`;
|
|
180
|
+
});
|
|
181
|
+
const keys = dedupeKeys(rawKeys);
|
|
182
|
+
for (let i = 0; i < marks.length; i++) {
|
|
183
|
+
marks[i].key = keys[i];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
169
187
|
/** Compute simple (non-grouped) vertical columns. */
|
|
170
188
|
function computeSimpleColumns(
|
|
171
189
|
data: DataRow[],
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|