@opendata-ai/openchart-engine 7.9.2 → 7.10.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.
@@ -123,16 +123,17 @@ const NUMERIC_SCALE_TYPES = new Set([
123
123
  const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
124
124
 
125
125
  /** Format a tick value based on the scale type. */
126
- function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
126
+ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact = false): string {
127
127
  const axisConfig = resolvedScale.channel.axis || undefined;
128
128
  const formatStr = axisConfig?.format;
129
129
  const suffix = axisConfig?.labelSuffix ?? '';
130
130
 
131
131
  if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
132
+ // Explicit user axis.format always wins, compact or not.
132
133
  const temporalFmt = buildTemporalFormatter(formatStr);
133
134
  if (temporalFmt) return temporalFmt(value as Date) + suffix;
134
135
  const useUtc = resolvedScale.type === 'utc';
135
- return formatDate(value as Date, undefined, undefined, useUtc) + suffix;
136
+ return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
136
137
  }
137
138
 
138
139
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
@@ -188,8 +189,15 @@ export function continuousTicks(
188
189
  * requesting a smaller `n` never produces squished neighbors — unlike
189
190
  * "keep first+last, drop middle" pruning which can stack the last tick
190
191
  * next to an endpoint and cascade to 2 ticks.
192
+ *
193
+ * `compact` switches temporal labels to the compact format table (bare '%b'
194
+ * months etc.); numeric and string labels ignore it.
191
195
  */
192
- export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number): AxisTick[] {
196
+ export function buildContinuousTicks(
197
+ resolvedScale: ResolvedScale,
198
+ count: number,
199
+ compact = false,
200
+ ): AxisTick[] {
193
201
  const scale = resolvedScale.scale as D3ContinuousScale;
194
202
  if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
195
203
  return continuousTicks(resolvedScale, 'full');
@@ -220,7 +228,7 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
220
228
  return ticks.map((value: unknown) => ({
221
229
  value,
222
230
  position: scale(value as number & Date) as number,
223
- label: formatTickLabel(value, resolvedScale),
231
+ label: formatTickLabel(value, resolvedScale, compact),
224
232
  }));
225
233
  }
226
234
 
@@ -256,8 +264,8 @@ export function categoricalTicks(
256
264
  if (resolvedScale.type === 'band' && orientation === 'horizontal') {
257
265
  // Horizontal band scales delegate thinning to the caller (computeAxes)
258
266
  // which knows the effective label angle after auto-rotation. Only apply
259
- // an explicit tickCount cap here; density-based thinning happens
260
- // downstream in thinBandTicksIfNeeded where rotation is accounted for.
267
+ // an explicit tickCount cap here; the angle ladder + stride safety net
268
+ // (see ./rotation) handle collisions downstream.
261
269
  if (explicitTickCount && domain.length > explicitTickCount) {
262
270
  const step = Math.ceil(domain.length / explicitTickCount);
263
271
  selectedValues = domain.filter((_: string, i: number) => i % step === 0);
@@ -315,7 +323,14 @@ export function categoricalTicks(
315
323
  return ticks;
316
324
  }
317
325
 
318
- /** Resolve explicit tick values from axis config into positioned ticks. */
326
+ /**
327
+ * Resolve explicit tick values from axis config into positioned ticks.
328
+ *
329
+ * Deliberately never compact: this renders the layout planner's precomputed
330
+ * ticks, and planner/renderer gutter agreement holds only because both sides
331
+ * format identically. Making this compact without making the planner compact
332
+ * recreates the reserved-space divergence bug class behind the 7.9.x saga.
333
+ */
319
334
  export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
320
335
  const scale = resolvedScale.scale;
321
336
  return values.map((value) => {
@@ -27,7 +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
+ import { bandLabelStride, resolveBandTickAngle } from './axes/rotation';
31
31
  import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
32
32
  import {
33
33
  buildContinuousTicks,
@@ -208,13 +208,14 @@ function ensureMinContinuousTicks(
208
208
  scale: ResolvedScales['x' | 'y'],
209
209
  ticks: AxisTick[],
210
210
  requestedCount: number,
211
+ compact = false,
211
212
  ): AxisTick[] {
212
213
  if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
213
214
  if (!scale || !scaleSupportsTickCount(scale)) return ticks;
214
215
 
215
216
  let best = ticks;
216
217
  for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
217
- const candidate = buildContinuousTicks(scale, n);
218
+ const candidate = buildContinuousTicks(scale, n, compact);
218
219
  if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
219
220
  if (candidate.length > best.length) best = candidate;
220
221
  }
@@ -260,6 +261,27 @@ function fitContinuousTicks(
260
261
  const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
261
262
  if (!overshoots && !overlaps) return flooredTicks;
262
263
 
264
+ // Compact temporal formats are a rung between "full labels overlap" and
265
+ // "drop a tick": at each candidate count, prefer the full format, then try
266
+ // the compact table at the SAME count before stepping the count down. Every
267
+ // returned set is internally consistent — one format family per layout pass.
268
+ // X-axes only: the planner/renderer y pair must keep formatting identically
269
+ // (see resolveExplicitTicks), so vertical axes never compact — including
270
+ // the fallback path below.
271
+ const useCompact =
272
+ (scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
273
+
274
+ const fitsCleanly = (candidate: AxisTick[]): boolean =>
275
+ candidate.length <= tolerance &&
276
+ !ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
277
+
278
+ // Compact-saves-the-count case: full format overlapped at the initial
279
+ // count, but compact labels may fit without giving up a single tick.
280
+ if (useCompact) {
281
+ const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
282
+ if (fitsCleanly(compactAtInitial)) return compactAtInitial;
283
+ }
284
+
263
285
  // Enforce the floor only when the axis is long enough to fit that many
264
286
  // labels without overlap. Very short axes can fall below.
265
287
  const minThreshold =
@@ -268,123 +290,45 @@ function fitContinuousTicks(
268
290
 
269
291
  // Track the smallest candidate that meets the floor. If no candidate fits
270
292
  // cleanly, we thin this instead of the overshot `initialTicks` so the
271
- // fallback doesn't reintroduce the cascading-to-2-ticks bug.
293
+ // fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
294
+ // runs against the full-format candidate, exactly as before the compact rung.
272
295
  let bestWithinFloor: AxisTick[] | undefined;
296
+ let bestWithinFloorCount: number | undefined;
273
297
  for (let n = initialCount - 1; n >= 2; n--) {
274
298
  const candidate = buildContinuousTicks(scale, n);
275
- const candidateOvershoots = candidate.length > tolerance;
276
- const candidateOverlaps = ticksOverlap(
277
- candidate,
278
- fontSize,
279
- fontWeight,
280
- measureText,
281
- orientation,
282
- );
283
- if (!candidateOvershoots && !candidateOverlaps) {
284
- return candidate;
299
+ if (fitsCleanly(candidate)) return candidate;
300
+ if (useCompact) {
301
+ const compact = buildContinuousTicks(scale, n, true);
302
+ if (fitsCleanly(compact)) return compact;
303
+ }
304
+ if (candidate.length >= floor) {
305
+ bestWithinFloor = candidate;
306
+ bestWithinFloorCount = n;
285
307
  }
286
- if (candidate.length >= floor) bestWithinFloor = candidate;
287
308
  }
288
309
 
289
310
  // No candidate fit cleanly. Thin whatever most recently met the floor; if
290
311
  // nothing did, synthesize a floor-count set directly from the scale so we
291
312
  // never hand the overshot initialTicks to the middle-pruning thinner.
292
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
293
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
313
+ // Compacting axes rebuild the fallback compact: if the axis is so starved
314
+ // that we're thinning below the floor, shorter labels are strictly better.
315
+ const rawFallback = useCompact
316
+ ? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
317
+ : (bestWithinFloor ?? buildContinuousTicks(scale, floor));
318
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
294
319
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
295
320
  }
296
321
 
297
322
  /**
298
- * Horizontal footprint `[left, right]` of a rotated band tick label, in axis
299
- * pixels. Rotated labels are anchored at the tick position (the rotation
300
- * pivot), not centered on it: at a negative angle the renderer right-anchors
301
- * the text so it extends left of the pivot; at a positive angle it left-anchors
302
- * so it extends right. The horizontal projection is `width * cos(angle)`.
303
- *
304
- * Modeling the footprint as centered (the old behavior) over-estimates each
305
- * label's reach by half its width in the wrong direction, which made a single
306
- * wide label (e.g. "2026 (to wk 17)") report overlaps against neighbors that
307
- * actually clear it — triggering needless thinning of the whole axis.
323
+ * Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
324
+ * keep every Nth label counting back from the LAST one, so the most recent
325
+ * category is always labeled. Stride 1 — the overwhelmingly common case once
326
+ * the angle ladder has run — keeps everything.
308
327
  */
309
- function labelSpanAtAngle(
310
- tick: AxisTick,
311
- angleDeg: number,
312
- fontSize: number,
313
- fontWeight: number,
314
- measureText?: MeasureTextFn,
315
- ): { left: number; right: number } {
316
- const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
317
- const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
318
- const projected = measureLabel(tick.label, fontSize, fontWeight, measureText) * cosA;
319
- if (angleDeg === 0) {
320
- // Horizontal labels are centered on the tick.
321
- return { left: tick.position - projected / 2, right: tick.position + projected / 2 };
322
- }
323
- if (angleDeg < 0) {
324
- // Right-anchored: pivot is the right edge, text extends left.
325
- return { left: tick.position - projected, right: tick.position };
326
- }
327
- // Left-anchored: pivot is the left edge, text extends right.
328
- return { left: tick.position, right: tick.position + projected };
329
- }
330
-
331
- /**
332
- * Thin band-scale tick labels only where they actually collide at their
333
- * effective angle, greedily keeping every label that clears the last one
334
- * retained. This preserves narrow labels that have room even when a single
335
- * wide label elsewhere on the axis forces some thinning near it, instead of
336
- * decimating the whole axis every-other. First and last labels are always
337
- * kept. Most grouped bar charts keep every label even at -45°.
338
- */
339
- function thinBandTicksIfNeeded(
340
- ticks: AxisTick[],
341
- angleDeg: number,
342
- fontSize: number,
343
- fontWeight: number,
344
- measureText?: MeasureTextFn,
345
- ): AxisTick[] {
346
- if (ticks.length < 3) return ticks;
347
- const minGap = fontSize * 0.5;
348
-
349
- const spans = ticks.map((t) => labelSpanAtAngle(t, angleDeg, fontSize, fontWeight, measureText));
350
-
351
- // Greedy left-to-right retention: keep a label only if its footprint clears
352
- // the last kept label's right edge by minGap. Always keep the first label.
353
- const keep = new Array<boolean>(ticks.length).fill(false);
354
- keep[0] = true;
355
- let lastRight = spans[0].right;
356
- for (let i = 1; i < ticks.length; i++) {
357
- if (spans[i].left >= lastRight + minGap) {
358
- keep[i] = true;
359
- lastRight = spans[i].right;
360
- }
361
- }
362
-
363
- // Always keep the last label. If keeping it collides with previously kept
364
- // labels, drop each colliding neighbor so the endpoint stays labeled. A very
365
- // wide final label (long category name) projects across several bands at an
366
- // angle, so clear every kept label its footprint overlaps, not just the
367
- // nearest one — otherwise an earlier kept label still overlaps the endpoint.
368
- //
369
- // Use true pixel overlap here (no minGap comfort buffer) so a neighbor that
370
- // merely falls a few pixels short of the comfort gap — but does not actually
371
- // touch the endpoint label — is kept rather than sacrificed. Dropping a
372
- // readable short label to seat a near-miss wide endpoint reads as arbitrary
373
- // (e.g. losing "2025" beside "2026 (to wk 17)" that clears it by 2px).
328
+ function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
329
+ if (stride <= 1 || ticks.length < 3) return ticks;
374
330
  const lastIdx = ticks.length - 1;
375
- if (!keep[lastIdx]) {
376
- keep[lastIdx] = true;
377
- for (let i = lastIdx - 1; i > 0; i--) {
378
- if (!keep[i]) continue;
379
- if (spans[i].right > spans[lastIdx].left) {
380
- keep[i] = false;
381
- continue;
382
- }
383
- break;
384
- }
385
- }
386
-
387
- return ticks.filter((_, i) => keep[i]);
331
+ return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
388
332
  }
389
333
 
390
334
  // ---------------------------------------------------------------------------
@@ -511,17 +455,40 @@ export function computeAxes(
511
455
  major: true,
512
456
  }));
513
457
 
514
- // Determine rotation before thinning so we know the effective label
515
- // footprint. Band scales auto-rotate when horizontal labels don't fit.
458
+ // Band-label policy: choose the shallowest angle at which every label
459
+ // fits (flat → -45° → -90°), then apply the uniform stride safety net —
460
+ // stride 1 (keep everything) except at extreme category counts. See
461
+ // ./axes/rotation for the geometry.
516
462
  let tickAngle = axisConfig?.labelAngle;
517
- if (tickAngle === undefined && scales.x.type === 'band' && allTicks.length > 1) {
518
- const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
463
+ let bandStride = 1;
464
+ if (scales.x.type === 'band' && allTicks.length > 1) {
465
+ const bandScale = scales.x.scale as ScaleBand<string>;
466
+ const bandwidth = bandScale.bandwidth();
467
+ // Anchor spacing between the ticks actually drawn — not the raw band
468
+ // step. categoricalTicks may have applied an explicit tickCount cap, so
469
+ // retained ticks can sit a multiple of the step apart, and explicit
470
+ // axis.values subsets can be non-uniform: collision geometry cares
471
+ // about the closest adjacent pair.
472
+ let anchorSpacing = Number.POSITIVE_INFINITY;
473
+ for (let i = 1; i < allTicks.length; i++) {
474
+ const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
475
+ if (d > 0 && d < anchorSpacing) anchorSpacing = d;
476
+ }
477
+ if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
519
478
  let maxLabelWidth = 0;
520
479
  for (const t of allTicks) {
521
480
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
522
481
  if (w > maxLabelWidth) maxLabelWidth = w;
523
482
  }
524
- tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
483
+ tickAngle = resolveBandTickAngle(
484
+ axisConfig?.labelAngle,
485
+ maxLabelWidth,
486
+ bandwidth,
487
+ allTicks.length,
488
+ anchorSpacing,
489
+ fontSize,
490
+ );
491
+ bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
525
492
  }
526
493
 
527
494
  // Thin tick labels to prevent overlap (skip for explicit tick values).
@@ -530,10 +497,7 @@ export function computeAxes(
530
497
  if (hasExplicitValues) {
531
498
  ticks = allTicks;
532
499
  } else if (scales.x.type === 'band') {
533
- // Band scales: thin only when labels actually overlap at their
534
- // effective angle. After rotation, most charts have room for every label.
535
- const effectiveAngle = tickAngle ?? 0;
536
- ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
500
+ ticks = applyBandLabelStride(allTicks, bandStride);
537
501
  } else if (isContinuousX) {
538
502
  ticks = fitContinuousTicks(
539
503
  scales.x,
@@ -36,7 +36,7 @@ import { legendGap } from '../legend/wrap';
36
36
  import { yTickPositionIsInline } from './axes';
37
37
  import { resolveBandTickAngle } from './axes/rotation';
38
38
  import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
39
- import { computeScales, estimateBandwidth } from './scales';
39
+ import { computeScales, estimateBandStep, estimateBandwidth } from './scales';
40
40
  import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
41
41
 
42
42
  // ---------------------------------------------------------------------------
@@ -342,8 +342,21 @@ export function resolveLayoutPlan(
342
342
  // bandwidth = step * (1 - paddingInner). Uses the same override resolution
343
343
  // as buildBandScale so paddingInner/paddingOuter are honored here too.
344
344
  const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
345
+ const step = estimateBandStep(encoding.x?.scale, plotWidth, xLabels.length);
346
+ // Mirror categoricalTicks' explicit tickCount cap: retained ticks sit a
347
+ // multiple of the band step apart, so the drawn labels and their anchor
348
+ // spacing — not the full category set — drive the angle here, matching
349
+ // what computeAxes measures from actual tick positions.
350
+ const xTickCount = xAxis?.tickCount as number | undefined;
351
+ let drawnLabels = xLabels;
352
+ let anchorSpacing = step;
353
+ if (xTickCount && xLabels.length > xTickCount) {
354
+ const capStride = Math.ceil(xLabels.length / xTickCount);
355
+ drawnLabels = xLabels.filter((_, i) => i % capStride === 0);
356
+ anchorSpacing = step * capStride;
357
+ }
345
358
  let maxXLabelWidth = 0;
346
- for (const label of xLabels) {
359
+ for (const label of drawnLabels) {
347
360
  const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
348
361
  if (w > maxXLabelWidth) maxXLabelWidth = w;
349
362
  }
@@ -351,7 +364,9 @@ export function resolveLayoutPlan(
351
364
  undefined,
352
365
  maxXLabelWidth,
353
366
  bandwidth,
354
- xLabels.length,
367
+ drawnLabels.length,
368
+ anchorSpacing,
369
+ theme.fonts.sizes.axisTick,
355
370
  );
356
371
  }
357
372
 
@@ -470,19 +470,43 @@ function evenRange(start: number, end: number, count: number): number[] {
470
470
  * each independently. step = width / (n - paddingInner + 2*paddingOuter);
471
471
  * bandwidth = step * (1 - paddingInner).
472
472
  */
473
+ /** d3 scaleBand padding resolution: `padding` sets both, overrides win. */
474
+ function resolveBandPadding(scaleConfig: EncodingChannel['scale'] | undefined): {
475
+ paddingInner: number;
476
+ paddingOuter: number;
477
+ } {
478
+ const padding = scaleConfig?.padding ?? 0.35;
479
+ return {
480
+ paddingInner: scaleConfig?.paddingInner ?? padding,
481
+ paddingOuter: scaleConfig?.paddingOuter ?? padding,
482
+ };
483
+ }
484
+
473
485
  export function estimateBandwidth(
474
486
  scaleConfig: EncodingChannel['scale'] | undefined,
475
487
  plotWidth: number,
476
488
  n: number,
489
+ ): number {
490
+ const { paddingInner } = resolveBandPadding(scaleConfig);
491
+ return estimateBandStep(scaleConfig, plotWidth, n) * (1 - paddingInner);
492
+ }
493
+
494
+ /**
495
+ * Estimate d3 scaleBand step (center-to-center band distance) for a given
496
+ * plot width and category count, using the same padding resolution as
497
+ * estimateBandwidth. The step — not the bandwidth — is the anchor spacing
498
+ * that drives rotated-label collision checks.
499
+ */
500
+ export function estimateBandStep(
501
+ scaleConfig: EncodingChannel['scale'] | undefined,
502
+ plotWidth: number,
503
+ n: number,
477
504
  ): number {
478
505
  if (n <= 0) return 0;
479
- const padding = scaleConfig?.padding ?? 0.35;
480
- const paddingInner = scaleConfig?.paddingInner ?? padding;
481
- const paddingOuter = scaleConfig?.paddingOuter ?? padding;
506
+ const { paddingInner, paddingOuter } = resolveBandPadding(scaleConfig);
482
507
  const denom = n - paddingInner + 2 * paddingOuter;
483
508
  if (denom <= 0) return 0;
484
- const step = plotWidth / denom;
485
- return step * (1 - paddingInner);
509
+ return plotWidth / denom;
486
510
  }
487
511
 
488
512
  function buildBandScale(