@opendata-ai/openchart-engine 7.3.0 → 7.4.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.
- package/dist/index.d.ts +4 -3
- package/dist/index.js +145 -80
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +4 -15
- package/src/__tests__/axes.test.ts +62 -0
- package/src/__tests__/dimensions.test.ts +63 -0
- package/src/__tests__/helpers/invariants.ts +4 -6
- package/src/__tests__/layout-fuzz.test.ts +24 -8
- package/src/__tests__/layout-invariants.test.ts +4 -2
- package/src/charts/bar/__tests__/compute.test.ts +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/registry.ts +4 -2
- package/src/compile.ts +3 -1
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +65 -35
- package/src/layout/plan.ts +28 -2
- package/src/layout/scales.ts +22 -0
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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)}`,
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -13,8 +13,15 @@ import { computeBarLabels } from './labels';
|
|
|
13
13
|
// Bar chart renderer
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
export const barRenderer: ChartRenderer = (
|
|
17
|
-
|
|
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 =
|