@opendata-ai/openchart-engine 7.0.2 → 7.0.4

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.0.2",
3
+ "version": "7.0.4",
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.0.2",
51
+ "@opendata-ai/openchart-core": "7.0.4",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -227,7 +227,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
227
227
  "connector": undefined,
228
228
  "style": {
229
229
  "dominantBaseline": "central",
230
- "fill": "#3c3c3c",
230
+ "fill": "#111111",
231
231
  "fontFamily": "system-ui, -apple-system, sans-serif",
232
232
  "fontSize": 11,
233
233
  "fontWeight": 600,
@@ -260,7 +260,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
260
260
  "connector": undefined,
261
261
  "style": {
262
262
  "dominantBaseline": "central",
263
- "fill": "#3c3c3c",
263
+ "fill": "#111111",
264
264
  "fontFamily": "system-ui, -apple-system, sans-serif",
265
265
  "fontSize": 11,
266
266
  "fontWeight": 600,
@@ -293,7 +293,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
293
293
  "connector": undefined,
294
294
  "style": {
295
295
  "dominantBaseline": "central",
296
- "fill": "#3c3c3c",
296
+ "fill": "#111111",
297
297
  "fontFamily": "system-ui, -apple-system, sans-serif",
298
298
  "fontSize": 11,
299
299
  "fontWeight": 600,
@@ -389,7 +389,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
389
389
  "#2166ac",
390
390
  ],
391
391
  },
392
- "gridline": "rgba(0,0,0,0.06)",
392
+ "gridline": "rgba(0,0,0,0.1)",
393
393
  "negative": "#dc2626",
394
394
  "positive": "#16a34a",
395
395
  "sequential": {
@@ -1359,7 +1359,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1359
1359
  "#2166ac",
1360
1360
  ],
1361
1361
  },
1362
- "gridline": "rgba(0,0,0,0.06)",
1362
+ "gridline": "rgba(0,0,0,0.1)",
1363
1363
  "negative": "#dc2626",
1364
1364
  "positive": "#16a34a",
1365
1365
  "sequential": {
@@ -1948,7 +1948,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
1948
1948
  "#2166ac",
1949
1949
  ],
1950
1950
  },
1951
- "gridline": "rgba(0,0,0,0.06)",
1951
+ "gridline": "rgba(0,0,0,0.1)",
1952
1952
  "negative": "#dc2626",
1953
1953
  "positive": "#16a34a",
1954
1954
  "sequential": {
@@ -143,9 +143,15 @@ describe('computeBarMarks', () => {
143
143
  });
144
144
  });
145
145
 
146
- describe('stacked bars', () => {
147
- it('produces marks for all data rows', () => {
146
+ describe('stacked bars (stack: zero)', () => {
147
+ function makeStackedBarSpec(): NormalizedChartSpec {
148
148
  const spec = makeGroupedBarSpec();
149
+ (spec.encoding.x as { stack?: string }).stack = 'zero';
150
+ return spec;
151
+ }
152
+
153
+ it('produces marks for all data rows', () => {
154
+ const spec = makeStackedBarSpec();
149
155
  const scales = computeScales(spec, chartArea, spec.data);
150
156
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
151
157
 
@@ -154,7 +160,7 @@ describe('computeBarMarks', () => {
154
160
  });
155
161
 
156
162
  it('segments within a category have different colors', () => {
157
- const spec = makeGroupedBarSpec();
163
+ const spec = makeStackedBarSpec();
158
164
  const scales = computeScales(spec, chartArea, spec.data);
159
165
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
160
166
 
@@ -165,7 +171,7 @@ describe('computeBarMarks', () => {
165
171
  });
166
172
 
167
173
  it('stacked segments share the same y position within a category', () => {
168
- const spec = makeGroupedBarSpec();
174
+ const spec = makeStackedBarSpec();
169
175
  const scales = computeScales(spec, chartArea, spec.data);
170
176
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
171
177
 
@@ -181,7 +187,7 @@ describe('computeBarMarks', () => {
181
187
  });
182
188
 
183
189
  it('stacked segments are placed end-to-end horizontally', () => {
184
- const spec = makeGroupedBarSpec();
190
+ const spec = makeStackedBarSpec();
185
191
  const scales = computeScales(spec, chartArea, spec.data);
186
192
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
187
193
 
@@ -193,7 +199,7 @@ describe('computeBarMarks', () => {
193
199
  });
194
200
 
195
201
  it('stacked bars have zero corner radius', () => {
196
- const spec = makeGroupedBarSpec();
202
+ const spec = makeStackedBarSpec();
197
203
  const scales = computeScales(spec, chartArea, spec.data);
198
204
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
199
205
 
@@ -203,15 +209,9 @@ describe('computeBarMarks', () => {
203
209
  });
204
210
  });
205
211
 
206
- describe('grouped bars (stack: null)', () => {
207
- function makeDodgedBarSpec(): NormalizedChartSpec {
208
- const spec = makeGroupedBarSpec();
209
- (spec.encoding.x as { stack?: boolean | null }).stack = null;
210
- return spec;
211
- }
212
-
212
+ describe('grouped bars (default)', () => {
213
213
  it('produces marks for all data rows', () => {
214
- const spec = makeDodgedBarSpec();
214
+ const spec = makeGroupedBarSpec();
215
215
  const scales = computeScales(spec, chartArea, spec.data);
216
216
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
217
217
 
@@ -219,7 +219,7 @@ describe('computeBarMarks', () => {
219
219
  });
220
220
 
221
221
  it('grouped bars within a category have different y positions', () => {
222
- const spec = makeDodgedBarSpec();
222
+ const spec = makeGroupedBarSpec();
223
223
  const scales = computeScales(spec, chartArea, spec.data);
224
224
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
225
225
 
@@ -234,7 +234,7 @@ describe('computeBarMarks', () => {
234
234
  });
235
235
 
236
236
  it('grouped bars all start from baseline (not cumulative)', () => {
237
- const spec = makeDodgedBarSpec();
237
+ const spec = makeGroupedBarSpec();
238
238
  const scales = computeScales(spec, chartArea, spec.data);
239
239
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
240
240
 
@@ -250,7 +250,7 @@ describe('computeBarMarks', () => {
250
250
  });
251
251
 
252
252
  it('grouped bars have cornerRadius 2', () => {
253
- const spec = makeDodgedBarSpec();
253
+ const spec = makeGroupedBarSpec();
254
254
  const scales = computeScales(spec, chartArea, spec.data);
255
255
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
256
256
 
@@ -260,7 +260,7 @@ describe('computeBarMarks', () => {
260
260
  });
261
261
 
262
262
  it('grouped bars do not set stackGroup', () => {
263
- const spec = makeDodgedBarSpec();
263
+ const spec = makeGroupedBarSpec();
264
264
  const scales = computeScales(spec, chartArea, spec.data);
265
265
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
266
266
 
@@ -269,22 +269,23 @@ describe('computeBarMarks', () => {
269
269
  }
270
270
  });
271
271
 
272
- it('sub-band heights are smaller than full bandwidth', () => {
273
- const spec = makeDodgedBarSpec();
274
- const scales = computeScales(spec, chartArea, spec.data);
275
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
272
+ it('sub-band heights are smaller than full bandwidth (stacked bars use full height)', () => {
273
+ const groupedSpec = makeGroupedBarSpec();
274
+ const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
275
+ const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
276
276
 
277
277
  // With 2 groups, each sub-bar should be less than the full bandwidth
278
278
  const stackedSpec = makeGroupedBarSpec();
279
+ (stackedSpec.encoding.x as { stack?: string }).stack = 'zero';
279
280
  const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
280
281
  const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
281
282
 
282
- expect(marks[0].height).toBeLessThan(stackedMarks[0].height);
283
- expect(marks[0].height).toBeGreaterThan(0);
283
+ expect(groupedMarks[0].height).toBeLessThan(stackedMarks[0].height);
284
+ expect(groupedMarks[0].height).toBeGreaterThan(0);
284
285
  });
285
286
 
286
287
  it('scale domain covers max individual value, not stacked sum', () => {
287
- const spec = makeDodgedBarSpec();
288
+ const spec = makeGroupedBarSpec();
288
289
  const scales = computeScales(spec, chartArea, spec.data);
289
290
 
290
291
  // Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
@@ -395,7 +396,7 @@ describe('computeBarMarks', () => {
395
396
  { size: '5,000+ employees', year: '2022', pay: 74800 },
396
397
  ];
397
398
 
398
- function makeWageSpec(stackNull = false): NormalizedChartSpec {
399
+ function makeWageSpec(stackZero = false): NormalizedChartSpec {
399
400
  return {
400
401
  markType: 'bar',
401
402
  markDef: { type: 'bar' },
@@ -404,7 +405,7 @@ describe('computeBarMarks', () => {
404
405
  x: {
405
406
  field: 'pay',
406
407
  type: 'quantitative',
407
- ...(stackNull ? { stack: null } : {}),
408
+ ...(stackZero ? { stack: 'zero' } : {}),
408
409
  },
409
410
  y: { field: 'size', type: 'nominal' },
410
411
  color: { field: 'year', type: 'nominal' },
@@ -418,7 +419,7 @@ describe('computeBarMarks', () => {
418
419
  };
419
420
  }
420
421
 
421
- it('stacks by default: segments are contiguous end-to-end within each category', () => {
422
+ it('groups by default: bars sit at different y positions within each category', () => {
422
423
  const spec = makeWageSpec(false);
423
424
  const scales = computeScales(spec, chartArea, spec.data);
424
425
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
@@ -426,6 +427,30 @@ describe('computeBarMarks', () => {
426
427
  // 2 firm sizes × 2 years = 4 bars
427
428
  expect(marks).toHaveLength(4);
428
429
 
430
+ const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
431
+ expect(smallFirmMarks).toHaveLength(2);
432
+ expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
433
+ });
434
+
435
+ it('groups by default: scale domain covers max individual value, not stacked sum', () => {
436
+ const spec = makeWageSpec(false);
437
+ const scales = computeScales(spec, chartArea, spec.data);
438
+
439
+ // Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
440
+ // Default grouped bars should NOT extend domain to 137100.
441
+ const xScale = scales.x!.scale;
442
+ const domain = xScale.domain() as number[];
443
+ expect(domain[1]).toBeLessThan(137100);
444
+ expect(domain[1]).toBeGreaterThanOrEqual(74800);
445
+ });
446
+
447
+ it('stacked with stack:zero: segments are contiguous end-to-end within each category', () => {
448
+ const spec = makeWageSpec(true);
449
+ const scales = computeScales(spec, chartArea, spec.data);
450
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
451
+
452
+ expect(marks).toHaveLength(4);
453
+
429
454
  // For stacked bars, the second segment starts exactly where the first ends.
430
455
  const smallFirmMarks = marks
431
456
  .filter((m) => m.aria.label.includes('<5'))
@@ -434,8 +459,8 @@ describe('computeBarMarks', () => {
434
459
  expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
435
460
  });
436
461
 
437
- it('stacks by default: segments share the same y position (stacked on same row)', () => {
438
- const spec = makeWageSpec(false);
462
+ it('stacked with stack:zero: segments share the same y position (stacked on same row)', () => {
463
+ const spec = makeWageSpec(true);
439
464
  const scales = computeScales(spec, chartArea, spec.data);
440
465
  const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
441
466
 
@@ -444,12 +469,12 @@ describe('computeBarMarks', () => {
444
469
  expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
445
470
  });
446
471
 
447
- it('grouped with stack:null: bar widths match individual pay values (not cumulative)', () => {
448
- const stackedSpec = makeWageSpec(false);
472
+ it('grouped vs stacked: grouped bars each start from baseline', () => {
473
+ const stackedSpec = makeWageSpec(true);
449
474
  const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
450
475
  const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
451
476
 
452
- const groupedSpec = makeWageSpec(true);
477
+ const groupedSpec = makeWageSpec(false);
453
478
  const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
454
479
  const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
455
480
 
@@ -463,29 +488,6 @@ describe('computeBarMarks', () => {
463
488
  const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
464
489
  expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
465
490
  });
466
-
467
- it('grouped with stack:null: bars sit at different y positions within each category', () => {
468
- const spec = makeWageSpec(true);
469
- const scales = computeScales(spec, chartArea, spec.data);
470
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
471
-
472
- const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
473
- expect(smallFirmMarks).toHaveLength(2);
474
- expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
475
- });
476
-
477
- it('grouped with stack:null: scale domain covers max individual value, not stacked sum', () => {
478
- const spec = makeWageSpec(true);
479
- const scales = computeScales(spec, chartArea, spec.data);
480
-
481
- // Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
482
- // With stack:null the domain should NOT reach 137100.
483
- const xScale = scales.x!.scale;
484
- const domain = xScale.domain() as number[];
485
- expect(domain[1]).toBeLessThan(137100);
486
- // But it should cover the max individual value
487
- expect(domain[1]).toBeGreaterThanOrEqual(74800);
488
- });
489
491
  });
490
492
 
491
493
  describe('edge cases', () => {
@@ -86,7 +86,8 @@ function makeStackedMark(index: number, value: number, width: number): RectMark
86
86
  width,
87
87
  height: 25,
88
88
  fill: '#4e79a7',
89
- cornerRadius: 0, // cornerRadius 0 = stacked segment
89
+ cornerRadius: 0,
90
+ stackGroup: `Cat${index}`,
90
91
  data: { category: `Cat${index}`, value },
91
92
  aria: { label: `Cat${index}: ${value}` },
92
93
  };
@@ -119,7 +120,7 @@ describe('stacked bar label segment-fit', () => {
119
120
  });
120
121
 
121
122
  it('non-stacked bars still show labels regardless of width', () => {
122
- // Non-stacked marks (no cornerRadius = undefined, not 0)
123
+ // Non-stacked marks have no stackGroup
123
124
  const nonStackedMarks: RectMark[] = [
124
125
  makeMark(0, 10), // width = 50
125
126
  makeMark(1, 20), // width = 100
@@ -119,10 +119,14 @@ export function computeBarMarks(
119
119
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
120
120
 
121
121
  if (needsStacking) {
122
- // stack: null or false -> grouped (side-by-side) bars
123
- const stackDisabled = xChannel.stack === null || xChannel.stack === false;
124
-
125
- if (stackDisabled) {
122
+ // stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
123
+ const stackEnabled =
124
+ xChannel.stack === true ||
125
+ xChannel.stack === 'zero' ||
126
+ xChannel.stack === 'normalize' ||
127
+ xChannel.stack === 'center';
128
+
129
+ if (!stackEnabled) {
126
130
  marks = computeGroupedBars(
127
131
  spec.data,
128
132
  xChannel.field,
@@ -20,8 +20,8 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
- findAccessibleColor,
24
23
  getRepresentativeColor,
24
+ pickLabelColor,
25
25
  resolveCollisions,
26
26
  } from '@opendata-ai/openchart-core';
27
27
  import { filterByDensity } from '../_shared/density-filter';
@@ -136,7 +136,7 @@ export function computeBarLabels(
136
136
  const textHeight = LABEL_FONT_SIZE * 1.2;
137
137
 
138
138
  // Detect stacked bars: cornerRadius 0 indicates stacked segment
139
- const isStacked = mark.cornerRadius === 0;
139
+ const isStacked = mark.stackGroup !== undefined;
140
140
 
141
141
  // Determine if label goes inside or outside the bar
142
142
  const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
@@ -150,18 +150,18 @@ export function computeBarLabels(
150
150
  if (isStacked && isInside) {
151
151
  // Stacked: centered within segment
152
152
  anchorX = mark.x + mark.width / 2;
153
- fill = findAccessibleColor('#ffffff', bgColor, 4.5);
153
+ fill = pickLabelColor(bgColor);
154
154
  textAnchor = 'middle';
155
155
  } else if (isInside) {
156
156
  if (isNegative) {
157
157
  // Negative bar: left-aligned within bar (bar extends leftward)
158
158
  anchorX = mark.x + LABEL_PADDING;
159
- fill = findAccessibleColor('#ffffff', bgColor, 4.5);
159
+ fill = pickLabelColor(bgColor);
160
160
  textAnchor = 'start';
161
161
  } else {
162
162
  // Positive bar: right-aligned within bar
163
163
  anchorX = mark.x + mark.width - LABEL_PADDING;
164
- fill = findAccessibleColor('#ffffff', bgColor, 4.5);
164
+ fill = pickLabelColor(bgColor);
165
165
  textAnchor = 'end';
166
166
  }
167
167
  } else {
@@ -148,9 +148,15 @@ describe('computeColumnMarks', () => {
148
148
  });
149
149
  });
150
150
 
151
- describe('stacked columns', () => {
152
- it('produces marks for all data rows', () => {
151
+ describe('stacked columns (stack: zero)', () => {
152
+ function makeStackedColumnSpec(): NormalizedChartSpec {
153
153
  const spec = makeGroupedColumnSpec();
154
+ (spec.encoding.y as { stack?: string }).stack = 'zero';
155
+ return spec;
156
+ }
157
+
158
+ it('produces marks for all data rows', () => {
159
+ const spec = makeStackedColumnSpec();
154
160
  const scales = computeScales(spec, chartArea, spec.data);
155
161
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
156
162
 
@@ -159,7 +165,7 @@ describe('computeColumnMarks', () => {
159
165
  });
160
166
 
161
167
  it('stacked segments within a category have different colors', () => {
162
- const spec = makeGroupedColumnSpec();
168
+ const spec = makeStackedColumnSpec();
163
169
  const scales = computeScales(spec, chartArea, spec.data);
164
170
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
165
171
 
@@ -169,7 +175,7 @@ describe('computeColumnMarks', () => {
169
175
  });
170
176
 
171
177
  it('stacked columns within a category share the same x position', () => {
172
- const spec = makeGroupedColumnSpec();
178
+ const spec = makeStackedColumnSpec();
173
179
  const scales = computeScales(spec, chartArea, spec.data);
174
180
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
175
181
 
@@ -187,15 +193,9 @@ describe('computeColumnMarks', () => {
187
193
  });
188
194
  });
189
195
 
190
- describe('grouped columns (stack: null)', () => {
191
- function makeDodgedColumnSpec(): NormalizedChartSpec {
192
- const spec = makeGroupedColumnSpec();
193
- (spec.encoding.y as { stack?: boolean | null }).stack = null;
194
- return spec;
195
- }
196
-
196
+ describe('grouped columns (default)', () => {
197
197
  it('produces marks for all data rows', () => {
198
- const spec = makeDodgedColumnSpec();
198
+ const spec = makeGroupedColumnSpec();
199
199
  const scales = computeScales(spec, chartArea, spec.data);
200
200
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
201
201
 
@@ -203,7 +203,7 @@ describe('computeColumnMarks', () => {
203
203
  });
204
204
 
205
205
  it('grouped columns within a category have different x positions', () => {
206
- const spec = makeDodgedColumnSpec();
206
+ const spec = makeGroupedColumnSpec();
207
207
  const scales = computeScales(spec, chartArea, spec.data);
208
208
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
209
209
 
@@ -217,22 +217,23 @@ describe('computeColumnMarks', () => {
217
217
  expect(janNorth.x).not.toBe(janSouth.x);
218
218
  });
219
219
 
220
- it('grouped columns have subdivided widths', () => {
221
- const spec = makeDodgedColumnSpec();
222
- const scales = computeScales(spec, chartArea, spec.data);
223
- const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
220
+ it('grouped columns have subdivided widths (vs stacked full bandwidth)', () => {
221
+ const groupedSpec = makeGroupedColumnSpec();
222
+ const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
223
+ const groupedMarks = computeColumnMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
224
224
 
225
225
  // With 2 groups, each sub-column should be narrower than full bandwidth
226
226
  const stackedSpec = makeGroupedColumnSpec();
227
+ (stackedSpec.encoding.y as { stack?: string }).stack = 'zero';
227
228
  const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
228
229
  const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
229
230
 
230
- expect(marks[0].width).toBeLessThan(stackedMarks[0].width);
231
- expect(marks[0].width).toBeGreaterThan(0);
231
+ expect(groupedMarks[0].width).toBeLessThan(stackedMarks[0].width);
232
+ expect(groupedMarks[0].width).toBeGreaterThan(0);
232
233
  });
233
234
 
234
235
  it('grouped columns have cornerRadius 2 and no stackGroup', () => {
235
- const spec = makeDodgedColumnSpec();
236
+ const spec = makeGroupedColumnSpec();
236
237
  const scales = computeScales(spec, chartArea, spec.data);
237
238
  const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
238
239
 
@@ -243,7 +244,7 @@ describe('computeColumnMarks', () => {
243
244
  });
244
245
 
245
246
  it('scale domain covers max individual value, not stacked sum', () => {
246
- const spec = makeDodgedColumnSpec();
247
+ const spec = makeGroupedColumnSpec();
247
248
  const scales = computeScales(spec, chartArea, spec.data);
248
249
 
249
250
  // Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
@@ -94,9 +94,14 @@ export function computeColumnMarks(
94
94
  const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
95
95
 
96
96
  if (needsStacking) {
97
- const stackDisabled = yChannel.stack === null || yChannel.stack === false;
98
-
99
- if (stackDisabled) {
97
+ // stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
98
+ const stackEnabled =
99
+ yChannel.stack === true ||
100
+ yChannel.stack === 'zero' ||
101
+ yChannel.stack === 'normalize' ||
102
+ yChannel.stack === 'center';
103
+
104
+ if (!stackEnabled) {
100
105
  marks = computeGroupedColumns(
101
106
  spec.data,
102
107
  xChannel.field,
@@ -360,7 +360,6 @@ export function computeEndpointLabels(
360
360
  32,
361
361
  );
362
362
  const columnX = chartArea.x + chartArea.width + ENDPOINT_COLUMN_GAP;
363
- const markerX = chartArea.x + chartArea.width;
364
363
 
365
364
  // The marker is the line's visual terminator — always at (chartRightX, dataY).
366
365
  // The swatch + label first-line baseline-center sit at `labelY + fontSize/2`.
@@ -388,7 +387,7 @@ export function computeEndpointLabels(
388
387
  };
389
388
  if (showMarker) {
390
389
  entry.marker = {
391
- x: markerX,
390
+ x: p.dataX,
392
391
  y: p.dataY,
393
392
  fill: markerFill,
394
393
  stroke: config?.markerStyle?.stroke ?? p.color,
@@ -667,12 +667,16 @@ export function computeScales(
667
667
  // Without this, stacked bars would clip past the chart area.
668
668
  let xData = data;
669
669
  let xChannel = encoding.x;
670
- const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
670
+ const xStackEnabled =
671
+ encoding.x.stack === true ||
672
+ encoding.x.stack === 'zero' ||
673
+ encoding.x.stack === 'normalize' ||
674
+ encoding.x.stack === 'center';
671
675
  if (
672
676
  spec.markType === 'bar' &&
673
677
  encoding.color &&
674
678
  encoding.x.type === 'quantitative' &&
675
- !xStackDisabled
679
+ xStackEnabled
676
680
  ) {
677
681
  if (encoding.x.stack === 'normalize') {
678
682
  // Normalize: domain is [0, 1]
@@ -738,9 +742,7 @@ export function computeScales(
738
742
  spec.markType === 'bar' &&
739
743
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
740
744
  encoding.y.type === 'quantitative';
741
- // Bar default is stacked, so undefined counts as stacked. Area default is
742
- // overlap (v6), so the stacked-domain expansion only applies when the user
743
- // explicitly opts into stacking.
745
+ // Both bar and area require explicit opt-in for stacked domain expansion.
744
746
  const stackProp = encoding.y.stack;
745
747
  const isExplicitlyStacked =
746
748
  stackProp === true ||
@@ -748,7 +750,7 @@ export function computeScales(
748
750
  stackProp === 'normalize' ||
749
751
  stackProp === 'center';
750
752
  const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
751
- const isBarStacked = isVerticalBar && stackProp !== null && stackProp !== false;
753
+ const isBarStacked = isVerticalBar && isExplicitlyStacked;
752
754
 
753
755
  // Sparkline tightening: drop the default `zero: true` baseline so the
754
756
  // y-domain hugs the actual data range. Without this, a series with