@opendata-ai/openchart-engine 7.2.4 → 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.
Files changed (45) hide show
  1. package/dist/index.d.ts +88 -39
  2. package/dist/index.js +4134 -2930
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
  6. package/src/__tests__/axes.test.ts +129 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +70 -2
  10. package/src/__tests__/helpers/invariants.ts +265 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +358 -0
  13. package/src/__tests__/layout-invariants.test.ts +535 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +131 -45
  26. package/src/charts/bar/compute.ts +44 -4
  27. package/src/charts/bar/index.ts +9 -2
  28. package/src/charts/bar/labels.ts +77 -26
  29. package/src/charts/column/labels.ts +29 -16
  30. package/src/charts/post-process.ts +21 -7
  31. package/src/charts/registry.ts +4 -2
  32. package/src/compile/layer.ts +31 -9
  33. package/src/compile.ts +88 -149
  34. package/src/index.ts +9 -0
  35. package/src/layout/axes/rotation.ts +36 -0
  36. package/src/layout/axes.ts +130 -8
  37. package/src/layout/dimensions.ts +80 -98
  38. package/src/layout/plan.ts +550 -0
  39. package/src/layout/scales.ts +22 -0
  40. package/src/layout/shared.ts +44 -0
  41. package/src/legend/compute.ts +315 -78
  42. package/src/legend/wrap.ts +11 -7
  43. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  44. package/src/tables/__tests__/heatmap.test.ts +24 -20
  45. package/src/tables/heatmap.ts +25 -9
@@ -3,13 +3,12 @@
3
3
  * optional callout connector to the data point.
4
4
  */
5
5
 
6
- import {
7
- estimateTextWidth,
8
- type Rect,
9
- type ResolvedAnnotation,
10
- type ResolvedLabel,
11
- type TextAnnotation,
12
- type TextStyle,
6
+ import type {
7
+ Rect,
8
+ ResolvedAnnotation,
9
+ ResolvedLabel,
10
+ TextAnnotation,
11
+ TextStyle,
13
12
  } from '@opendata-ai/openchart-core';
14
13
  import type { ResolvedScales } from '../layout/scales';
15
14
  import {
@@ -27,7 +26,15 @@ import {
27
26
  SUBTITLE_FONT_SIZE_RATIO,
28
27
  SUBTITLE_GAP,
29
28
  } from './constants';
30
- import { applyOffset, computeAnchorOffset, computeConnectorOrigin } from './geometry';
29
+ import {
30
+ type AnnotationMeasureTextFn,
31
+ applyOffset,
32
+ computeAnchorOffset,
33
+ computeConnectorOrigin,
34
+ computeTextBlockBounds,
35
+ heuristicMeasure,
36
+ unionRects,
37
+ } from './geometry';
31
38
  import { resolvePosition } from './position';
32
39
 
33
40
  /** Horizontal gap between the drop-line and the label text. */
@@ -59,6 +66,7 @@ export function resolveTextAnnotation(
59
66
  scales: ResolvedScales,
60
67
  chartArea: Rect,
61
68
  isDark: boolean,
69
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
62
70
  ): ResolvedAnnotation | null {
63
71
  const px = resolvePosition(annotation.x, scales.x);
64
72
  const py = resolvePosition(annotation.y, scales.y);
@@ -78,7 +86,23 @@ export function resolveTextAnnotation(
78
86
  // label sitting flush beside it. Auto-flips to the opposite side if the
79
87
  // chosen side would overflow the chart area.
80
88
  if (annotation.connector === 'drop-line') {
81
- return resolveDropLineAnnotation(annotation, px, py, chartArea, labelStyle, defaultTextFill);
89
+ return resolveDropLineAnnotation(
90
+ annotation,
91
+ px,
92
+ py,
93
+ chartArea,
94
+ labelStyle,
95
+ defaultTextFill,
96
+ measure,
97
+ );
98
+ }
99
+
100
+ // Multi-line non-drop-line: engine sets textAnchor to 'middle' so bounds
101
+ // and rendering derive from the same anchor (previously the renderer
102
+ // forced this override, causing bounds to lie).
103
+ const isMultiLine = annotation.text.includes('\n');
104
+ if (isMultiLine) {
105
+ labelStyle.textAnchor = 'middle';
82
106
  }
83
107
 
84
108
  // Compute position from anchor direction + user offset
@@ -130,6 +154,8 @@ export function resolveTextAnnotation(
130
154
  ? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
131
155
  : adjustedToRaw;
132
156
 
157
+ const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
158
+
133
159
  const label: ResolvedLabel = {
134
160
  text: annotation.text,
135
161
  x: labelX,
@@ -147,6 +173,7 @@ export function resolveTextAnnotation(
147
173
  : undefined,
148
174
  background: annotation.background,
149
175
  halo: annotation.halo,
176
+ bounds: labelBounds,
150
177
  };
151
178
 
152
179
  // Resolve dot marker. Uses the connector's "to" endpoint coordinates
@@ -170,6 +197,7 @@ export function resolveTextAnnotation(
170
197
  // Resolve subtitle. Positioned below the primary text block by
171
198
  // (lineHeight * primaryLineCount * fontSize) + gap.
172
199
  let subtitle: ResolvedAnnotation['subtitle'] | undefined;
200
+ let annotationBounds: Rect = labelBounds;
173
201
  if (annotation.subtitle) {
174
202
  const primaryLineCount = annotation.text.split('\n').length;
175
203
  const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
@@ -187,6 +215,14 @@ export function resolveTextAnnotation(
187
215
  y: subtitleY,
188
216
  style: subtitleStyle,
189
217
  };
218
+ const subtitleBounds = computeTextBlockBounds(
219
+ labelX,
220
+ subtitleY,
221
+ annotation.subtitle,
222
+ subtitleStyle,
223
+ measure,
224
+ );
225
+ annotationBounds = unionRects(labelBounds, subtitleBounds);
190
226
  }
191
227
 
192
228
  return {
@@ -199,6 +235,7 @@ export function resolveTextAnnotation(
199
235
  zIndex: annotation.zIndex,
200
236
  dot,
201
237
  subtitle,
238
+ bounds: annotationBounds,
202
239
  };
203
240
  }
204
241
 
@@ -214,13 +251,14 @@ function resolveDropLineAnnotation(
214
251
  chartArea: Rect,
215
252
  labelStyle: TextStyle,
216
253
  defaultTextFill: string,
254
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
217
255
  ): ResolvedAnnotation {
218
256
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
219
257
  const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
220
258
  const lines = annotation.text.split('\n');
221
259
  const estimatedWidth = Math.max(
222
260
  0,
223
- ...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)),
261
+ ...lines.map((line) => measure(line, { fontSize, fontWeight })),
224
262
  );
225
263
 
226
264
  // Pick initial side from anchor; default to 'left' (label sits to the left
@@ -261,6 +299,14 @@ function resolveDropLineAnnotation(
261
299
  const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
262
300
  const to = { x: px, y: py - DROP_LINE_BOTTOM_GAP };
263
301
 
302
+ const labelBounds = computeTextBlockBounds(
303
+ labelX,
304
+ labelBaselineY,
305
+ annotation.text,
306
+ resolvedStyle,
307
+ measure,
308
+ );
309
+
264
310
  const label: ResolvedLabel = {
265
311
  text: annotation.text,
266
312
  x: labelX,
@@ -276,6 +322,7 @@ function resolveDropLineAnnotation(
276
322
  },
277
323
  background: annotation.background,
278
324
  halo: annotation.halo,
325
+ bounds: labelBounds,
279
326
  };
280
327
 
281
328
  return {
@@ -286,5 +333,6 @@ function resolveDropLineAnnotation(
286
333
  fill: annotation.fill,
287
334
  opacity: annotation.opacity,
288
335
  zIndex: annotation.zIndex,
336
+ bounds: labelBounds,
289
337
  };
290
338
  }
@@ -16,8 +16,8 @@ describe('computeMarkObstacles', () => {
16
16
  const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
17
17
  const result = computeMarkObstacles(marks, scales);
18
18
  expect(result).toHaveLength(2);
19
- expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30 });
20
- expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30 });
19
+ expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30, kind: 'mark' });
20
+ expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30, kind: 'mark' });
21
21
  });
22
22
 
23
23
  it('returns point mark bounds as bounding box from cx/cy/r', () => {
@@ -25,7 +25,7 @@ describe('computeMarkObstacles', () => {
25
25
  const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
26
26
  const result = computeMarkObstacles(marks, scales);
27
27
  expect(result).toHaveLength(1);
28
- expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20 });
28
+ expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20, kind: 'mark' });
29
29
  });
30
30
 
31
31
  it('returns grouped row obstacles for band-scale charts', () => {
@@ -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);