@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.
@@ -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[],
@@ -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
- return computeDumbbellMarks(
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
- // Single series: lollipop stems from baseline
95
- return computeLollipopMarks(
96
- spec.data,
97
- xChannel.field,
98
- yChannel.field,
99
- xScale,
100
- yScale,
101
- bandwidth,
102
- baseline,
103
- scales,
104
- isSequentialColor,
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
  // ---------------------------------------------------------------------------
@@ -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
- return [...mutedMarks, ...highlightedMarks];
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
  // ---------------------------------------------------------------------------