@opendata-ai/openchart-engine 7.0.3 → 7.1.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.
@@ -230,7 +230,10 @@ describe('compileChart', () => {
230
230
 
231
231
  expect(light.theme.isDark).toBe(false);
232
232
  expect(dark.theme.isDark).toBe(true);
233
- expect(dark.theme.colors.background).not.toBe(light.theme.colors.background);
233
+ // Both modes preserve transparent background — dark mode swaps text/axis/gridline
234
+ // colors but keeps transparency so the host surface shows through.
235
+ expect(dark.theme.colors.background).toBe('transparent');
236
+ expect(light.theme.colors.background).toBe('transparent');
234
237
  // Dark mode text should be light, light mode text should be dark
235
238
  expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
236
239
  });
@@ -142,7 +142,12 @@ describe('computeDimensions', () => {
142
142
 
143
143
  expect(lightDims.theme.isDark).toBe(false);
144
144
  expect(darkDims.theme.isDark).toBe(true);
145
- expect(darkDims.theme.colors.background).not.toBe(lightDims.theme.colors.background);
145
+ // Both modes use transparent background — the dark adaptation changes text/axis
146
+ // colors while keeping transparency so the host surface shows through.
147
+ expect(darkDims.theme.colors.background).toBe('transparent');
148
+ expect(lightDims.theme.colors.background).toBe('transparent');
149
+ // Dark mode uses a different text color
150
+ expect(darkDims.theme.colors.text).not.toBe(lightDims.theme.colors.text);
146
151
  });
147
152
 
148
153
  it('prevents negative chart area dimensions', () => {
@@ -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,
@@ -32,7 +32,7 @@ import { formatLabelValue } from '../_shared/format-label-value';
32
32
 
33
33
  const LABEL_FONT_SIZE = 10;
34
34
  const LABEL_FONT_WEIGHT = 600;
35
- const LABEL_OFFSET_Y = 6;
35
+ const LABEL_OFFSET_Y = 8;
36
36
 
37
37
  // ---------------------------------------------------------------------------
38
38
  // Public API
@@ -91,8 +91,11 @@ export function computeColumnLabels(
91
91
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
92
92
  const textHeight = LABEL_FONT_SIZE * 1.2;
93
93
 
94
- // For positive values, place label above the column top.
95
- // For negative values, place label below the column bottom.
94
+ // anchorY is the TOP of the label bounding box so the collision system's
95
+ // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
96
+ // correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
97
+ // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
98
+ // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
96
99
  const anchorX = mark.x + mark.width / 2;
97
100
  const anchorY = isNegative
98
101
  ? mark.y + mark.height + LABEL_OFFSET_Y
@@ -112,7 +115,7 @@ export function computeColumnLabels(
112
115
  fill: labelColor ?? getRepresentativeColor(mark.fill),
113
116
  lineHeight: 1.2,
114
117
  textAnchor: 'middle',
115
- dominantBaseline: isNegative ? 'hanging' : 'auto',
118
+ dominantBaseline: 'hanging',
116
119
  },
117
120
  });
118
121
  }
@@ -620,12 +620,31 @@ describe('computeAreaMarks', () => {
620
620
  expect(fill.gradient).toBe('linear');
621
621
  expect(fill.stops).toHaveLength(2);
622
622
  expect(fill.stops[0].opacity).toBe(0.65);
623
- expect(fill.stops[1].opacity).toBe(0.35);
623
+ // Light mode: bottom fades to 0 so the colored wash at the base is avoided
624
+ expect(fill.stops[1].opacity).toBe(0);
624
625
  // fillOpacity should be 1 so gradient stop-opacity controls the fade
625
626
  expect(mark.fillOpacity).toBe(1);
626
627
  }
627
628
  });
628
629
 
630
+ it('stacked areas use higher bottom opacity in dark mode', () => {
631
+ const spec = makeMultiSeriesSpec();
632
+ spec.encoding.y!.stack = 'zero';
633
+ const scales = computeScales(spec, chartArea, spec.data);
634
+ const marks = computeAreaMarks(spec, scales, chartArea, true /* darkMode */);
635
+
636
+ expect(marks.length).toBeGreaterThan(0);
637
+ for (const mark of marks) {
638
+ const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
639
+ expect(fill.gradient).toBe('linear');
640
+ expect(fill.stops).toHaveLength(2);
641
+ expect(fill.stops[0].opacity).toBe(0.65);
642
+ // Dark mode: bottom stop is 0.35 so bands remain visible on dark surfaces
643
+ expect(fill.stops[1].opacity).toBe(0.35);
644
+ expect(mark.fillOpacity).toBe(1);
645
+ }
646
+ });
647
+
629
648
  it('stacked: markDef.fill string still overrides per-layer gradient', () => {
630
649
  const spec = makeMultiSeriesSpec();
631
650
  spec.encoding.y!.stack = 'zero';
@@ -80,6 +80,13 @@ const STACKED_GRADIENT_STOPS = [
80
80
  { offset: 1, opacity: 0.35 },
81
81
  ];
82
82
 
83
+ // Light-mode stacked areas: bottom out at opacity 0 rather than 0.35 to avoid
84
+ // the muddy color wash at the base of each band on white/light backgrounds.
85
+ const STACKED_GRADIENT_STOPS_LIGHT = [
86
+ { offset: 0, opacity: 0.65 },
87
+ { offset: 1, opacity: 0 },
88
+ ];
89
+
83
90
  function buildGradientFill(
84
91
  colorStr: string,
85
92
  stops: ReadonlyArray<{ offset: number; opacity: number }>,
@@ -257,6 +264,7 @@ function computeStackedArea(
257
264
  spec: NormalizedChartSpec,
258
265
  scales: ResolvedScales,
259
266
  chartArea: Rect,
267
+ darkMode?: boolean,
260
268
  ): AreaMark[] {
261
269
  const encoding = spec.encoding as Encoding;
262
270
  const xChannel = encoding.x;
@@ -389,7 +397,8 @@ function computeStackedArea(
389
397
  fillOpacity = isGradientDef(markFill) ? 1 : (spec.markDef.opacity ?? 0.7);
390
398
  } else {
391
399
  const colorStr = getRepresentativeColor(color);
392
- fillValue = buildGradientFill(colorStr, STACKED_GRADIENT_STOPS);
400
+ const stackedStops = darkMode ? STACKED_GRADIENT_STOPS : STACKED_GRADIENT_STOPS_LIGHT;
401
+ fillValue = buildGradientFill(colorStr, stackedStops);
393
402
  fillOpacity = 1;
394
403
  }
395
404
 
@@ -443,12 +452,13 @@ export function computeAreaMarks(
443
452
  spec: NormalizedChartSpec,
444
453
  scales: ResolvedScales,
445
454
  chartArea: Rect,
455
+ darkMode?: boolean,
446
456
  ): AreaMark[] {
447
457
  const encoding = spec.encoding as Encoding;
448
458
  const yChannel = encoding.y;
449
459
 
450
460
  if (yChannel && isStacked(yChannel.stack)) {
451
- return computeStackedArea(spec, scales, chartArea);
461
+ return computeStackedArea(spec, scales, chartArea, darkMode);
452
462
  }
453
463
 
454
464
  return computeSingleArea(spec, scales, chartArea);
@@ -71,8 +71,8 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
71
71
  * of whether the layout is stacked (cumulative tops) or overlap (per-series
72
72
  * raw values).
73
73
  */
74
- export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
75
- const areas = computeAreaMarks(spec, scales, chartArea);
74
+ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
75
+ const areas = computeAreaMarks(spec, scales, chartArea, theme.isDark);
76
76
 
77
77
  const encoding = spec.encoding;
78
78
  const hasColor = !!(encoding.color && 'field' in encoding.color);
@@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest';
19
19
 
20
20
  import type { NormalizedChartSpec } from '../../compiler/types';
21
21
  import { bidirectionalSweep, computeEndpointLabels } from '../compute';
22
+ import { ENDPOINT_MARKER_RADIUS } from '../constants';
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Fixtures
@@ -281,7 +282,10 @@ describe('computeEndpointLabels', () => {
281
282
 
282
283
  for (const entry of layout.entries) {
283
284
  expect(entry.marker).toBeDefined();
284
- expect(entry.marker!.x).toBe(lastX);
285
+ // dataX is the original line endpoint; x is offset right by radius so the
286
+ // line terminates at the circle edge rather than its center.
287
+ expect(entry.marker!.dataX).toBe(lastX);
288
+ expect(entry.marker!.x).toBe(lastX + ENDPOINT_MARKER_RADIUS);
285
289
  // Marker y is at the actual data point (not displaced labelY).
286
290
  expect(entry.marker!.y).toBe(entry.dataY);
287
291
  // Open-circle convention: fill = background, stroke = series color.
@@ -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`.
@@ -387,9 +386,12 @@ export function computeEndpointLabels(
387
386
  showLeader: showLeader && displaced,
388
387
  };
389
388
  if (showMarker) {
389
+ // Offset cx right by markerRadius so the line terminates at the circle's
390
+ // left edge rather than its center — prevents the line from piercing the ring.
390
391
  entry.marker = {
391
- x: markerX,
392
+ x: p.dataX + markerRadius,
392
393
  y: p.dataY,
394
+ dataX: p.dataX,
393
395
  fill: markerFill,
394
396
  stroke: config?.markerStyle?.stroke ?? p.color,
395
397
  strokeWidth: markerStrokeWidth,
@@ -12,9 +12,11 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
12
 
13
13
  /**
14
14
  * Minimum gap between adjacent tick labels as a multiple of font size.
15
- * At the default 12px axis font, this yields ~12px of breathing room.
15
+ * At the default 11px axis font, this yields ~5-6px of breathing room.
16
+ * Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
17
+ * with a small number of categories that clearly have room for all labels.
16
18
  */
17
- const MIN_TICK_GAP_FACTOR = 1.0;
19
+ const MIN_TICK_GAP_FACTOR = 0.5;
18
20
 
19
21
  /** Always show at least this many ticks, even if they overlap. */
20
22
  const MIN_TICK_COUNT = 2;