@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.
@@ -5,8 +5,10 @@
5
5
  * if the bar is wide enough, or outside (to the right) otherwise.
6
6
  *
7
7
  * Respects the spec's label density setting:
8
- * - 'all': show every label, skip collision detection
9
- * - 'auto': existing behavior (collision detection)
8
+ * - 'all': consider every bar's label (no density pre-filter), but still run
9
+ * collision resolution so colliding losers are hidden rather than drawn on
10
+ * top of each other. Composes with the stacked-segment pre-hide pass below.
11
+ * - 'auto': existing behavior (density pre-filter + collision detection)
10
12
  * - 'endpoints': first and last bars only
11
13
  * - 'none': return empty array
12
14
  */
@@ -106,6 +108,10 @@ export function computeBarLabels(
106
108
  // Track whether each candidate fits within its stacked segment.
107
109
  // Non-stacked bars are always considered fitting (undefined = fits).
108
110
  const fitsInSegment: boolean[] = [];
111
+ // The display anchor x (start/end/middle reference point) for each candidate.
112
+ // Candidates carry the box's LEFT edge for collision math; on emit we map any
113
+ // horizontal nudge back onto this display anchor so textAnchor stays honored.
114
+ const displayAnchorX: number[] = [];
109
115
 
110
116
  const formatter = buildD3Formatter(labelFormat);
111
117
 
@@ -183,17 +189,32 @@ export function computeBarLabels(
183
189
  }
184
190
  }
185
191
 
186
- // anchorY = bar vertical center. With dominant-baseline: central,
187
- // SVG places the text center at this y coordinate.
188
- const anchorY = mark.y + mark.height / 2;
192
+ // Collision math works in top-left-edge space so the AABB
193
+ // (rect = { x: anchorX, y: anchorY, width, height }) matches the label's
194
+ // real footprint. The renderer anchors text by textAnchor (start/end/
195
+ // middle) and centers it vertically (dominant-baseline: central), so we
196
+ // convert the display anchor to the box's top-left corner here and shift
197
+ // back on emit. Feeding the display anchor directly desynced the collision
198
+ // box from the drawn text (by width for end/middle, by textHeight/2
199
+ // vertically), letting adjacent bar labels pass the overlap check while
200
+ // visibly colliding.
201
+ const centerY = mark.y + mark.height / 2;
202
+ const anchorY = centerY - textHeight / 2;
203
+ const leftEdgeX =
204
+ textAnchor === 'end'
205
+ ? anchorX - textWidth
206
+ : textAnchor === 'middle'
207
+ ? anchorX - textWidth / 2
208
+ : anchorX;
189
209
 
190
210
  // Check if label text fits within the stacked segment
191
211
  const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
192
212
 
213
+ displayAnchorX.push(anchorX);
193
214
  fitsInSegment.push(fits);
194
215
  candidates.push({
195
216
  text: valuePart,
196
- anchorX,
217
+ anchorX: leftEdgeX,
197
218
  anchorY,
198
219
  width: textWidth,
199
220
  height: textHeight,
@@ -212,46 +233,76 @@ export function computeBarLabels(
212
233
 
213
234
  if (candidates.length === 0) return [];
214
235
 
215
- // 'all': skip collision detection, mark everything visible
216
- // (but hide labels that don't fit in their stacked segment)
217
- if (density === 'all') {
218
- return candidates.map((c, i) => ({
219
- text: c.text,
220
- x: c.anchorX,
221
- y: c.anchorY,
222
- style: c.style,
223
- visible: fitsInSegment[i] !== false,
224
- }));
225
- }
226
-
227
- // For 'auto' and 'endpoints': pre-mark candidates that don't fit their
228
- // stacked segment as hidden before running collision detection.
236
+ // Every density that reaches here ('all' and 'auto') pre-hides candidates
237
+ // that don't fit their stacked segment, then runs collision resolution.
238
+ // 'all' just skips the density pre-filter above so every mark is a
239
+ // candidate; colliding losers are still hidden by resolveCollisions.
229
240
  const fittingCandidates: LabelCandidate[] = [];
230
241
  const unfittingIndices = new Set<number>();
231
242
  for (let i = 0; i < candidates.length; i++) {
232
243
  if (fitsInSegment[i] === false) {
233
244
  unfittingIndices.add(i);
234
245
  } else {
235
- fittingCandidates.push(candidates[i]);
246
+ // Carry the original candidate index so we can zip the resolved output
247
+ // back to it regardless of any reordering resolveCollisions performs.
248
+ fittingCandidates.push({ ...candidates[i], index: i });
236
249
  }
237
250
  }
238
251
 
239
- const resolved = resolveCollisions(fittingCandidates);
252
+ // resolveCollisions may reorder its output (it sorts by priority), so key the
253
+ // result by the source index we carried rather than by position.
254
+ const resolvedByOrig = new Map<number, ResolvedLabel>();
255
+ for (const r of resolveCollisions(fittingCandidates)) {
256
+ if (r.index !== undefined) resolvedByOrig.set(r.index, r);
257
+ }
258
+
259
+ // Shift resolved y back from top-edge space to the vertical center that the
260
+ // central-baseline renderer expects (see anchorY derivation above).
261
+ const halfHeight = FONT_SIZE * 1.2 * 0.5;
240
262
 
241
263
  // Re-insert hidden labels for candidates that didn't fit, preserving order
242
264
  const results: ResolvedLabel[] = [];
243
- let resolvedIdx = 0;
244
265
  for (let i = 0; i < candidates.length; i++) {
245
266
  if (unfittingIndices.has(i)) {
246
267
  results.push({
247
268
  text: candidates[i].text,
248
- x: candidates[i].anchorX,
249
- y: candidates[i].anchorY,
269
+ x: displayAnchorX[i],
270
+ y: candidates[i].anchorY + halfHeight,
250
271
  style: candidates[i].style,
251
272
  visible: false,
252
273
  });
253
274
  } else {
254
- results.push(resolved[resolvedIdx++]);
275
+ const r = resolvedByOrig.get(i);
276
+ if (!r) continue;
277
+ // Collision math ran in left-edge/top space; renderers draw at the display
278
+ // anchor with central baseline. The inverse mapping shifts every point by
279
+ // dx (left-edge -> display anchor) on x and +halfHeight on y. Apply it to
280
+ // the label AND both connector endpoints, or the leader line for an
281
+ // end/middle-anchored label points to the box's top-left corner (up to
282
+ // textWidth + halfHeight away from the text it's calling out).
283
+ const dx = displayAnchorX[i] - candidates[i].anchorX;
284
+ // Drop the internal zip index so it doesn't leak into the label output.
285
+ const { index: _index, ...rest } = r;
286
+ results.push({
287
+ ...rest,
288
+ x: r.x + dx,
289
+ y: r.y + halfHeight,
290
+ connector: r.connector
291
+ ? {
292
+ ...r.connector,
293
+ from: {
294
+ ...r.connector.from,
295
+ x: r.connector.from.x + dx,
296
+ y: r.connector.from.y + halfHeight,
297
+ },
298
+ to: {
299
+ ...r.connector.to,
300
+ x: r.connector.to.x + dx,
301
+ y: r.connector.to.y + halfHeight,
302
+ },
303
+ }
304
+ : undefined,
305
+ });
255
306
  }
256
307
  }
257
308
 
@@ -5,8 +5,10 @@
5
5
  * or below (for negative values).
6
6
  *
7
7
  * Respects the spec's label density setting:
8
- * - 'all': show every label, skip collision detection
9
- * - 'auto': existing behavior (collision detection)
8
+ * - 'all': consider every column's label (no density pre-filter), but still
9
+ * run collision resolution so colliding losers are hidden rather than drawn
10
+ * on top of each other
11
+ * - 'auto': existing behavior (density pre-filter + collision detection)
10
12
  * - 'endpoints': first and last columns only
11
13
  * - 'none': return empty array
12
14
  */
@@ -22,6 +24,7 @@ import {
22
24
  estimateTextWidth,
23
25
  getRepresentativeColor,
24
26
  resolveCollisions,
27
+ textAscent,
25
28
  } from '@opendata-ai/openchart-core';
26
29
  import { filterByDensity } from '../_shared/density-filter';
27
30
  import { formatLabelValue } from '../_shared/format-label-value';
@@ -97,7 +100,8 @@ export function computeColumnLabels(
97
100
 
98
101
  // anchorY is the TOP of the label bounding box so the collision system's
99
102
  // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
100
- // correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
103
+ // correct. Collision math stays in top-coordinate space; the ascent shift
104
+ // to the alphabetic baseline is applied only at emission (see below).
101
105
  // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
102
106
  // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
103
107
  const anchorX = mark.x + mark.width / 2;
@@ -119,23 +123,32 @@ export function computeColumnLabels(
119
123
  fill: labelColor ?? getRepresentativeColor(mark.fill),
120
124
  lineHeight: 1.2,
121
125
  textAnchor: 'middle',
122
- dominantBaseline: 'hanging',
123
126
  },
124
127
  });
125
128
  }
126
129
 
127
130
  if (candidates.length === 0) return [];
128
131
 
129
- // 'all': skip collision detection, mark everything visible
130
- if (density === 'all') {
131
- return candidates.map((c) => ({
132
- text: c.text,
133
- x: c.anchorX,
134
- y: c.anchorY,
135
- style: c.style,
136
- visible: true,
137
- }));
138
- }
139
-
140
- return resolveCollisions(candidates);
132
+ // Collision resolution keeps y in top-edge coordinates; renderers position
133
+ // text on the alphabetic baseline, so shift every emitted y down by the
134
+ // ascent instead of relying on dominant-baseline:hanging (WebKit computes
135
+ // hanging from different font metrics than Blink, clipping labels on iOS).
136
+ const ascent = textAscent(FONT_SIZE);
137
+
138
+ // Every density that reaches here ('all' and 'auto') runs collision
139
+ // resolution. 'all' just skips the density pre-filter above so every mark is
140
+ // a candidate; colliding losers are still hidden by resolveCollisions rather
141
+ // than stacked on top of each other.
142
+ return resolveCollisions(candidates).map((label) => ({
143
+ ...label,
144
+ y: label.y + ascent,
145
+ // The connector originates at the label; shift its start to the baseline
146
+ // too so the leader stays glued to the shifted text.
147
+ connector: label.connector
148
+ ? {
149
+ ...label.connector,
150
+ from: { ...label.connector.from, y: label.connector.from.y + ascent },
151
+ }
152
+ : undefined,
153
+ }));
141
154
  }
@@ -19,8 +19,9 @@ import type { ResolvedScales } from '../layout/scales';
19
19
  * A chart renderer function.
20
20
  *
21
21
  * Takes a normalized spec, resolved scales, chart area, layout strategy,
22
- * and the resolved theme for theme-aware styling (e.g. label colors).
23
- * Returns an array of marks to render.
22
+ * the resolved theme for theme-aware styling (e.g. label colors), and the
23
+ * full container width (used by geometry gates that must key off the
24
+ * container, not the derived plot width). Returns an array of marks to render.
24
25
  */
25
26
  export type ChartRenderer = (
26
27
  spec: NormalizedChartSpec,
@@ -28,6 +29,7 @@ export type ChartRenderer = (
28
29
  chartArea: Rect,
29
30
  strategy: LayoutStrategy,
30
31
  theme: ResolvedTheme,
32
+ containerWidth: number,
31
33
  ) => Mark[];
32
34
 
33
35
  // ---------------------------------------------------------------------------
package/src/compile.ts CHANGED
@@ -683,7 +683,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
683
683
  renderSpec.markDef,
684
684
  );
685
685
  const renderer = getChartRenderer(rendererKey);
686
- const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
686
+ const marks: Mark[] = renderer
687
+ ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
688
+ : [];
687
689
 
688
690
  // Compute the right-side endpoint labels column for multi-series line/area
689
691
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Auto-rotation decision for band-scale x-axis tick labels.
3
+ *
4
+ * Vertical band x-axes rotate their tick labels to -45° when horizontal
5
+ * labels are wider than the available band. The layout planner and
6
+ * computeAxes must agree on this decision: the planner reserves the bottom
7
+ * margin for the rotated label footprint, and computeAxes emits the same
8
+ * angle. Sharing this rule keeps the reserved space matched to the rendered
9
+ * labels (a mismatch spilled rotated ticks into the source line).
10
+ */
11
+
12
+ /** Band fraction above which horizontal labels are considered too wide to fit. */
13
+ const BAND_FIT_FRACTION = 0.85;
14
+
15
+ /** Angle used when band labels don't fit horizontally. */
16
+ const AUTO_ROTATE_ANGLE = -45;
17
+
18
+ /**
19
+ * Decide the effective x tick angle for a band scale.
20
+ *
21
+ * @param explicitAngle - User-specified labelAngle (wins if defined).
22
+ * @param maxLabelWidth - Widest tick label width in pixels.
23
+ * @param bandwidth - Band width in pixels.
24
+ * @param labelCount - Number of category labels (rotation needs >= 2).
25
+ * @returns The explicit angle, `-45` if auto-rotation triggers, or `undefined`.
26
+ */
27
+ export function resolveBandTickAngle(
28
+ explicitAngle: number | undefined,
29
+ maxLabelWidth: number,
30
+ bandwidth: number,
31
+ labelCount: number,
32
+ ): number | undefined {
33
+ if (explicitAngle !== undefined) return explicitAngle;
34
+ if (labelCount < 2) return undefined;
35
+ return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : undefined;
36
+ }
@@ -27,6 +27,7 @@ import {
27
27
  X_AXIS_TITLE_BAND_ROTATED,
28
28
  } from '@opendata-ai/openchart-core';
29
29
  import type { ScaleBand } from 'd3-scale';
30
+ import { resolveBandTickAngle } from './axes/rotation';
30
31
  import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
31
32
  import {
32
33
  buildContinuousTicks,
@@ -130,6 +131,40 @@ const CONTINUOUS_TICK_FLOOR = 4;
130
131
  */
131
132
  const OVERSHOOT_TOLERANCE = 1.5;
132
133
 
134
+ /**
135
+ * Quantitative axes must always render at least this many tick labels. A lone
136
+ * tick reads as a broken axis (a real iPhone blog chart degenerated to a
137
+ * single "80" when D3's `scale.ticks(2)` collapsed a tight domain like
138
+ * [73, 88] to one nice value). This floor holds at every width.
139
+ */
140
+ const MIN_QUANTITATIVE_TICKS = 2;
141
+
142
+ /**
143
+ * Ensure a continuous scale yields at least MIN_QUANTITATIVE_TICKS ticks.
144
+ *
145
+ * D3's `scale.ticks(n)` picks nice step sizes, so a tight domain can collapse
146
+ * to a single value at low counts (`[73, 88].ticks(2)` → `[80]`). When that
147
+ * happens, request progressively higher counts until at least two ticks
148
+ * appear. Prefers the smallest count that clears the floor, which keeps the
149
+ * ticks spread toward the domain endpoints rather than clustered.
150
+ */
151
+ function ensureMinContinuousTicks(
152
+ scale: ResolvedScales['x' | 'y'],
153
+ ticks: AxisTick[],
154
+ requestedCount: number,
155
+ ): AxisTick[] {
156
+ if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
157
+ if (!scale || !scaleSupportsTickCount(scale)) return ticks;
158
+
159
+ let best = ticks;
160
+ for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
161
+ const candidate = buildContinuousTicks(scale, n);
162
+ if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
163
+ if (candidate.length > best.length) best = candidate;
164
+ }
165
+ return best;
166
+ }
167
+
133
168
  /**
134
169
  * Fit continuous ticks by re-requesting progressively fewer ticks from the
135
170
  * scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
@@ -159,10 +194,15 @@ function fitContinuousTicks(
159
194
  ): AxisTick[] {
160
195
  if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
161
196
 
197
+ // Floor the initial set: a tight domain can make D3 return a single nice
198
+ // value, which would otherwise pass straight through (no overlap, no
199
+ // overshoot) and render as a lone tick.
200
+ const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
201
+
162
202
  const tolerance = initialCount * OVERSHOOT_TOLERANCE;
163
- const overshoots = initialTicks.length > tolerance;
164
- const overlaps = ticksOverlap(initialTicks, fontSize, fontWeight, measureText, orientation);
165
- if (!overshoots && !overlaps) return initialTicks;
203
+ const overshoots = flooredTicks.length > tolerance;
204
+ const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
205
+ if (!overshoots && !overlaps) return flooredTicks;
166
206
 
167
207
  // Enforce the floor only when the axis is long enough to fit that many
168
208
  // labels without overlap. Very short axes can fall below.
@@ -193,7 +233,8 @@ function fitContinuousTicks(
193
233
  // No candidate fit cleanly. Thin whatever most recently met the floor; if
194
234
  // nothing did, synthesize a floor-count set directly from the scale so we
195
235
  // never hand the overshot initialTicks to the middle-pruning thinner.
196
- const fallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
236
+ const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
237
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
197
238
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
198
239
  }
199
240
 
@@ -385,9 +426,7 @@ export function computeAxes(
385
426
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
386
427
  if (w > maxLabelWidth) maxLabelWidth = w;
387
428
  }
388
- if (maxLabelWidth > bandwidth * 0.85) {
389
- tickAngle = -45;
390
- }
429
+ tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
391
430
  }
392
431
 
393
432
  // Thin tick labels to prevent overlap (skip for explicit tick values).
@@ -554,38 +593,29 @@ export function computeAxes(
554
593
  const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
555
594
 
556
595
  if (result.x) {
557
- let rotatedHeight: number | undefined;
558
- if (result.x.tickAngle && Math.abs(result.x.tickAngle) > 10) {
559
- const angleRad = Math.abs(result.x.tickAngle) * (Math.PI / 180);
560
- let maxLabelWidth = 40;
561
- for (const tick of result.x.ticks) {
562
- const w = estimateTextWidth(
563
- tick.label,
564
- result.x.tickLabelStyle.fontSize,
565
- result.x.tickLabelStyle.fontWeight,
566
- );
567
- if (w > maxLabelWidth) maxLabelWidth = w;
568
- }
569
- rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
570
- }
571
-
572
- if (rotatedHeight != null) {
573
- result.x.extent = result.x.label ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
574
- } else {
575
- result.x.extent = computeXAxisExtentFromLabels({
576
- labels: result.x.ticks.map((t) => t.label),
577
- tickAngle: result.x.tickAngle,
578
- hasTitle: !!result.x.label,
579
- tickFontSize: result.x.tickLabelStyle.fontSize,
580
- tickFontWeight: result.x.tickLabelStyle.fontWeight,
581
- xAxisHeight: theme.spacing.xAxisHeight,
582
- });
583
- }
596
+ const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
597
+
598
+ // Single source of truth for the x-axis vertical extent (rotated or flat).
599
+ // computeXAxisExtentFromLabels applies the correct rotated-extent math
600
+ // (textWidth*|sin θ| + lineHeight*|cos θ|), so the reserved bottom margin
601
+ // matches what the labels actually occupy.
602
+ result.x.extent = computeXAxisExtentFromLabels({
603
+ labels: result.x.ticks.map((t) => t.label),
604
+ tickAngle: result.x.tickAngle,
605
+ hasTitle: !!result.x.label,
606
+ tickFontSize: result.x.tickLabelStyle.fontSize,
607
+ tickFontWeight: result.x.tickLabelStyle.fontWeight,
608
+ xAxisHeight: theme.spacing.xAxisHeight,
609
+ measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText),
610
+ });
584
611
 
585
612
  if (result.x.label) {
613
+ // Rotated: place the title just below the tick band (extent already
614
+ // includes the rotated label height and the title band). Flat: fixed 35px.
615
+ const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
586
616
  result.x.titlePosition = {
587
617
  x: chartArea.x + chartArea.width / 2,
588
- y: chartArea.y + chartArea.height + (rotatedHeight != null ? rotatedHeight + 14 : 35),
618
+ y: chartArea.y + chartArea.height + rotatedBand,
589
619
  };
590
620
  }
591
621
  }
@@ -32,8 +32,9 @@ import { format as d3Format } from 'd3-format';
32
32
  import type { NormalizedChartSpec } from '../compiler/types';
33
33
  import { computeLegendContent, type LegendContent } from '../legend/compute';
34
34
  import { legendGap } from '../legend/wrap';
35
+ import { resolveBandTickAngle } from './axes/rotation';
35
36
  import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
36
- import { computeScales } from './scales';
37
+ import { computeScales, estimateBandwidth } from './scales';
37
38
  import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
38
39
 
39
40
  // ---------------------------------------------------------------------------
@@ -319,13 +320,38 @@ export function resolveLayoutPlan(
319
320
  bottomLegendReservation,
320
321
  );
321
322
 
323
+ // Effective x tick angle. Band x-axes auto-rotate to -45° when horizontal
324
+ // labels don't fit their band; computeAxes makes the same call later. The
325
+ // planner must know the angle now so it reserves the rotated-label
326
+ // footprint in the bottom margin instead of the (smaller) flat one.
327
+ let effectiveXTickAngle = xTickAngle;
328
+ if (xTickAngle === undefined && xLabels.length > 1) {
329
+ const rightMarginEst = hPad + (isRadial ? hPad : axisMargin);
330
+ const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
331
+ // Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
332
+ // bandwidth = step * (1 - paddingInner). Uses the same override resolution
333
+ // as buildBandScale so paddingInner/paddingOuter are honored here too.
334
+ const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
335
+ let maxXLabelWidth = 0;
336
+ for (const label of xLabels) {
337
+ const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
338
+ if (w > maxXLabelWidth) maxXLabelWidth = w;
339
+ }
340
+ effectiveXTickAngle = resolveBandTickAngle(
341
+ undefined,
342
+ maxXLabelWidth,
343
+ bandwidth,
344
+ xLabels.length,
345
+ );
346
+ }
347
+
322
348
  // X-axis extent
323
349
  const xAxisExtent =
324
350
  isRadial || xAxisSuppressed
325
351
  ? 0
326
352
  : computeXAxisExtentFromLabels({
327
353
  labels: xLabels,
328
- tickAngle: xTickAngle,
354
+ tickAngle: effectiveXTickAngle,
329
355
  hasTitle: hasXAxisTitle,
330
356
  tickFontSize: theme.fonts.sizes.axisTick,
331
357
  tickFontWeight: theme.fonts.weights.normal,
@@ -463,6 +463,28 @@ function evenRange(start: number, end: number, count: number): number[] {
463
463
  return Array.from({ length: count }, (_, i) => start + step * i);
464
464
  }
465
465
 
466
+ /**
467
+ * Estimate d3 scaleBand bandwidth for a given plot width and category count,
468
+ * mirroring buildBandScale's padding resolution. d3 sets both paddingInner and
469
+ * paddingOuter to `padding`, then paddingInner/paddingOuter overrides adjust
470
+ * each independently. step = width / (n - paddingInner + 2*paddingOuter);
471
+ * bandwidth = step * (1 - paddingInner).
472
+ */
473
+ export function estimateBandwidth(
474
+ scaleConfig: EncodingChannel['scale'] | undefined,
475
+ plotWidth: number,
476
+ n: number,
477
+ ): number {
478
+ if (n <= 0) return 0;
479
+ const padding = scaleConfig?.padding ?? 0.35;
480
+ const paddingInner = scaleConfig?.paddingInner ?? padding;
481
+ const paddingOuter = scaleConfig?.paddingOuter ?? padding;
482
+ const denom = n - paddingInner + 2 * paddingOuter;
483
+ if (denom <= 0) return 0;
484
+ const step = plotWidth / denom;
485
+ return step * (1 - paddingInner);
486
+ }
487
+
466
488
  function buildBandScale(
467
489
  channel: EncodingChannel,
468
490
  data: DataRow[],
@@ -144,7 +144,15 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
144
144
  // Sequential (quantitative) color doesn't produce discrete legend entries
145
145
  if (colorEnc.type === 'quantitative') return [];
146
146
 
147
- const dataValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
147
+ // Rows missing the color field (e.g. a sibling layer in a layered spec whose
148
+ // marks don't participate in the color encoding) must not manufacture a legend
149
+ // category. Without this guard, `String(undefined)` seeds a phantom "undefined"
150
+ // entry and — with an explicit domain — appends it past the authored entries.
151
+ const dataValues = [
152
+ ...new Set(
153
+ spec.data.filter((d) => d[colorEnc.field] != null).map((d) => String(d[colorEnc.field])),
154
+ ),
155
+ ];
148
156
  const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
149
157
  const explicitRange = colorEnc.scale?.range as string[] | undefined;
150
158
  const palette = explicitRange ?? theme.colors.categorical;