@opendata-ai/openchart-engine 6.12.0 → 6.15.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.
Files changed (43) hide show
  1. package/dist/index.js +1022 -648
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +390 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/index.ts +3 -0
  20. package/src/charts/bar/labels.ts +38 -14
  21. package/src/charts/column/__tests__/compute.test.ts +99 -0
  22. package/src/charts/column/compute.ts +27 -6
  23. package/src/charts/column/index.ts +3 -0
  24. package/src/charts/column/labels.ts +35 -13
  25. package/src/charts/dot/index.ts +10 -1
  26. package/src/charts/dot/labels.ts +37 -6
  27. package/src/charts/line/area.ts +31 -6
  28. package/src/charts/line/compute.ts +7 -2
  29. package/src/charts/line/index.ts +33 -2
  30. package/src/charts/post-process.ts +215 -0
  31. package/src/compile.ts +91 -158
  32. package/src/compiler/normalize.ts +2 -2
  33. package/src/layout/axes.ts +12 -15
  34. package/src/layout/dimensions.ts +3 -3
  35. package/src/layout/scales.ts +116 -36
  36. package/src/legend/compute.ts +2 -4
  37. package/src/tooltips/__tests__/compute.test.ts +188 -0
  38. package/src/tooltips/compute.ts +54 -12
  39. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  40. package/src/transforms/__tests__/fold.test.ts +79 -0
  41. package/src/transforms/aggregate.ts +130 -0
  42. package/src/transforms/fold.ts +49 -0
  43. package/src/transforms/index.ts +8 -0
@@ -18,12 +18,24 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
+ abbreviateNumber,
21
22
  buildD3Formatter,
22
23
  estimateTextWidth,
24
+ formatNumber,
23
25
  getRepresentativeColor,
24
26
  resolveCollisions,
25
27
  } from '@opendata-ai/openchart-core';
26
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Format a column value for display (abbreviate large numbers). */
34
+ function formatColumnValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
27
39
  // ---------------------------------------------------------------------------
28
40
  // Constants
29
41
  // ---------------------------------------------------------------------------
@@ -47,6 +59,7 @@ export function computeColumnLabels(
47
59
  density: LabelDensity = 'auto',
48
60
  labelFormat?: string,
49
61
  labelPrefix?: string,
62
+ valueField?: string,
50
63
  ): ResolvedLabel[] {
51
64
  // 'none': no labels at all
52
65
  if (density === 'none') return [];
@@ -60,19 +73,28 @@ export function computeColumnLabels(
60
73
  const candidates: LabelCandidate[] = [];
61
74
 
62
75
  for (const mark of targetMarks) {
63
- // Extract the display value from the aria label.
64
- // Format is "category: value" or "category, group: value".
65
- // Use the last colon to split, which handles colons in category names.
66
- const ariaLabel = mark.aria.label;
67
- const lastColon = ariaLabel.lastIndexOf(':');
68
- const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
69
- if (!rawValue) continue;
70
-
71
- // Apply label format if provided (re-parse the number from the aria string)
72
- let valuePart = rawValue;
73
- if (formatter) {
74
- const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
75
- if (!Number.isNaN(num)) valuePart = formatter(num);
76
+ // Get the original numeric value from the data row when possible,
77
+ // falling back to parsing the aria label (which may lose precision
78
+ // due to abbreviation rounding, e.g. 1955 "2K" 2000).
79
+ let valuePart: string;
80
+ const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
81
+
82
+ if (formatter && Number.isFinite(rawNum)) {
83
+ valuePart = formatter(rawNum);
84
+ } else if (Number.isFinite(rawNum)) {
85
+ valuePart = formatColumnValue(rawNum);
86
+ } else {
87
+ // Fallback: extract from aria label
88
+ const ariaLabel = mark.aria.label;
89
+ const lastColon = ariaLabel.lastIndexOf(':');
90
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
91
+ if (!rawValue) continue;
92
+ if (formatter) {
93
+ const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
94
+ valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
95
+ } else {
96
+ valuePart = rawValue;
97
+ }
76
98
  }
77
99
  if (labelPrefix) valuePart = labelPrefix + valuePart;
78
100
 
@@ -26,7 +26,16 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
26
26
  const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
27
27
 
28
28
  // Compute and attach labels to point marks (respects spec.labels.density)
29
- const labels = computeDotLabels(pointMarks, chartArea, spec.labels.density, spec.labels.prefix);
29
+ const valueField =
30
+ spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
31
+ const labels = computeDotLabels(
32
+ pointMarks,
33
+ chartArea,
34
+ spec.labels.density,
35
+ spec.labels.prefix,
36
+ spec.labels.format,
37
+ valueField,
38
+ );
30
39
  let labelIdx = 0;
31
40
  for (const mark of marks) {
32
41
  if (mark.type === 'point' && labelIdx < labels.length) {
@@ -18,11 +18,24 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
+ abbreviateNumber,
22
+ buildD3Formatter,
21
23
  estimateTextWidth,
24
+ formatNumber,
22
25
  getRepresentativeColor,
23
26
  resolveCollisions,
24
27
  } from '@opendata-ai/openchart-core';
25
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Format a dot value for display (abbreviate large numbers). */
34
+ function formatDotValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
26
39
  // ---------------------------------------------------------------------------
27
40
  // Constants
28
41
  // ---------------------------------------------------------------------------
@@ -45,6 +58,8 @@ export function computeDotLabels(
45
58
  _chartArea: Rect,
46
59
  density: LabelDensity = 'auto',
47
60
  labelPrefix?: string,
61
+ labelFormat?: string,
62
+ valueField?: string,
48
63
  ): ResolvedLabel[] {
49
64
  // 'none': no labels at all
50
65
  if (density === 'none') return [];
@@ -53,15 +68,31 @@ export function computeDotLabels(
53
68
  const targetMarks =
54
69
  density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
55
70
 
71
+ const formatter = buildD3Formatter(labelFormat);
56
72
  const candidates: LabelCandidate[] = [];
57
73
 
58
74
  for (const mark of targetMarks) {
59
- // Extract the display value from the aria label.
60
- // Format is "category: value". Use the last colon to handle colons in category names.
61
- const ariaLabel = mark.aria.label;
62
- const lastColon = ariaLabel.lastIndexOf(':');
63
- let valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
64
- if (!valuePart) continue;
75
+ // Get the original numeric value from the data row when possible,
76
+ // falling back to parsing the aria label (which may lose precision
77
+ // due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
78
+ let valuePart: string;
79
+ const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
80
+
81
+ if (formatter && Number.isFinite(rawNum)) {
82
+ valuePart = formatter(rawNum);
83
+ } else if (Number.isFinite(rawNum)) {
84
+ valuePart = formatDotValue(rawNum);
85
+ } else {
86
+ // Fallback: extract from aria label
87
+ const ariaLabel = mark.aria.label;
88
+ const lastColon = ariaLabel.lastIndexOf(':');
89
+ valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
90
+ if (!valuePart) continue;
91
+ if (formatter) {
92
+ const num = Number(valuePart.replace(/[^0-9.-]/g, ''));
93
+ if (!Number.isNaN(num)) valuePart = formatter(num);
94
+ }
95
+ }
65
96
  if (labelPrefix) valuePart = labelPrefix + valuePart;
66
97
 
67
98
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
@@ -9,7 +9,15 @@
9
9
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
11
  import type { ScaleLinear } from 'd3-scale';
12
- import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
12
+ import {
13
+ area,
14
+ line,
15
+ stack,
16
+ stackOffsetExpand,
17
+ stackOffsetNone,
18
+ stackOffsetSilhouette,
19
+ stackOrderNone,
20
+ } from 'd3-shape';
13
21
 
14
22
  import type { NormalizedChartSpec } from '../../compiler/types';
15
23
  import type { ResolvedScales } from '../../layout/scales';
@@ -66,8 +74,12 @@ function computeSingleArea(
66
74
  for (const [seriesKey, rows] of groups) {
67
75
  const color = getColor(scales, seriesKey);
68
76
 
69
- // Sort rows by x-axis field so areas draw left-to-right
70
- const sortedRows = sortByField(rows, xChannel.field);
77
+ // Sort rows by x-axis field so areas draw left-to-right.
78
+ // For nominal/ordinal axes, preserve data order.
79
+ const sortedRows =
80
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
81
+ ? rows
82
+ : sortByField(rows, xChannel.field);
71
83
 
72
84
  // Compute points, filtering out null values
73
85
  const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
@@ -154,8 +166,12 @@ function computeStackedArea(
154
166
  return computeSingleArea(spec, scales, chartArea);
155
167
  }
156
168
 
157
- // Sort data by x field so stacked areas render left-to-right
158
- const sortedData = sortByField(spec.data, xChannel.field);
169
+ // Sort data by x field so stacked areas render left-to-right.
170
+ // For nominal/ordinal axes, preserve data order.
171
+ const sortedData =
172
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
173
+ ? spec.data
174
+ : sortByField(spec.data, xChannel.field);
159
175
 
160
176
  // Collect unique series keys and x values, and build a lookup from
161
177
  // (x-value, series-key) -> original data row so stacked area marks
@@ -200,11 +216,20 @@ function computeStackedArea(
200
216
  return pivot;
201
217
  });
202
218
 
219
+ // Resolve stack offset from the y channel's stack property
220
+ const stackProp = yChannel.stack;
221
+ const offsetFn =
222
+ stackProp === 'normalize'
223
+ ? stackOffsetExpand
224
+ : stackProp === 'center'
225
+ ? stackOffsetSilhouette
226
+ : stackOffsetNone;
227
+
203
228
  // Use d3 stack to compute the stacked layout
204
229
  const stackGenerator = stack<Record<string, unknown>>()
205
230
  .keys(keys)
206
231
  .order(stackOrderNone)
207
- .offset(stackOffsetNone);
232
+ .offset(offsetFn);
208
233
 
209
234
  const stackedData = stackGenerator(pivotData);
210
235
  const yScale = scales.y.scale as ScaleLinear<number, number>;
@@ -75,8 +75,13 @@ export function computeLineMarks(
75
75
  : getColor(scales, seriesKey);
76
76
  const strokeColor = getRepresentativeColor(color);
77
77
 
78
- // Sort rows by x-axis field so lines draw left-to-right
79
- const sortedRows = sortByField(rows, xChannel.field);
78
+ // Sort rows by x-axis field so lines draw left-to-right.
79
+ // For nominal/ordinal axes, preserve data order since there's no
80
+ // natural sort and the scale domain already reflects intended order.
81
+ const sortedRows =
82
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
83
+ ? rows
84
+ : sortByField(rows, xChannel.field);
80
85
 
81
86
  // Compute pixel positions for each data point, preserving nulls
82
87
  // for line break handling
@@ -4,7 +4,8 @@
4
4
  * Exports line and area chart renderers and computation functions.
5
5
  */
6
6
 
7
- import type { LineMark, Mark } from '@opendata-ai/openchart-core';
7
+ import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
8
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
8
9
  import type { ChartRenderer } from '../registry';
9
10
  import { computeAreaMarks } from './area';
10
11
  import { computeLineMarks } from './compute';
@@ -53,12 +54,42 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
53
54
  */
54
55
  export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
55
56
  const areas = computeAreaMarks(spec, scales, chartArea);
56
- const lines = computeLineMarks(spec, scales, chartArea, strategy);
57
+
58
+ const encoding = spec.encoding;
59
+ const hasColor = !!(encoding.color && 'field' in encoding.color);
60
+
61
+ // For stacked areas, derive line marks from the area top paths so lines
62
+ // align with stacked positions. For non-stacked, compute lines normally.
63
+ const lines = hasColor
64
+ ? linesFromAreas(areas)
65
+ : computeLineMarks(spec, scales, chartArea, strategy);
57
66
 
58
67
  // Areas go first (rendered behind lines), then lines on top
59
68
  return [...areas, ...lines] as Mark[];
60
69
  };
61
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Derive LineMark[] from stacked AreaMark[] using each area's top boundary.
77
+ * This ensures lines sit on top of their corresponding stacked area bands.
78
+ */
79
+ function linesFromAreas(areas: AreaMark[]): LineMark[] {
80
+ return areas.map((a) => ({
81
+ type: 'line' as const,
82
+ points: a.topPoints,
83
+ path: a.topPath,
84
+ stroke: getRepresentativeColor(a.fill),
85
+ strokeWidth: a.strokeWidth ?? 1,
86
+ seriesKey: a.seriesKey,
87
+ data: a.data,
88
+ dataPoints: a.dataPoints,
89
+ aria: { label: `${a.seriesKey ?? 'Series'}: line with ${a.topPoints.length} data points` },
90
+ }));
91
+ }
92
+
62
93
  // ---------------------------------------------------------------------------
63
94
  // Public exports
64
95
  // ---------------------------------------------------------------------------
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Mark post-processing: obstacle computation, renderer dispatch, and
3
+ * animation index assignment. These are mark-type-aware operations that
4
+ * run after the chart renderer has produced marks.
5
+ */
6
+
7
+ import type {
8
+ Encoding,
9
+ Mark,
10
+ MarkDef,
11
+ PointMark,
12
+ Rect,
13
+ RectMark,
14
+ ResolvedAnimation,
15
+ } from '@opendata-ai/openchart-core';
16
+ import type { ResolvedScales } from '../layout/scales';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Mark obstacles for annotation collision avoidance
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Compute bounding rects from marks to use as obstacles for annotation nudging.
24
+ *
25
+ * For band-scale charts (bar, dot): groups marks by band row and returns
26
+ * a single obstacle per row spanning the full band height and x-range.
27
+ *
28
+ * For other charts (column, scatter): returns individual mark bounds so
29
+ * annotations avoid overlapping any visible data mark.
30
+ */
31
+ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
32
+ // Band-scale y-axis: group marks by row for efficient obstacle computation
33
+ if (scales.y?.type === 'band') {
34
+ return computeBandRowObstacles(marks, scales);
35
+ }
36
+
37
+ // All other charts: use individual rect/point mark bounds as obstacles
38
+ const obstacles: Rect[] = [];
39
+ for (const mark of marks) {
40
+ if (mark.type === 'rect') {
41
+ const rm = mark as RectMark;
42
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
43
+ } else if (mark.type === 'point') {
44
+ const pm = mark as PointMark;
45
+ obstacles.push({
46
+ x: pm.cx - pm.r,
47
+ y: pm.cy - pm.r,
48
+ width: pm.r * 2,
49
+ height: pm.r * 2,
50
+ });
51
+ }
52
+ }
53
+ return obstacles;
54
+ }
55
+
56
+ /** Group band-scale marks by row, returning one obstacle per band. */
57
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
58
+ const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
59
+
60
+ for (const mark of marks) {
61
+ let cy: number;
62
+ let left: number;
63
+ let right: number;
64
+
65
+ if (mark.type === 'point') {
66
+ const pm = mark as PointMark;
67
+ cy = pm.cy;
68
+ left = pm.cx - pm.r;
69
+ right = pm.cx + pm.r;
70
+ } else if (mark.type === 'rect') {
71
+ const rm = mark as RectMark;
72
+ cy = rm.y + rm.height / 2;
73
+ left = rm.x;
74
+ right = rm.x + rm.width;
75
+ } else {
76
+ continue;
77
+ }
78
+
79
+ // Round cy to group marks on the same band
80
+ const key = Math.round(cy);
81
+ const existing = rows.get(key);
82
+ if (existing) {
83
+ existing.minX = Math.min(existing.minX, left);
84
+ existing.maxX = Math.max(existing.maxX, right);
85
+ } else {
86
+ rows.set(key, { minX: left, maxX: right, bandY: cy });
87
+ }
88
+ }
89
+
90
+ // Get bandwidth from the band scale
91
+ const bandScale = scales.y!.scale as { bandwidth?: () => number };
92
+ const bandwidth = bandScale.bandwidth?.() ?? 0;
93
+ if (bandwidth === 0) return [];
94
+
95
+ const obstacles: Rect[] = [];
96
+ for (const { minX, maxX, bandY } of rows.values()) {
97
+ obstacles.push({
98
+ x: minX,
99
+ y: bandY - bandwidth / 2,
100
+ width: maxX - minX,
101
+ height: bandwidth,
102
+ });
103
+ }
104
+
105
+ return obstacles;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Renderer key dispatch
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Resolve the renderer key from mark type, encoding, and mark definition.
114
+ *
115
+ * - 'bar' -> 'bar' (horizontal) or 'bar:vertical' based on encoding axis types
116
+ * - 'arc' -> 'arc' (pie) or 'arc:donut' based on innerRadius
117
+ * - All other mark types pass through unchanged
118
+ */
119
+ export function resolveRendererKey(
120
+ markType: string,
121
+ encoding: Partial<Encoding>,
122
+ markDef: Partial<MarkDef>,
123
+ ): string {
124
+ if (markType === 'bar') {
125
+ const xType = encoding.x?.type;
126
+ const yType = encoding.y?.type;
127
+ const isVertical =
128
+ (xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
129
+ yType === 'quantitative';
130
+ if (isVertical) {
131
+ return 'bar:vertical';
132
+ }
133
+ } else if (markType === 'arc') {
134
+ const innerRadius = markDef.innerRadius;
135
+ if (innerRadius && innerRadius > 0) {
136
+ return 'arc:donut';
137
+ }
138
+ }
139
+ return markType;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Animation index assignment
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Extract the primary quantitative value from a mark for value-based stagger ordering. */
147
+ function getMarkPrimaryValue(mark: Mark): number {
148
+ switch (mark.type) {
149
+ case 'rect':
150
+ return mark.height; // bar height is the primary value encoding
151
+ case 'point':
152
+ return mark.cy; // y position for scatter
153
+ case 'arc':
154
+ return mark.endAngle - mark.startAngle; // arc angle extent
155
+ case 'line':
156
+ case 'area':
157
+ return 0; // series marks don't have individual values
158
+ default:
159
+ return 0;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Assign animation indices to marks for stagger ordering.
165
+ *
166
+ * Two phases:
167
+ * 1. Value-based stagger: sorts marks by primary value, assigns sequential indices.
168
+ * Skips stacked rects since they get group-based indices in phase 2.
169
+ * 2. Stack-based stagger: groups marks by stackGroup, assigns the same index to
170
+ * all segments in a group, and computes stackPos (segment position: 0, 1, 2...).
171
+ * This intentionally overwrites any value-based indices for stacked marks.
172
+ */
173
+ export function assignAnimationIndices(
174
+ marks: Mark[],
175
+ animation: ResolvedAnimation | undefined,
176
+ ): void {
177
+ if (!animation?.enabled) return;
178
+
179
+ // Phase 1: Value-based stagger ordering. Skip stacked rects
180
+ // since they get group-based indices below (avoids wasted work that gets overwritten).
181
+ if (animation.staggerOrder === 'value') {
182
+ const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
183
+ indexed.sort((a, b) => {
184
+ const av = getMarkPrimaryValue(a.mark);
185
+ const bv = getMarkPrimaryValue(b.mark);
186
+ return av - bv;
187
+ });
188
+ for (let i = 0; i < indexed.length; i++) {
189
+ const m = indexed[i].mark;
190
+ if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
191
+ m.animationIndex = i;
192
+ }
193
+ }
194
+
195
+ // Phase 2: For stacked bars/columns, assign the same animationIndex to all segments
196
+ // sharing a stackGroup so they animate as one contiguous bar per category.
197
+ // Also compute stackPos (segment position within each group: 0, 1, 2...)
198
+ // so the renderer can chain segment animations sequentially.
199
+ const groupIndexMap = new Map<string, number>();
200
+ const groupStackPos = new Map<string, number>();
201
+ let nextGroupIndex = 0;
202
+ for (const mark of marks) {
203
+ if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
204
+ const rect = mark as RectMark;
205
+ const group = rect.stackGroup!;
206
+ if (!groupIndexMap.has(group)) {
207
+ groupIndexMap.set(group, nextGroupIndex++);
208
+ }
209
+ rect.animationIndex = groupIndexMap.get(group)!;
210
+ const pos = groupStackPos.get(group) ?? 0;
211
+ rect.stackPos = pos;
212
+ groupStackPos.set(group, pos + 1);
213
+ }
214
+ }
215
+ }