@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.7.0",
3
+ "version": "7.9.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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.7.0",
51
+ "@opendata-ai/openchart-core": "7.9.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -224,6 +224,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
224
224
  },
225
225
  "fill": "#06b6d4",
226
226
  "height": 57.45223880597016,
227
+ "key": "|A",
227
228
  "label": {
228
229
  "connector": undefined,
229
230
  "style": {
@@ -258,6 +259,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
258
259
  },
259
260
  "fill": "#06b6d4",
260
261
  "height": 57.45223880597016,
262
+ "key": "|B",
261
263
  "label": {
262
264
  "connector": undefined,
263
265
  "style": {
@@ -292,6 +294,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
292
294
  },
293
295
  "fill": "#06b6d4",
294
296
  "height": 57.45223880597016,
297
+ "key": "|C",
295
298
  "label": {
296
299
  "connector": undefined,
297
300
  "style": {
@@ -1037,8 +1040,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1037
1040
  "y": 105.2,
1038
1041
  },
1039
1042
  ],
1043
+ "interpolate": "monotone",
1044
+ "key": "US",
1040
1045
  "opacity": undefined,
1041
1046
  "path": "M47,334.25L651.761,105.2",
1047
+ "pointKeys": [
1048
+ "2020-01-01",
1049
+ "2021-01-01",
1050
+ ],
1042
1051
  "points": [
1043
1052
  {
1044
1053
  "x": 47,
@@ -1129,8 +1138,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1129
1138
  "y": 143.375,
1130
1139
  },
1131
1140
  ],
1141
+ "interpolate": "monotone",
1142
+ "key": "UK",
1132
1143
  "opacity": undefined,
1133
1144
  "path": "M47,296.075L651.761,143.375",
1145
+ "pointKeys": [
1146
+ "2020-01-01",
1147
+ "2021-01-01",
1148
+ ],
1134
1149
  "points": [
1135
1150
  {
1136
1151
  "x": 47,
@@ -1221,8 +1236,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1221
1236
  "y": 242.62999999999997,
1222
1237
  },
1223
1238
  ],
1239
+ "interpolate": "monotone",
1240
+ "key": "FR",
1224
1241
  "opacity": undefined,
1225
1242
  "path": "M47,349.52L651.761,242.63",
1243
+ "pointKeys": [
1244
+ "2020-01-01",
1245
+ "2021-01-01",
1246
+ ],
1226
1247
  "points": [
1227
1248
  {
1228
1249
  "x": 47,
@@ -1313,8 +1334,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1313
1334
  "y": 196.82,
1314
1335
  },
1315
1336
  ],
1337
+ "interpolate": "monotone",
1338
+ "key": "DE",
1316
1339
  "opacity": undefined,
1317
1340
  "path": "M47,318.98L651.761,196.82",
1341
+ "pointKeys": [
1342
+ "2020-01-01",
1343
+ "2021-01-01",
1344
+ ],
1318
1345
  "points": [
1319
1346
  {
1320
1347
  "x": 47,
@@ -1804,6 +1831,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1804
1831
  "type": "linear",
1805
1832
  },
1806
1833
  "height": 101.27142857142857,
1834
+ "key": "|A",
1807
1835
  "label": {
1808
1836
  "connector": undefined,
1809
1837
  "style": {
@@ -1849,6 +1877,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1849
1877
  "type": "linear",
1850
1878
  },
1851
1879
  "height": 185.6642857142857,
1880
+ "key": "|B",
1852
1881
  "label": {
1853
1882
  "connector": undefined,
1854
1883
  "style": {
@@ -1894,6 +1923,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1894
1923
  "type": "linear",
1895
1924
  },
1896
1925
  "height": 236.29999999999998,
1926
+ "key": "|C",
1897
1927
  "label": {
1898
1928
  "connector": undefined,
1899
1929
  "style": {
@@ -1939,6 +1969,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1939
1969
  "type": "linear",
1940
1970
  },
1941
1971
  "height": 151.90714285714287,
1972
+ "key": "|D",
1942
1973
  "label": {
1943
1974
  "connector": undefined,
1944
1975
  "style": {
@@ -50,11 +50,11 @@ describe('compileChart with animation', () => {
50
50
  const layout = compileChart(spec, { width: 600, height: 400 });
51
51
 
52
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');
53
+ expect(layout.animation!.enter).toBeDefined();
54
+ expect(layout.animation!.enter!.duration).toBe(500);
55
+ expect(layout.animation!.enter!.ease).toBe('smooth');
56
+ expect(layout.animation!.enter!.staggerDelay).toBe(80);
57
+ expect(layout.animation!.enter!.staggerOrder).toBe('index');
58
58
  expect(layout.animation!.annotationDelay).toBe(200);
59
59
  });
60
60
 
@@ -80,8 +80,8 @@ describe('compileChart with animation', () => {
80
80
  const layout = compileChart(spec, { width: 600, height: 400 });
81
81
 
82
82
  expect(layout.animation).toBeDefined();
83
- expect(layout.animation!.duration).toBe(800);
84
- expect(layout.animation!.ease).toBe('smooth');
83
+ expect(layout.animation!.enter!.duration).toBe(800);
84
+ expect(layout.animation!.enter!.ease).toBe('smooth');
85
85
  expect(layout.animation!.annotationDelay).toBe(500);
86
86
  });
87
87
 
@@ -152,7 +152,7 @@ describe('compileChart with animation', () => {
152
152
  } as ChartSpec;
153
153
  const layoutCompact = compileChart(specNoAnim, { width: 350, height: 400 });
154
154
  expect(layoutCompact.animation).toBeDefined();
155
- expect(layoutCompact.animation!.enabled).toBe(true);
155
+ expect(layoutCompact.animation!.enter).toBeDefined();
156
156
 
157
157
  // At full width (> 700), no override applies, so no animation
158
158
  const layoutFull = compileChart(specNoAnim, { width: 800, height: 400 });
@@ -943,7 +943,7 @@ describe('compileChart', () => {
943
943
  };
944
944
  const layout = compileChart(spec, { width: 320, height: 200 });
945
945
  expect(layout.display).toBe('sparkline');
946
- expect(layout.animation?.enabled).toBe(true);
946
+ expect(layout.animation?.enter).toBeDefined();
947
947
  });
948
948
 
949
949
  it('breakpoint chrome wins when top-level is sparkline', () => {
@@ -1012,8 +1012,8 @@ describe('compileChart', () => {
1012
1012
  { ...lineSpec, display: 'sparkline' as const, animation: true },
1013
1013
  { width: 400, height: 80 },
1014
1014
  );
1015
- expect(layout.animation?.enabled).toBe(true);
1016
- expect(layout.animation?.duration).toBe(1100);
1015
+ expect(layout.animation?.enter).toBeDefined();
1016
+ expect(layout.animation?.enter?.duration).toBe(1100);
1017
1017
  });
1018
1018
 
1019
1019
  it('sparkline + animation: { enter: { duration: 500 } } respects user duration', () => {
@@ -1025,12 +1025,12 @@ describe('compileChart', () => {
1025
1025
  },
1026
1026
  { width: 400, height: 80 },
1027
1027
  );
1028
- expect(layout.animation?.duration).toBe(500);
1028
+ expect(layout.animation?.enter?.duration).toBe(500);
1029
1029
  });
1030
1030
 
1031
1031
  it('full mode + animation: true uses default 500ms (sparkline bump does not leak)', () => {
1032
1032
  const layout = compileChart({ ...lineSpec, animation: true }, { width: 600, height: 400 });
1033
- expect(layout.animation?.duration).toBe(500);
1033
+ expect(layout.animation?.enter?.duration).toBe(500);
1034
1034
  });
1035
1035
 
1036
1036
  // ---------------------------------------------------------------------------
@@ -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
- enabled: true,
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
- enabled: true,
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
- enabled: true,
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 is disabled', () => {
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
- enabled: true,
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[],