@opendata-ai/openchart-engine 7.9.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.9.2",
3
+ "version": "7.9.3",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.9.2",
51
+ "@opendata-ai/openchart-core": "7.9.3",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -482,12 +482,11 @@ describe('text-aware tick density', () => {
482
482
  expect(axes.x!.ticks.length).toBe(categories.length);
483
483
  });
484
484
 
485
- it('keeps narrow band labels when one label is much wider (no global decimation)', () => {
485
+ it('keeps all band labels when one label is much wider (no decimation)', () => {
486
486
  // Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
487
- // than the others. The old every-other thinning dropped BOTH "2023" and
488
- // "2025" even though the narrow labels had ample room — a single wide label
489
- // forced global decimation. Greedy per-label retention keeps the narrow
490
- // ones; only the neighbor immediately crowded by the wide final label drops.
487
+ // than the others. Label width cannot cause rotated-label collisions
488
+ // (parallel ribbons — see ./layout/axes/rotation), so every label is kept.
489
+ // The old span-based thinning dropped "2023" and "2025" here.
491
490
  const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
492
491
  const barSpec: NormalizedChartSpec = {
493
492
  ...lineSpec,
@@ -507,16 +506,13 @@ describe('text-aware tick density', () => {
507
506
  height: 12,
508
507
  });
509
508
 
510
- // Narrow area (280px) forces -45° rotation. The middle narrow labels survive
511
- // — old every-other thinning would have dropped 2023 too.
509
+ // Narrow area (280px) forces -45° rotation. Every label survives — the
510
+ // ~47px step separates the diagonal ribbons by far more than a line height.
512
511
  const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
513
512
  const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
514
513
  const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
515
- const narrowKept = narrowAxes.x!.ticks.map((t) => t.label);
516
514
  expect(narrowAxes.x!.tickAngle).toBe(-45);
517
- expect(narrowKept).toContain('2023');
518
- expect(narrowKept).toContain('2024');
519
- expect(narrowKept).toContain('2026 (to wk 17)');
515
+ expect(narrowAxes.x!.ticks.map((t) => t.label)).toEqual(categories);
520
516
 
521
517
  // Wider area (500px) has room for every label — none are thinned.
522
518
  const wideArea = { x: 50, y: 50, width: 500, height: 300 };
@@ -525,13 +521,11 @@ describe('text-aware tick density', () => {
525
521
  expect(wideAxes.x!.ticks.length).toBe(categories.length);
526
522
  });
527
523
 
528
- it('keeps a short neighbor that a wide final label only barely reaches', () => {
529
- // Regression (mobile): with a short label whose footprint clears the wide
530
- // final label by a couple pixels — less than the comfort minGap but with no
531
- // real overlap — the last-label protection must NOT drop it. At the phone
532
- // width where "2026 (to wk 17)" sits ~2px from "2025", dropping "2025" to
533
- // seat the wide endpoint reads as arbitrary. The protection pass keys on
534
- // true pixel overlap, not the comfort gap, so "2025" survives.
524
+ it('keeps a short neighbor beside a wide final label at phone width', () => {
525
+ // Regression (mobile): "2025" beside "2026 (to wk 17)" at ~300px. Two
526
+ // earlier span-model revisions dropped "2025" here (once via minGap
527
+ // comfort padding, once via a genuine 1-D span overlap at larger fonts)
528
+ // even though the rendered -45° diagonals never touch.
535
529
  const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
536
530
  const barSpec: NormalizedChartSpec = {
537
531
  ...lineSpec,
@@ -557,11 +551,38 @@ describe('text-aware tick density', () => {
557
551
  expect(kept.length).toBe(categories.length);
558
552
  });
559
553
 
560
- it('drops every neighbor a very wide final label overlaps, not just the nearest', () => {
561
- // A final category long enough to project across several bands at -45° must
562
- // clear ALL kept labels its footprint overlaps. The last-label protection
563
- // pass drops each colliding neighbor, not only the immediate one — otherwise
564
- // an earlier kept label still overlaps the forced final label.
554
+ it('keeps all labels with a larger themed tick font at phone width (deployed blog regression)', () => {
555
+ // Regression: the deployed blog theme sets axisTick: 14 (default 11). At
556
+ // ~61px steps the larger font made the 1-D span overlap genuine, so 7.9.2
557
+ // still dropped "2025" in production while the default theme passed every
558
+ // test. Ribbon separation at -45° is 61·sin45 ≈ 43px vs the ~21px one line
559
+ // at 14px needs — the labels never touch, so all five must survive.
560
+ const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
561
+ const barSpec: NormalizedChartSpec = {
562
+ ...lineSpec,
563
+ markType: 'bar',
564
+ markDef: { type: 'bar', orient: 'vertical' },
565
+ data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2026, 1602][i] })),
566
+ encoding: {
567
+ x: { field: 'cat', type: 'nominal' },
568
+ y: { field: 'val', type: 'quantitative' },
569
+ },
570
+ };
571
+ const blogTheme = resolveTheme({ fonts: { sizes: { axisTick: 14 } } });
572
+
573
+ const area = { x: 60, y: 50, width: 330, height: 300 };
574
+ const scales = computeScales(barSpec, area, barSpec.data);
575
+ const axes = computeAxes(scales, area, fullStrategy, blogTheme);
576
+ expect(axes.x!.tickAngle).toBe(-45);
577
+ expect(axes.x!.ticks.map((t) => t.label)).toEqual(categories);
578
+ });
579
+
580
+ it('keeps every label when one is very long — rotated labels are parallel ribbons', () => {
581
+ // A very long final category does NOT force neighbors to drop. Rotated
582
+ // labels all lean at the same angle from their own anchors, so adjacent
583
+ // labels are parallel ribbons separated by step·sin(angle) — label LENGTH
584
+ // is irrelevant to collision. The old 1-D horizontal-span model reported
585
+ // phantom overlaps here and dropped "B", "C", and "D".
565
586
  const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
566
587
  const barSpec: NormalizedChartSpec = {
567
588
  ...lineSpec,
@@ -579,15 +600,63 @@ describe('text-aware tick density', () => {
579
600
  const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
580
601
  expect(axes.x!.tickAngle).toBe(-45);
581
602
 
603
+ // Every category keeps its label — the diagonals never touch.
604
+ const kept = axes.x!.ticks.map((t) => t.label);
605
+ expect(kept).toEqual(categories);
606
+ });
607
+
608
+ it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
609
+ // 40 categories with tickCount: 8 — categoricalTicks retains every 5th
610
+ // category, so the drawn ticks sit 5 band steps apart. The angle and
611
+ // stride must use that real spacing: at ~45px apart the 8 labels fit at
612
+ // -45° with no thinning. Keying on the raw ~9px band step would wrongly
613
+ // escalate to -90° and stride away half the requested labels.
614
+ const categories = Array.from({ length: 40 }, (_, i) => `Cat ${i + 1}`);
615
+ const barSpec: NormalizedChartSpec = {
616
+ ...lineSpec,
617
+ markType: 'bar',
618
+ markDef: { type: 'bar', orient: 'vertical' },
619
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
620
+ encoding: {
621
+ x: { field: 'cat', type: 'nominal', axis: { tickCount: 8 } },
622
+ y: { field: 'val', type: 'quantitative' },
623
+ },
624
+ };
625
+ const measure = (text: string) => ({ width: text.length * 6, height: 12 });
626
+ const area = { x: 50, y: 50, width: 360, height: 300 };
627
+ const scales = computeScales(barSpec, area, barSpec.data);
628
+ const axes = computeAxes(scales, area, fullStrategy, theme, measure);
629
+
630
+ expect(axes.x!.tickAngle).toBe(-45);
631
+ expect(axes.x!.ticks.length).toBe(8);
632
+ });
633
+
634
+ it('applies the last-anchored stride when even vertical labels cannot fit', () => {
635
+ // 40 short categories on a 360px chart: ~9px steps fit nothing at any
636
+ // angle (a vertical label ribbon needs ~17px), so the safety net keeps
637
+ // every 2nd label counting back from the LAST category.
638
+ const categories = Array.from({ length: 40 }, (_, i) => `C${i + 1}`);
639
+ const barSpec: NormalizedChartSpec = {
640
+ ...lineSpec,
641
+ markType: 'bar',
642
+ markDef: { type: 'bar', orient: 'vertical' },
643
+ data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
644
+ encoding: {
645
+ x: { field: 'cat', type: 'nominal' },
646
+ y: { field: 'val', type: 'quantitative' },
647
+ },
648
+ };
649
+ const measure = (text: string) => ({ width: text.length * 6, height: 12 });
650
+ const area = { x: 50, y: 50, width: 360, height: 300 };
651
+ const scales = computeScales(barSpec, area, barSpec.data);
652
+ const axes = computeAxes(scales, area, fullStrategy, theme, measure);
653
+
654
+ expect(axes.x!.tickAngle).toBe(-90);
582
655
  const kept = axes.x!.ticks.map((t) => t.label);
583
- // The forced final label is always retained.
584
- expect(kept).toContain('This Is A Very Long Final Category Name');
585
- // Every middle label the wide final label's footprint overlaps is dropped —
586
- // not just the nearest one. Before the fix, "B" and "C" would survive and
587
- // overlap the final label; only "D" (the immediate neighbor) was dropped.
588
- expect(kept).not.toContain('B');
589
- expect(kept).not.toContain('C');
590
- expect(kept).not.toContain('D');
656
+ expect(kept.length).toBe(20);
657
+ // The stride anchors at the final category — the most recent one is
658
+ // always labeled.
659
+ expect(kept[kept.length - 1]).toBe('C40');
591
660
  });
592
661
 
593
662
  it('y-axis gridlines match ticks so every gridline has a label', () => {
@@ -978,7 +1047,7 @@ describe('horizontal bar y-axis label thinning regression', () => {
978
1047
  expect(axes.y!.ticks.length).toBe(countries.length);
979
1048
  });
980
1049
 
981
- it('still thins x-axis band scale labels at minimal density (column chart)', () => {
1050
+ it('rotates to vertical instead of thinning when diagonals would touch (column chart)', () => {
982
1051
  const vBarSpec: NormalizedChartSpec = {
983
1052
  ...hBarSpec,
984
1053
  markDef: { type: 'bar', orient: 'vertical' },
@@ -991,9 +1060,11 @@ describe('horizontal bar y-axis label thinning regression', () => {
991
1060
  const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
992
1061
  const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
993
1062
 
994
- // X-axis band scale with 10 categories at minimal density on a narrow chart
995
- // should thin -- showing all 10 on 200px would overlap
996
- expect(axes.x!.ticks.length).toBeLessThan(countries.length);
1063
+ // 10 categories at ~19px steps: -45° ribbons would touch (19·sin45 ≈ 14px
1064
+ // < one line height), so the ladder escalates to vertical, where the 19px
1065
+ // step separation fits — every bar keeps its label.
1066
+ expect(axes.x!.tickAngle).toBe(-90);
1067
+ expect(axes.x!.ticks.length).toBe(countries.length);
997
1068
  });
998
1069
  });
999
1070
 
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { bandLabelStride, resolveBandTickAngle } from '../layout/axes/rotation';
3
+
4
+ // Geometry cheat sheet at the default 11px tick font:
5
+ // - ribbon thickness = 11 * 1.2 + 4 = 17.2px (perpendicular room one label needs)
6
+ // - at -45°, ribbon separation = spacing * 0.707, so the -45 → -90 flip sits
7
+ // near spacing ≈ 24.3px
8
+ // - at -90°, separation = spacing, so the stride safety net starts below
9
+ // spacing ≈ 17.2px
10
+
11
+ describe('resolveBandTickAngle', () => {
12
+ it('returns the explicit angle untouched', () => {
13
+ expect(resolveBandTickAngle(-30, 100, 20, 5, 50, 11)).toBe(-30);
14
+ expect(resolveBandTickAngle(0, 100, 20, 5, 50, 11)).toBe(0);
15
+ });
16
+
17
+ it('never rotates a single label', () => {
18
+ expect(resolveBandTickAngle(undefined, 100, 20, 1, 50, 11)).toBeUndefined();
19
+ });
20
+
21
+ it('stays flat when the widest label fits its band', () => {
22
+ expect(resolveBandTickAngle(undefined, 17, 20, 5, 30, 11)).toBeUndefined();
23
+ });
24
+
25
+ it('picks -45 when diagonal ribbons clear one line height', () => {
26
+ // separation = 25 * sin45 ≈ 17.7 >= 17.2
27
+ expect(resolveBandTickAngle(undefined, 40, 20, 5, 25, 11)).toBe(-45);
28
+ });
29
+
30
+ it('escalates to -90 when diagonal ribbons would touch', () => {
31
+ // separation = 24 * sin45 ≈ 17.0 < 17.2, and 24px along-text clearance
32
+ // cannot fit a 40px label; vertical separation 24 >= 17.2 fits.
33
+ expect(resolveBandTickAngle(undefined, 40, 20, 5, 24, 11)).toBe(-90);
34
+ });
35
+
36
+ it('keeps -45 for short labels that clear end-to-end along the text direction', () => {
37
+ // Ribbons touch (24 * sin45 < 17.2) but a 10px label clears via the
38
+ // 17px along-text offset — finite segments slide past each other.
39
+ expect(resolveBandTickAngle(undefined, 10, 8, 5, 24, 11)).toBe(-45);
40
+ });
41
+
42
+ it('refuses -90 for labels longer than the rotated-extent cap', () => {
43
+ // A 150px label projected vertically exceeds the 120px reservation cap
44
+ // and would clip into the source line; stay at -45 and let the stride
45
+ // safety net thin instead.
46
+ expect(resolveBandTickAngle(undefined, 150, 20, 5, 20, 11)).toBe(-45);
47
+ });
48
+ });
49
+
50
+ describe('bandLabelStride', () => {
51
+ it('returns 1 for degenerate spacing', () => {
52
+ expect(bandLabelStride(50, -45, 11, 0)).toBe(1);
53
+ expect(bandLabelStride(50, -45, 11, -5)).toBe(1);
54
+ });
55
+
56
+ it('drives the flat stride by label width', () => {
57
+ // (50 + 4) / 20 → every 3rd label
58
+ expect(bandLabelStride(50, undefined, 11, 20)).toBe(3);
59
+ expect(bandLabelStride(10, undefined, 11, 20)).toBe(1);
60
+ });
61
+
62
+ it('keeps every label when vertical separation fits', () => {
63
+ expect(bandLabelStride(50, -90, 11, 20)).toBe(1);
64
+ });
65
+
66
+ it('thins vertical labels only at tiny steps', () => {
67
+ // separation 7 < 17.2 → ceil(17.2 / 7) = 3
68
+ expect(bandLabelStride(50, -90, 11, 7)).toBe(3);
69
+ });
70
+
71
+ it('does not thin a shallow explicit angle whose labels clear end-to-end', () => {
72
+ // labelAngle -20 at 50px spacing: ribbon separation 17.1 barely misses
73
+ // one line height, but the 47px along-text offset clears a 30px label.
74
+ // The old span model dropped every other label here.
75
+ expect(bandLabelStride(30, -20, 11, 50)).toBe(1);
76
+ });
77
+
78
+ it('thins a shallow explicit angle when labels overlap by both routes', () => {
79
+ // labelAngle -20 at 30px spacing with 60px labels: separation 10.3 needs
80
+ // stride 2, along-text clearance 28.2 needs stride 3 → ribbons win.
81
+ expect(bandLabelStride(60, -20, 11, 30)).toBe(2);
82
+ });
83
+ });
@@ -1,36 +1,152 @@
1
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).
2
+ * Auto-rotation and thinning policy for band-scale x-axis tick labels.
3
+ *
4
+ * Band categories are data: every bar should keep its label. The policy
5
+ * steepens the label angle until everything fits — flat, then -45°, then -90°
6
+ * (vertical) — and only thins (uniform every-Nth) when even the steepest
7
+ * usable angle cannot fit every label, which takes dozens of categories at
8
+ * tiny steps.
9
+ *
10
+ * The collision geometry differs by orientation, and getting it right is what
11
+ * makes thinning almost never necessary:
12
+ *
13
+ * - FLAT labels share a baseline, so adjacent labels collide when the widest
14
+ * label's width exceeds the anchor spacing.
15
+ * - ROTATED labels are PARALLEL RIBBONS: every label leans at the same angle
16
+ * from its own anchor, so adjacent labels are parallel segments. They clear
17
+ * each other when EITHER the perpendicular separation between the ribbons
18
+ * (`spacing * |sin θ|`) exceeds one line height, OR the offset along the
19
+ * text direction (`spacing * |cos θ|`) exceeds the label length — finite
20
+ * segments slide past each other end-to-end at shallow angles. Earlier
21
+ * revisions modeled rotated labels as 1-D horizontal spans, which reported
22
+ * phantom collisions for long labels (dropping "2025" beside
23
+ * "2026 (to wk 17)") even though the rendered diagonals never touch.
24
+ *
25
+ * The layout planner and computeAxes must agree on these decisions: the
26
+ * planner reserves the bottom margin for the rotated label footprint, and
27
+ * computeAxes emits the same angle. Sharing this module keeps the reserved
28
+ * space matched to the rendered labels (a mismatch spilled rotated ticks into
29
+ * the source line).
10
30
  */
11
31
 
12
- /** Band fraction above which horizontal labels are considered too wide to fit. */
32
+ import { TICK_LINE_HEIGHT_FACTOR, X_AXIS_ROTATED_EXTENT_CAP } from '@opendata-ai/openchart-core';
33
+
34
+ /** Band fraction above which flat labels are considered too wide to fit. */
13
35
  const BAND_FIT_FRACTION = 0.85;
14
36
 
15
- /** Angle used when band labels don't fit horizontally. */
37
+ /** Diagonal angle tried first when flat labels don't fit. */
16
38
  const AUTO_ROTATE_ANGLE = -45;
17
39
 
40
+ /** Vertical angle used when diagonal ribbons would touch. */
41
+ const STEEP_ROTATE_ANGLE = -90;
42
+
43
+ /** Minimum visible gap between adjacent label ribbons, in px. */
44
+ const LABEL_GAP = 4;
45
+
18
46
  /**
19
- * Decide the effective x tick angle for a band scale.
47
+ * Perpendicular separation between adjacent rotated label ribbons.
48
+ * Labels anchor at points `spacing` apart; leaning at `angleDeg` makes them
49
+ * parallel lines whose perpendicular distance is `spacing * |sin(angle)|`.
50
+ */
51
+ function ribbonSeparation(spacing: number, angleDeg: number): number {
52
+ return spacing * Math.abs(Math.sin((angleDeg * Math.PI) / 180));
53
+ }
54
+
55
+ /**
56
+ * Offset between adjacent labels measured along the text direction.
57
+ * Finite labels clear each other end-to-end when this exceeds the label
58
+ * length, even if the ribbons are closer than a line height — the escape
59
+ * route that matters at shallow angles.
60
+ */
61
+ function alongTextClearance(spacing: number, angleDeg: number): number {
62
+ return spacing * Math.abs(Math.cos((angleDeg * Math.PI) / 180));
63
+ }
64
+
65
+ /** Whether every label fits at the given rotation without touching. */
66
+ function rotatedLabelsFit(
67
+ spacing: number,
68
+ angleDeg: number,
69
+ fontSize: number,
70
+ maxLabelWidth: number,
71
+ ): boolean {
72
+ return (
73
+ ribbonSeparation(spacing, angleDeg) >= fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP ||
74
+ alongTextClearance(spacing, angleDeg) >= maxLabelWidth + LABEL_GAP
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Decide the effective x tick angle for a band scale: the shallowest angle in
80
+ * the ladder (flat → -45° → -90°) at which adjacent labels don't touch.
81
+ *
82
+ * The flat trigger compares the widest label against the bandwidth (does the
83
+ * flat label fit its band?) — the long-standing rule, kept for stability.
84
+ * The rotated steps use the parallel-ribbon fit test above.
85
+ *
86
+ * -90° projects the full label length vertically, so labels longer than the
87
+ * reserved-extent cap would clip into the source line. For those the ladder
88
+ * stays at -45° (smaller vertical footprint) and lets the stride safety net
89
+ * thin the ribbons instead.
20
90
  *
21
91
  * @param explicitAngle - User-specified labelAngle (wins if defined).
22
92
  * @param maxLabelWidth - Widest tick label width in pixels.
23
- * @param bandwidth - Band width in pixels.
93
+ * @param bandwidth - Band width in pixels (excludes inner padding).
24
94
  * @param labelCount - Number of category labels (rotation needs >= 2).
25
- * @returns The explicit angle, `-45` if auto-rotation triggers, or `undefined`.
95
+ * @param spacing - Distance between the closest adjacent DRAWN tick anchors.
96
+ * This is the band step when every category gets a tick, but a multiple of
97
+ * it when an explicit tickCount capped the tick set upstream.
98
+ * @param fontSize - Tick label font size (ribbon thickness term).
99
+ * @returns The explicit angle, `-45`, `-90`, or `undefined` (no rotation).
26
100
  */
27
101
  export function resolveBandTickAngle(
28
102
  explicitAngle: number | undefined,
29
103
  maxLabelWidth: number,
30
104
  bandwidth: number,
31
105
  labelCount: number,
106
+ spacing: number,
107
+ fontSize: number,
32
108
  ): number | undefined {
33
109
  if (explicitAngle !== undefined) return explicitAngle;
34
110
  if (labelCount < 2) return undefined;
35
- return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : undefined;
111
+ if (maxLabelWidth <= bandwidth * BAND_FIT_FRACTION) return undefined;
112
+ if (rotatedLabelsFit(spacing, AUTO_ROTATE_ANGLE, fontSize, maxLabelWidth)) {
113
+ return AUTO_ROTATE_ANGLE;
114
+ }
115
+ return maxLabelWidth <= X_AXIS_ROTATED_EXTENT_CAP ? STEEP_ROTATE_ANGLE : AUTO_ROTATE_ANGLE;
116
+ }
117
+
118
+ /**
119
+ * Uniform every-Nth retention stride for band tick labels — the safety net
120
+ * for when even the resolved angle cannot fit every label (dozens of
121
+ * categories at tiny steps, an explicit shallow labelAngle on a crowded axis,
122
+ * or labels too long for the -90° escalation). Returns 1 (keep every label)
123
+ * whenever labels fit.
124
+ *
125
+ * Callers keep indices counting back from the LAST label
126
+ * (`(lastIdx - i) % stride === 0`) so the most recent category — usually the
127
+ * most editorially important — is always labeled.
128
+ *
129
+ * Flat labels collide along their shared baseline, so the stride is driven by
130
+ * the widest label's width. Rotated labels escape by either route — ribbon
131
+ * separation or along-text clearance — and both scale linearly with the
132
+ * stride, so the cheaper one wins.
133
+ */
134
+ export function bandLabelStride(
135
+ maxLabelWidth: number,
136
+ angleDeg: number | undefined,
137
+ fontSize: number,
138
+ spacing: number,
139
+ ): number {
140
+ if (spacing <= 0) return 1;
141
+ if (angleDeg === undefined || angleDeg === 0) {
142
+ return Math.max(1, Math.ceil((maxLabelWidth + LABEL_GAP) / spacing));
143
+ }
144
+ const separation = ribbonSeparation(spacing, angleDeg);
145
+ const clearance = alongTextClearance(spacing, angleDeg);
146
+ const required = fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP;
147
+ const byRibbon = separation > 0 ? Math.ceil(required / separation) : Number.POSITIVE_INFINITY;
148
+ const byLength =
149
+ clearance > 0 ? Math.ceil((maxLabelWidth + LABEL_GAP) / clearance) : Number.POSITIVE_INFINITY;
150
+ const stride = Math.min(byRibbon, byLength);
151
+ return Number.isFinite(stride) ? Math.max(1, stride) : 1;
36
152
  }
@@ -256,8 +256,8 @@ export function categoricalTicks(
256
256
  if (resolvedScale.type === 'band' && orientation === 'horizontal') {
257
257
  // Horizontal band scales delegate thinning to the caller (computeAxes)
258
258
  // 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.
259
+ // an explicit tickCount cap here; the angle ladder + stride safety net
260
+ // (see ./rotation) handle collisions downstream.
261
261
  if (explicitTickCount && domain.length > explicitTickCount) {
262
262
  const step = Math.ceil(domain.length / explicitTickCount);
263
263
  selectedValues = domain.filter((_: string, i: number) => i % step === 0);
@@ -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,96 +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.
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).
303
+ function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
304
+ if (stride <= 1 || ticks.length < 3) return ticks;
374
305
  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]);
306
+ return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
388
307
  }
389
308
 
390
309
  // ---------------------------------------------------------------------------
@@ -511,17 +430,40 @@ export function computeAxes(
511
430
  major: true,
512
431
  }));
513
432
 
514
- // Determine rotation before thinning so we know the effective label
515
- // 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.
516
437
  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();
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();
519
453
  let maxLabelWidth = 0;
520
454
  for (const t of allTicks) {
521
455
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
522
456
  if (w > maxLabelWidth) maxLabelWidth = w;
523
457
  }
524
- 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);
525
467
  }
526
468
 
527
469
  // Thin tick labels to prevent overlap (skip for explicit tick values).
@@ -530,10 +472,7 @@ export function computeAxes(
530
472
  if (hasExplicitValues) {
531
473
  ticks = allTicks;
532
474
  } 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);
475
+ ticks = applyBandLabelStride(allTicks, bandStride);
537
476
  } else if (isContinuousX) {
538
477
  ticks = fitContinuousTicks(
539
478
  scales.x,