@opendata-ai/openchart-engine 7.3.0 → 7.4.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.
@@ -11,6 +11,11 @@ import { computeBarLabels } from '../labels';
11
11
 
12
12
  const chartArea: Rect = { x: 80, y: 20, width: 500, height: 300 };
13
13
 
14
+ // Default container width for tests that don't exercise the narrow-container
15
+ // grouped-bar reclaim gate. Wide enough (>= NARROW_VIEWPORT_MAX) that the gate
16
+ // stays off, matching the prior default geometry.
17
+ const CONTAINER_WIDTH = 800;
18
+
14
19
  const fullStrategy: LayoutStrategy = {
15
20
  labelMode: 'all',
16
21
  legendPosition: 'right',
@@ -97,7 +102,7 @@ describe('computeBarMarks', () => {
97
102
  it('produces one RectMark per data row', () => {
98
103
  const spec = makeSimpleBarSpec();
99
104
  const scales = computeScales(spec, chartArea, spec.data);
100
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
105
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
101
106
 
102
107
  expect(marks).toHaveLength(3);
103
108
  expect(marks.every((m) => m.type === 'rect')).toBe(true);
@@ -106,7 +111,7 @@ describe('computeBarMarks', () => {
106
111
  it('bars have positive width and height', () => {
107
112
  const spec = makeSimpleBarSpec();
108
113
  const scales = computeScales(spec, chartArea, spec.data);
109
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
114
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
110
115
 
111
116
  for (const mark of marks) {
112
117
  expect(mark.width).toBeGreaterThan(0);
@@ -117,7 +122,7 @@ describe('computeBarMarks', () => {
117
122
  it('wider bars correspond to larger values', () => {
118
123
  const spec = makeSimpleBarSpec();
119
124
  const scales = computeScales(spec, chartArea, spec.data);
120
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
125
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
121
126
 
122
127
  // Cherry (70) should be wider than Banana (30)
123
128
  const cherry = marks.find((m) => m.aria.label.includes('Cherry'))!;
@@ -128,7 +133,7 @@ describe('computeBarMarks', () => {
128
133
  it('bars have corner radius applied', () => {
129
134
  const spec = makeSimpleBarSpec();
130
135
  const scales = computeScales(spec, chartArea, spec.data);
131
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
136
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
132
137
 
133
138
  expect(marks[0].cornerRadius).toBe(2);
134
139
  });
@@ -136,7 +141,7 @@ describe('computeBarMarks', () => {
136
141
  it('each bar has an aria label with category and value', () => {
137
142
  const spec = makeSimpleBarSpec();
138
143
  const scales = computeScales(spec, chartArea, spec.data);
139
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
144
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
140
145
 
141
146
  expect(marks[0].aria.label).toContain('Apple');
142
147
  expect(marks[0].aria.label).toContain('50');
@@ -153,7 +158,7 @@ describe('computeBarMarks', () => {
153
158
  it('produces marks for all data rows', () => {
154
159
  const spec = makeStackedBarSpec();
155
160
  const scales = computeScales(spec, chartArea, spec.data);
156
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
161
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
157
162
 
158
163
  // 3 categories * 2 groups = 6
159
164
  expect(marks).toHaveLength(6);
@@ -162,7 +167,7 @@ describe('computeBarMarks', () => {
162
167
  it('segments within a category have different colors', () => {
163
168
  const spec = makeStackedBarSpec();
164
169
  const scales = computeScales(spec, chartArea, spec.data);
165
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
170
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
166
171
 
167
172
  // Find Q1 bars
168
173
  const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
@@ -173,7 +178,7 @@ describe('computeBarMarks', () => {
173
178
  it('stacked segments share the same y position within a category', () => {
174
179
  const spec = makeStackedBarSpec();
175
180
  const scales = computeScales(spec, chartArea, spec.data);
176
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
181
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
177
182
 
178
183
  const q1East = marks.find(
179
184
  (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
@@ -189,7 +194,7 @@ describe('computeBarMarks', () => {
189
194
  it('stacked segments are placed end-to-end horizontally', () => {
190
195
  const spec = makeStackedBarSpec();
191
196
  const scales = computeScales(spec, chartArea, spec.data);
192
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
197
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
193
198
 
194
199
  const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
195
200
  // Second segment should start where first ends
@@ -201,7 +206,7 @@ describe('computeBarMarks', () => {
201
206
  it('stacked bars have zero corner radius', () => {
202
207
  const spec = makeStackedBarSpec();
203
208
  const scales = computeScales(spec, chartArea, spec.data);
204
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
209
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
205
210
 
206
211
  for (const mark of marks) {
207
212
  expect(mark.cornerRadius).toBe(0);
@@ -213,7 +218,7 @@ describe('computeBarMarks', () => {
213
218
  it('produces marks for all data rows', () => {
214
219
  const spec = makeGroupedBarSpec();
215
220
  const scales = computeScales(spec, chartArea, spec.data);
216
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
221
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
217
222
 
218
223
  expect(marks).toHaveLength(6);
219
224
  });
@@ -221,7 +226,7 @@ describe('computeBarMarks', () => {
221
226
  it('grouped bars within a category have different y positions', () => {
222
227
  const spec = makeGroupedBarSpec();
223
228
  const scales = computeScales(spec, chartArea, spec.data);
224
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
229
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
225
230
 
226
231
  const q1East = marks.find(
227
232
  (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
@@ -236,7 +241,7 @@ describe('computeBarMarks', () => {
236
241
  it('grouped bars all start from baseline (not cumulative)', () => {
237
242
  const spec = makeGroupedBarSpec();
238
243
  const scales = computeScales(spec, chartArea, spec.data);
239
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
244
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
240
245
 
241
246
  const q1East = marks.find(
242
247
  (m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
@@ -252,7 +257,7 @@ describe('computeBarMarks', () => {
252
257
  it('grouped bars have cornerRadius 2', () => {
253
258
  const spec = makeGroupedBarSpec();
254
259
  const scales = computeScales(spec, chartArea, spec.data);
255
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
260
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
256
261
 
257
262
  for (const mark of marks) {
258
263
  expect(mark.cornerRadius).toBe(2);
@@ -262,7 +267,7 @@ describe('computeBarMarks', () => {
262
267
  it('grouped bars do not set stackGroup', () => {
263
268
  const spec = makeGroupedBarSpec();
264
269
  const scales = computeScales(spec, chartArea, spec.data);
265
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
270
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
266
271
 
267
272
  for (const mark of marks) {
268
273
  expect(mark.stackGroup).toBeUndefined();
@@ -272,13 +277,25 @@ describe('computeBarMarks', () => {
272
277
  it('sub-band heights are smaller than full bandwidth (stacked bars use full height)', () => {
273
278
  const groupedSpec = makeGroupedBarSpec();
274
279
  const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
275
- const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
280
+ const groupedMarks = computeBarMarks(
281
+ groupedSpec,
282
+ groupedScales,
283
+ chartArea,
284
+ fullStrategy,
285
+ CONTAINER_WIDTH,
286
+ );
276
287
 
277
288
  // With 2 groups, each sub-bar should be less than the full bandwidth
278
289
  const stackedSpec = makeGroupedBarSpec();
279
290
  (stackedSpec.encoding.x as { stack?: string }).stack = 'zero';
280
291
  const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
281
- const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
292
+ const stackedMarks = computeBarMarks(
293
+ stackedSpec,
294
+ stackedScales,
295
+ chartArea,
296
+ fullStrategy,
297
+ CONTAINER_WIDTH,
298
+ );
282
299
 
283
300
  expect(groupedMarks[0].height).toBeLessThan(stackedMarks[0].height);
284
301
  expect(groupedMarks[0].height).toBeGreaterThan(0);
@@ -294,6 +311,57 @@ describe('computeBarMarks', () => {
294
311
  // Domain should not extend to the stacked sum (115)
295
312
  expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
296
313
  });
314
+
315
+ // Issue 1: at tight bandwidths on narrow plots, grouped sub-bars fall below
316
+ // the readable floor and read as stripes. The engine reclaims reserved
317
+ // inter-category whitespace to keep bars >= MIN_GROUPED_BAR_THICKNESS (8px).
318
+ it('reclaims band whitespace so narrow-plot sub-bars meet the readable floor', () => {
319
+ // 11 categories x 3 series in a short, phone-width plot -> sub-bars would
320
+ // be ~7px without the reclaim.
321
+ const data = [] as { district: string; source: string; dollars: number }[];
322
+ for (let i = 0; i < 11; i++) {
323
+ for (const source of ['State', 'Local', 'Federal']) {
324
+ data.push({ district: `D${i}`, source, dollars: 100 + i });
325
+ }
326
+ }
327
+ const spec: NormalizedChartSpec = {
328
+ markType: 'bar',
329
+ markDef: { type: 'bar' },
330
+ data,
331
+ encoding: {
332
+ x: { field: 'dollars', type: 'quantitative' },
333
+ y: { field: 'district', type: 'nominal' },
334
+ color: { field: 'source', type: 'nominal' },
335
+ },
336
+ chrome: {},
337
+ annotations: [],
338
+ responsive: true,
339
+ theme: {},
340
+ darkMode: 'off',
341
+ labels: { density: 'auto', format: '' },
342
+ };
343
+
344
+ // Narrow container (< NARROW_VIEWPORT_MAX): reclaim active. Same plot
345
+ // geometry both times; only the container width toggles the gate, proving
346
+ // the gate keys off the container, not the derived plot width.
347
+ const plotArea: Rect = { x: 120, y: 20, width: 240, height: 380 };
348
+ const scales = computeScales(spec, plotArea, spec.data);
349
+ const narrowMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 400);
350
+ for (const mark of narrowMarks) {
351
+ expect(mark.height).toBeGreaterThanOrEqual(8);
352
+ }
353
+
354
+ // Wide container (>= NARROW_VIEWPORT_MAX): reclaim gated off, so the same
355
+ // tight-height plot is left untouched (bars may be thin, matching the
356
+ // pre-fix desktop geometry). Bars must not be *taller* than the narrow
357
+ // case, proving the reclaim only applies for narrow containers. A wide
358
+ // container with a sub-500px plot (e.g. a large left gutter) is exactly
359
+ // the desktop-embed case that must stay pixel-identical.
360
+ const wideMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 700);
361
+ const wideMax = Math.max(...wideMarks.map((m) => m.height));
362
+ const narrowMin = Math.min(...narrowMarks.map((m) => m.height));
363
+ expect(wideMax).toBeLessThan(narrowMin);
364
+ });
297
365
  });
298
366
 
299
367
  describe('colored (non-stacked) bars', () => {
@@ -319,7 +387,7 @@ describe('computeBarMarks', () => {
319
387
  labels: { density: 'auto', format: '' },
320
388
  };
321
389
  const scales = computeScales(spec, chartArea, spec.data);
322
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
390
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
323
391
 
324
392
  expect(marks).toHaveLength(3);
325
393
  // Each bar should have different colors
@@ -352,7 +420,7 @@ describe('computeBarMarks', () => {
352
420
  labels: { density: 'auto', format: '' },
353
421
  };
354
422
  const scales = computeScales(spec, chartArea, spec.data);
355
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
423
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
356
424
 
357
425
  expect(marks).toHaveLength(2);
358
426
  const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
@@ -367,7 +435,7 @@ describe('computeBarMarks', () => {
367
435
  it('negative bars extend leftward from baseline', () => {
368
436
  const spec = makeNegativeBarSpec();
369
437
  const scales = computeScales(spec, chartArea, spec.data);
370
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
438
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
371
439
 
372
440
  const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
373
441
  const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
@@ -379,7 +447,7 @@ describe('computeBarMarks', () => {
379
447
  it('negative bars still have positive width', () => {
380
448
  const spec = makeNegativeBarSpec();
381
449
  const scales = computeScales(spec, chartArea, spec.data);
382
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
450
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
383
451
 
384
452
  for (const mark of marks) {
385
453
  expect(mark.width).toBeGreaterThan(0);
@@ -422,7 +490,7 @@ describe('computeBarMarks', () => {
422
490
  it('groups by default: bars sit at different y positions within each category', () => {
423
491
  const spec = makeWageSpec(false);
424
492
  const scales = computeScales(spec, chartArea, spec.data);
425
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
493
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
426
494
 
427
495
  // 2 firm sizes × 2 years = 4 bars
428
496
  expect(marks).toHaveLength(4);
@@ -447,7 +515,7 @@ describe('computeBarMarks', () => {
447
515
  it('stacked with stack:zero: segments are contiguous end-to-end within each category', () => {
448
516
  const spec = makeWageSpec(true);
449
517
  const scales = computeScales(spec, chartArea, spec.data);
450
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
518
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
451
519
 
452
520
  expect(marks).toHaveLength(4);
453
521
 
@@ -462,7 +530,7 @@ describe('computeBarMarks', () => {
462
530
  it('stacked with stack:zero: segments share the same y position (stacked on same row)', () => {
463
531
  const spec = makeWageSpec(true);
464
532
  const scales = computeScales(spec, chartArea, spec.data);
465
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
533
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
466
534
 
467
535
  const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
468
536
  expect(smallFirmMarks).toHaveLength(2);
@@ -472,11 +540,23 @@ describe('computeBarMarks', () => {
472
540
  it('grouped vs stacked: grouped bars each start from baseline', () => {
473
541
  const stackedSpec = makeWageSpec(true);
474
542
  const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
475
- const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
543
+ const stackedMarks = computeBarMarks(
544
+ stackedSpec,
545
+ stackedScales,
546
+ chartArea,
547
+ fullStrategy,
548
+ CONTAINER_WIDTH,
549
+ );
476
550
 
477
551
  const groupedSpec = makeWageSpec(false);
478
552
  const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
479
- const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
553
+ const groupedMarks = computeBarMarks(
554
+ groupedSpec,
555
+ groupedScales,
556
+ chartArea,
557
+ fullStrategy,
558
+ CONTAINER_WIDTH,
559
+ );
480
560
 
481
561
  expect(groupedMarks).toHaveLength(4);
482
562
 
@@ -495,7 +575,7 @@ describe('computeBarMarks', () => {
495
575
  const spec = makeSimpleBarSpec();
496
576
  (spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
497
577
  const scales = computeScales(spec, chartArea, spec.data);
498
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
578
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
499
579
 
500
580
  const xScale = scales.x!.scale as (v: number) => number;
501
581
  const left = xScale(65);
@@ -517,7 +597,7 @@ describe('computeBarMarks', () => {
517
597
  // Lift values into the [65, 95] window so widths stay positive.
518
598
  spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
519
599
  const scales = computeScales(spec, chartArea, spec.data);
520
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
600
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
521
601
 
522
602
  const xScale = scales.x!.scale as (v: number) => number;
523
603
  const left = xScale(65);
@@ -533,7 +613,7 @@ describe('computeBarMarks', () => {
533
613
  it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
534
614
  const spec = makeSimpleBarSpec();
535
615
  const scales = computeScales(spec, chartArea, spec.data);
536
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
616
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
537
617
 
538
618
  const xScale = scales.x!.scale as (v: number) => number;
539
619
  for (const mark of marks) {
@@ -544,7 +624,7 @@ describe('computeBarMarks', () => {
544
624
  it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
545
625
  const spec = makeNegativeBarSpec();
546
626
  const scales = computeScales(spec, chartArea, spec.data);
547
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
627
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
548
628
 
549
629
  const xScale = scales.x!.scale as (v: number) => number;
550
630
  const zero = xScale(0);
@@ -574,7 +654,7 @@ describe('computeBarMarks', () => {
574
654
  labels: { density: 'auto', format: '' },
575
655
  };
576
656
  const scales = computeScales(spec, chartArea, spec.data);
577
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
657
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
578
658
  expect(marks).toHaveLength(0);
579
659
  });
580
660
 
@@ -595,7 +675,7 @@ describe('computeBarMarks', () => {
595
675
  labels: { density: 'auto', format: '' },
596
676
  };
597
677
  const scales = computeScales(spec, chartArea, spec.data);
598
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
678
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
599
679
  expect(marks).toHaveLength(0);
600
680
  });
601
681
  });
@@ -609,7 +689,7 @@ describe('computeBarLabels', () => {
609
689
  it('produces one label per bar mark', () => {
610
690
  const spec = makeSimpleBarSpec();
611
691
  const scales = computeScales(spec, chartArea, spec.data);
612
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
692
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
613
693
  const labels = computeBarLabels(marks, chartArea);
614
694
 
615
695
  expect(labels).toHaveLength(marks.length);
@@ -618,7 +698,7 @@ describe('computeBarLabels', () => {
618
698
  it('labels contain the value text', () => {
619
699
  const spec = makeSimpleBarSpec();
620
700
  const scales = computeScales(spec, chartArea, spec.data);
621
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
701
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
622
702
  const labels = computeBarLabels(marks, chartArea);
623
703
 
624
704
  const texts = labels.map((l) => l.text);
@@ -630,7 +710,7 @@ describe('computeBarLabels', () => {
630
710
  it('applies d3 label format string', () => {
631
711
  const spec = makeSimpleBarSpec();
632
712
  const scales = computeScales(spec, chartArea, spec.data);
633
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
713
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
634
714
  const labels = computeBarLabels(marks, chartArea, 'auto', '$,.0f');
635
715
 
636
716
  const texts = labels.map((l) => l.text);
@@ -659,7 +739,7 @@ describe('computeBarLabels', () => {
659
739
  labels: { density: 'all', format: '$,.2~fT' },
660
740
  };
661
741
  const scales = computeScales(spec, chartArea, spec.data);
662
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
742
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
663
743
  const labels = computeBarLabels(marks, chartArea, 'all', '$,.2~fT');
664
744
 
665
745
  const texts = labels.map((l) => l.text);
@@ -670,7 +750,7 @@ describe('computeBarLabels', () => {
670
750
  it('applies format with non-alpha suffix (e.g. "%")', () => {
671
751
  const spec = makeSimpleBarSpec();
672
752
  const scales = computeScales(spec, chartArea, spec.data);
673
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
753
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
674
754
  const labels = computeBarLabels(marks, chartArea, 'auto', '.0f%');
675
755
 
676
756
  const texts = labels.map((l) => l.text);
@@ -701,7 +781,7 @@ describe('computeBarLabels', () => {
701
781
  labels: { density: 'auto', format: '', prefix: '' },
702
782
  };
703
783
  const scales = computeScales(spec, smallArea, spec.data);
704
- const marks = computeBarMarks(spec, scales, smallArea, fullStrategy);
784
+ const marks = computeBarMarks(spec, scales, smallArea, fullStrategy, CONTAINER_WIDTH);
705
785
  const labels = computeBarLabels(
706
786
  marks,
707
787
  smallArea,
@@ -726,7 +806,7 @@ describe('markDef overrides', () => {
726
806
  const spec = makeSimpleBarSpec();
727
807
  spec.markDef = { type: 'bar', size: 6 };
728
808
  const scales = computeScales(spec, chartArea, spec.data);
729
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
809
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
730
810
 
731
811
  for (const mark of marks) {
732
812
  expect(mark.height).toBe(6);
@@ -735,7 +815,13 @@ describe('markDef overrides', () => {
735
815
  // Bars should be centered: offset = (bandwidth - 6) / 2
736
816
  const specDefault = makeSimpleBarSpec();
737
817
  const scalesDefault = computeScales(specDefault, chartArea, specDefault.data);
738
- const marksDefault = computeBarMarks(specDefault, scalesDefault, chartArea, fullStrategy);
818
+ const marksDefault = computeBarMarks(
819
+ specDefault,
820
+ scalesDefault,
821
+ chartArea,
822
+ fullStrategy,
823
+ CONTAINER_WIDTH,
824
+ );
739
825
  for (let i = 0; i < marks.length; i++) {
740
826
  expect(marks[i].y).toBeGreaterThan(marksDefault[i].y);
741
827
  }
@@ -745,7 +831,7 @@ describe('markDef overrides', () => {
745
831
  const spec = makeSimpleBarSpec();
746
832
  spec.markDef = { type: 'bar', size: 9999 };
747
833
  const scales = computeScales(spec, chartArea, spec.data);
748
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
834
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
749
835
 
750
836
  const bandwidth = marks[0].height;
751
837
  // Should be capped at bandwidth, not 9999
@@ -756,7 +842,7 @@ describe('markDef overrides', () => {
756
842
  const spec = makeSimpleBarSpec();
757
843
  spec.markDef = { type: 'bar', size: 6, cornerRadius: 'pill' };
758
844
  const scales = computeScales(spec, chartArea, spec.data);
759
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
845
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
760
846
 
761
847
  for (const mark of marks) {
762
848
  expect(mark.cornerRadius).toBe(3);
@@ -767,7 +853,7 @@ describe('markDef overrides', () => {
767
853
  const spec = makeSimpleBarSpec();
768
854
  spec.markDef = { type: 'bar', cornerRadius: 8 };
769
855
  const scales = computeScales(spec, chartArea, spec.data);
770
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
856
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
771
857
 
772
858
  for (const mark of marks) {
773
859
  expect(mark.cornerRadius).toBe(8);
@@ -779,7 +865,7 @@ describe('markDef overrides', () => {
779
865
  spec.markDef = { type: 'bar', size: 6 };
780
866
  // Enable stacking (default for grouped)
781
867
  const scales = computeScales(spec, chartArea, spec.data);
782
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
868
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
783
869
 
784
870
  const stackedMarks = marks.filter((m) => m.stackGroup !== undefined);
785
871
  for (const mark of stackedMarks) {
@@ -791,7 +877,7 @@ describe('markDef overrides', () => {
791
877
  const spec = makeSimpleBarSpec();
792
878
  spec.markDef = { type: 'bar', size: 10, cornerRadius: 'pill' };
793
879
  const scales = computeScales(spec, chartArea, spec.data);
794
- const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
880
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
795
881
 
796
882
  for (const mark of marks) {
797
883
  expect(mark.height).toBe(10);
@@ -17,7 +17,7 @@ import type {
17
17
  Rect,
18
18
  RectMark,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { isGradientDef } from '@opendata-ai/openchart-core';
20
+ import { isGradientDef, NARROW_VIEWPORT_MAX } from '@opendata-ai/openchart-core';
21
21
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
22
  import type { NormalizedChartSpec } from '../../compiler/types';
23
23
  import type { ResolvedScales } from '../../layout/scales';
@@ -54,6 +54,16 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
54
54
 
55
55
  const MIN_BAR_WIDTH = 1;
56
56
 
57
+ /**
58
+ * Readable floor (px) for a single grouped-bar thickness. Value labels render at
59
+ * fontSize 10 with ~12px line height; below this a bar is thinner than its own
60
+ * label and the chart reads as stripes (observed on iOS at 375-430px). When
61
+ * grouped sub-bars would fall under this, we reclaim the band scale's reserved
62
+ * inter-category whitespace (step - bandwidth) before letting bars shrink,
63
+ * trading padding for legibility rather than inventing extra height.
64
+ */
65
+ const MIN_GROUPED_BAR_THICKNESS = 8;
66
+
57
67
  // ---------------------------------------------------------------------------
58
68
  // Public API
59
69
  // ---------------------------------------------------------------------------
@@ -70,6 +80,7 @@ export function computeBarMarks(
70
80
  scales: ResolvedScales,
71
81
  _chartArea: Rect,
72
82
  _strategy: LayoutStrategy,
83
+ containerWidth: number,
73
84
  ): RectMark[] {
74
85
  const encoding = spec.encoding as Encoding;
75
86
  const xChannel = encoding.x;
@@ -144,6 +155,7 @@ export function computeBarMarks(
144
155
  bandwidth,
145
156
  baseline,
146
157
  scales,
158
+ containerWidth,
147
159
  );
148
160
  } else {
149
161
  const stackMode =
@@ -267,6 +279,7 @@ function computeGroupedBars(
267
279
  bandwidth: number,
268
280
  baseline: number,
269
281
  scales: ResolvedScales,
282
+ containerWidth: number,
270
283
  ): RectMark[] {
271
284
  const marks: RectMark[] = [];
272
285
  const categoryGroups = groupByField(data, categoryField);
@@ -282,9 +295,36 @@ function computeGroupedBars(
282
295
  const groupCount = groupIndexMap.size;
283
296
  if (groupCount === 0) return marks;
284
297
 
285
- // Subdivide the band height by group count with a small gap
298
+ // Subdivide the band height by group count with a small gap.
286
299
  const gap = Math.min(1, bandwidth * 0.05);
287
- const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
300
+ let groupHeight = bandwidth;
301
+ let subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
302
+
303
+ // At tight bandwidths sub-bars fall below the readable floor and read as
304
+ // stripes. The band scale reserves whitespace between categories (step -
305
+ // bandwidth); reclaim it by growing the group height toward the full step
306
+ // before letting bars shrink. Bars stay centered in the band so the widened
307
+ // group doesn't touch its neighbors. A one-pixel residual keeps adjacent
308
+ // categories visually separable even at full reclaim.
309
+ //
310
+ // Gated to narrow containers (< NARROW_VIEWPORT_MAX): the fix targets phones
311
+ // where this was observed. Gating on the container width (not the derived plot
312
+ // width) keeps desktop embeds pixel-identical even when a wide container's
313
+ // left gutter shrinks the plot below 500px.
314
+ const step = typeof yScale.step === 'function' ? yScale.step() : bandwidth;
315
+ const maxGroupHeight = Math.max(bandwidth, step - MIN_BAR_WIDTH);
316
+ if (
317
+ containerWidth < NARROW_VIEWPORT_MAX &&
318
+ subBandHeight < MIN_GROUPED_BAR_THICKNESS &&
319
+ maxGroupHeight > bandwidth
320
+ ) {
321
+ const needed = MIN_GROUPED_BAR_THICKNESS * groupCount + gap * (groupCount - 1);
322
+ groupHeight = Math.min(Math.max(groupHeight, needed), maxGroupHeight);
323
+ subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
324
+ }
325
+ subBandHeight = Math.max(subBandHeight, MIN_BAR_WIDTH);
326
+ // Center the (possibly widened) group within the band.
327
+ const groupOffset = (bandwidth - groupHeight) / 2;
288
328
 
289
329
  for (const [category, rows] of categoryGroups) {
290
330
  const bandY = yScale(category);
@@ -299,7 +339,7 @@ function computeGroupedBars(
299
339
  const color = getColor(scales, groupKey);
300
340
  const xPos = value >= 0 ? baseline : xScale(value);
301
341
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
302
- const subY = bandY + groupIndex * (subBandHeight + gap);
342
+ const subY = bandY + groupOffset + groupIndex * (subBandHeight + gap);
303
343
 
304
344
  const aria: MarkAria = {
305
345
  label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
@@ -13,8 +13,15 @@ import { computeBarLabels } from './labels';
13
13
  // Bar chart renderer
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
17
- const marks = computeBarMarks(spec, scales, chartArea, strategy);
16
+ export const barRenderer: ChartRenderer = (
17
+ spec,
18
+ scales,
19
+ chartArea,
20
+ strategy,
21
+ theme,
22
+ containerWidth,
23
+ ) => {
24
+ const marks = computeBarMarks(spec, scales, chartArea, strategy, containerWidth);
18
25
 
19
26
  // Compute and attach value labels (respects spec.labels.density)
20
27
  const valueField =