@opendata-ai/openchart-engine 7.9.1 → 7.9.3

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.
@@ -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,
@@ -295,90 +295,15 @@ function fitContinuousTicks(
295
295
  }
296
296
 
297
297
  /**
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.
298
+ * Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
299
+ * keep every Nth label counting back from the LAST one, so the most recent
300
+ * category is always labeled. Stride 1 — the overwhelmingly common case once
301
+ * the angle ladder has run — keeps everything.
308
302
  */
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.
303
+ function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
304
+ if (stride <= 1 || ticks.length < 3) return ticks;
368
305
  const lastIdx = ticks.length - 1;
369
- if (!keep[lastIdx]) {
370
- keep[lastIdx] = true;
371
- for (let i = lastIdx - 1; i > 0; i--) {
372
- if (!keep[i]) continue;
373
- if (spans[i].right + minGap > spans[lastIdx].left) {
374
- keep[i] = false;
375
- continue;
376
- }
377
- break;
378
- }
379
- }
380
-
381
- return ticks.filter((_, i) => keep[i]);
306
+ return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
382
307
  }
383
308
 
384
309
  // ---------------------------------------------------------------------------
@@ -505,17 +430,40 @@ export function computeAxes(
505
430
  major: true,
506
431
  }));
507
432
 
508
- // Determine rotation before thinning so we know the effective label
509
- // footprint. Band scales auto-rotate when horizontal labels don't fit.
433
+ // Band-label policy: choose the shallowest angle at which every label
434
+ // fits (flat → -45° → -90°), then apply the uniform stride safety net —
435
+ // stride 1 (keep everything) except at extreme category counts. See
436
+ // ./axes/rotation for the geometry.
510
437
  let tickAngle = axisConfig?.labelAngle;
511
- if (tickAngle === undefined && scales.x.type === 'band' && allTicks.length > 1) {
512
- const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
438
+ let bandStride = 1;
439
+ if (scales.x.type === 'band' && allTicks.length > 1) {
440
+ const bandScale = scales.x.scale as ScaleBand<string>;
441
+ const bandwidth = bandScale.bandwidth();
442
+ // Anchor spacing between the ticks actually drawn — not the raw band
443
+ // step. categoricalTicks may have applied an explicit tickCount cap, so
444
+ // retained ticks can sit a multiple of the step apart, and explicit
445
+ // axis.values subsets can be non-uniform: collision geometry cares
446
+ // about the closest adjacent pair.
447
+ let anchorSpacing = Number.POSITIVE_INFINITY;
448
+ for (let i = 1; i < allTicks.length; i++) {
449
+ const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
450
+ if (d > 0 && d < anchorSpacing) anchorSpacing = d;
451
+ }
452
+ if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
513
453
  let maxLabelWidth = 0;
514
454
  for (const t of allTicks) {
515
455
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
516
456
  if (w > maxLabelWidth) maxLabelWidth = w;
517
457
  }
518
- tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
458
+ tickAngle = resolveBandTickAngle(
459
+ axisConfig?.labelAngle,
460
+ maxLabelWidth,
461
+ bandwidth,
462
+ allTicks.length,
463
+ anchorSpacing,
464
+ fontSize,
465
+ );
466
+ bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
519
467
  }
520
468
 
521
469
  // Thin tick labels to prevent overlap (skip for explicit tick values).
@@ -524,10 +472,7 @@ export function computeAxes(
524
472
  if (hasExplicitValues) {
525
473
  ticks = allTicks;
526
474
  } else if (scales.x.type === 'band') {
527
- // Band scales: thin only when labels actually overlap at their
528
- // effective angle. After rotation, most charts have room for every label.
529
- const effectiveAngle = tickAngle ?? 0;
530
- ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
475
+ ticks = applyBandLabelStride(allTicks, bandStride);
531
476
  } else if (isContinuousX) {
532
477
  ticks = fitContinuousTicks(
533
478
  scales.x,
@@ -735,7 +680,17 @@ export function computeAxes(
735
680
  );
736
681
  return Math.max(max, w);
737
682
  }, 0);
738
- const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
683
+ // Inline y-ticks live inside the plot (no left gutter), so the title
684
+ // clears only the chart edge — pass the inline flag so the offset drops
685
+ // the tick-label width and the viewport floor. Omitting it reserved a
686
+ // gutter-sized offset that pushed the rotated title off the left edge of
687
+ // the container on narrow/large-font charts.
688
+ const titleOff = axisTitleOffset(
689
+ maxTickLabelWidth,
690
+ result.y.labelStyle.fontSize,
691
+ totalWidth,
692
+ result.y.tickPosition === 'inline',
693
+ );
739
694
  result.y.titlePosition = {
740
695
  x: chartArea.x - titleOff,
741
696
  y: chartArea.y + chartArea.height / 2,
@@ -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(