@opendata-ai/openchart-engine 7.2.4 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.ts +88 -39
  2. package/dist/index.js +4134 -2930
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
  6. package/src/__tests__/axes.test.ts +129 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +70 -2
  10. package/src/__tests__/helpers/invariants.ts +265 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +358 -0
  13. package/src/__tests__/layout-invariants.test.ts +535 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +131 -45
  26. package/src/charts/bar/compute.ts +44 -4
  27. package/src/charts/bar/index.ts +9 -2
  28. package/src/charts/bar/labels.ts +77 -26
  29. package/src/charts/column/labels.ts +29 -16
  30. package/src/charts/post-process.ts +21 -7
  31. package/src/charts/registry.ts +4 -2
  32. package/src/compile/layer.ts +31 -9
  33. package/src/compile.ts +88 -149
  34. package/src/index.ts +9 -0
  35. package/src/layout/axes/rotation.ts +36 -0
  36. package/src/layout/axes.ts +130 -8
  37. package/src/layout/dimensions.ts +80 -98
  38. package/src/layout/plan.ts +550 -0
  39. package/src/layout/scales.ts +22 -0
  40. package/src/layout/shared.ts +44 -0
  41. package/src/legend/compute.ts +315 -78
  42. package/src/legend/wrap.ts +11 -7
  43. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  44. package/src/tables/__tests__/heatmap.test.ts +24 -20
  45. package/src/tables/heatmap.ts +25 -9
package/src/compile.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Pipeline for charts:
5
5
  * validate spec -> normalize -> resolve theme -> dark mode adapt ->
6
- * compute legend -> compute dimensions (with legend space) ->
7
- * compute scales -> compute axes -> compute gridlines ->
6
+ * filter data -> resolveLayoutPlan (measure real labels) ->
7
+ * compute dimensions (with plan) -> placeLegend ->
8
+ * compute scales -> compute axes (with pinned ticks) -> compute gridlines ->
8
9
  * get chart renderer -> compute marks -> compute a11y -> return ChartLayout
9
10
  *
10
11
  * Table compiler handles full data pipeline (sort, search, pagination, visual enhancements).
@@ -23,7 +24,6 @@ import type {
23
24
  EncodingChannel,
24
25
  LayerSpec,
25
26
  Mark,
26
- Rect,
27
27
  ResolvedAnnotation,
28
28
  ResolvedTheme,
29
29
  TableLayout,
@@ -34,7 +34,6 @@ import type {
34
34
  import {
35
35
  adaptTheme,
36
36
  computeLabelBounds,
37
- estimateTextWidth,
38
37
  generateAltText,
39
38
  generateDataTable,
40
39
  getBreakpoint,
@@ -42,8 +41,9 @@ import {
42
41
  getLayoutStrategy,
43
42
  resolveTheme,
44
43
  } from '@opendata-ai/openchart-core';
45
- import { format as d3Format } from 'd3-format';
46
44
  import { computeAnnotations } from './annotations/compute';
45
+ import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
46
+ import type { PlacementObstacle } from './annotations/placement';
47
47
  import { computeEndpointLabels } from './endpoint-labels/compute';
48
48
  // Side-effect import: registers all built-in chart renderers with the
49
49
  // registry on module load. Tests that clear the registry can import
@@ -74,9 +74,9 @@ import type { GraphCompilation } from './graphs/types';
74
74
  import { computeAxes } from './layout/axes';
75
75
  import { computeDimensions } from './layout/dimensions';
76
76
  import { computeGridlines } from './layout/gridlines';
77
+ import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
77
78
  import { computeScales } from './layout/scales';
78
- import { computeLegend } from './legend/compute';
79
- import { legendGap } from './legend/wrap';
79
+ import { placeLegend } from './legend/compute';
80
80
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
81
81
  import { compileTableLayout } from './tables/compile-table';
82
82
  import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
@@ -533,70 +533,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
533
533
  chartSpec = applySparklineDefaults(chartSpec, theme);
534
534
  }
535
535
 
536
- // INVARIANT 1 — double legend pass: preliminaryArea → computeDimensions → legendArea → final
537
- // legend. Breaks a dims/legend dependency cycle. Do not collapse into one call.
538
- // Compute legend first (needs to reserve space)
539
- const preliminaryArea: Rect = {
540
- x: 0,
541
- y: 0,
542
- width: options.width,
543
- height: options.height,
544
- };
545
- const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea, watermark);
546
-
547
- // Compute dimensions (accounts for chrome + legend + responsive strategy)
548
- const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy, watermark);
549
- const chartArea = dims.chartArea;
550
-
551
- // Recompute legend bounds relative to actual chart area.
552
- // chartArea was shrunk to exclude legend space, so expand it back to include
553
- // the reserved margin. This way computeLegend positions the legend outside
554
- // the data area (in the margin) instead of overlapping data marks.
555
- //
556
- // Top/bottom legends align their left edge to the plot area (chartArea.x),
557
- // so the legend sits above/below the y-axis guide rather than flush against
558
- // the container edge.
559
- //
560
- // Width runs from the new left origin (chartArea.x) to the container's right
561
- // padding, so the rendered legend never paints past the right edge / over the
562
- // endpoint-label column. This is narrower than the first pass (which used the
563
- // full container at preliminaryArea.width = options.width), so on a chart with
564
- // BOTH a wide y-axis gutter AND a legend that nearly fills its row, the second
565
- // pass can wrap to one more row than the first pass reserved in margins.top —
566
- // the extra row protrudes slightly into the chrome gap. That graceful failure
567
- // (a few px of vertical crowding, still on-canvas) is preferable to the
568
- // alternative (chips clipped off the right edge of the SVG). The maxRows cap
569
- // (default 2 for top legends) bounds the worst case to a single extra row.
570
- const legendArea: Rect = { ...chartArea };
571
- if ('entries' in legendLayout && legendLayout.entries.length > 0) {
572
- const legendInnerWidth = options.width - theme.spacing.padding - chartArea.x;
573
- const gap = legendGap(options.width);
574
- switch (legendLayout.position) {
575
- case 'top':
576
- legendArea.x = chartArea.x;
577
- legendArea.width = legendInnerWidth;
578
- legendArea.y -= legendLayout.bounds.height + gap;
579
- legendArea.height += legendLayout.bounds.height + gap;
580
- break;
581
- case 'bottom':
582
- legendArea.x = chartArea.x;
583
- legendArea.width = legendInnerWidth;
584
- // Bottom legend sits below the x-axis tick row, not over it. Expand
585
- // legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
586
- // legend lands beneath the axis. Mirrors dimensions.ts which reserved
587
- // the same xAxisHeight in margins.bottom.
588
- legendArea.height += legendLayout.bounds.height + gap + dims.xAxisHeight;
589
- break;
590
- case 'right':
591
- case 'bottom-right':
592
- legendArea.width += legendLayout.bounds.width + 8;
593
- break;
594
- }
595
- }
596
- const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea, watermark);
597
-
598
- // Apply data filtering after legend (so legend retains all series), but before
599
- // scale computation (so hidden/clipped data doesn't affect domains or marks).
536
+ // ---------------------------------------------------------------------------
537
+ // Data filtering: move BEFORE layout plan so the plan measures real labels
538
+ // from the filtered data set. Legend retains all series via chartSpec
539
+ // (unfiltered); scales and marks use renderSpec (filtered).
540
+ // ---------------------------------------------------------------------------
600
541
  let renderData = chartSpec.data;
601
542
 
602
543
  // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
@@ -619,7 +560,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
619
560
  // Lock the color scale domain to the unfiltered series list so palette
620
561
  // assignments stay stable across legend toggles. Without this, hiding a
621
562
  // series shrinks the ordinal scale's domain and shifts every remaining
622
- // series down one palette index — the visible lines would mismatch the
563
+ // series down one palette index -- the visible lines would mismatch the
623
564
  // legend swatches that still represent the original assignment.
624
565
  // Skipped when the user already supplied an explicit domain.
625
566
  const colorEnc = chartSpec.encoding.color;
@@ -644,73 +585,53 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
644
585
  };
645
586
  }
646
587
 
647
- // Inline y-axis labels render at chartArea.x with text-anchor=start. Without
648
- // an inset the leftmost data point on a line/area would clip through the
649
- // label glyphs (since the line begins at chartArea.x too). Inset the x-scale
650
- // range start by the widest tick label width so the data starts to the right
651
- // of the labels. Axes/gridlines still span the full chart area, so inline
652
- // labels stay flush left where the design intends.
653
- const yEnc = renderSpec.encoding?.y;
654
- const yAxisCfgInline =
655
- typeof yEnc?.axis === 'object' && yEnc.axis !== null
656
- ? (yEnc.axis as Record<string, unknown>)
657
- : undefined;
658
- const yIsContinuous = yEnc?.type === 'quantitative' || yEnc?.type === 'temporal';
659
- const isLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
660
- const yInlineExplicit = yAxisCfgInline?.tickPosition as 'inline' | 'gutter' | undefined;
661
- // Sparkline mode hides the y-axis by default, so the inline-label inset
662
- // would shrink the data range without any labels actually being drawn —
663
- // the line ends up starting ~30px from the left edge for no visible
664
- // reason. Skip the inset unless the user explicitly opted into the
665
- // y-axis (in which case the labels do render and the inset is needed).
666
- const sparklineSuppressesYAxis =
667
- chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
668
- const yIsInline =
669
- !sparklineSuppressesYAxis &&
670
- (yInlineExplicit === 'inline' ||
671
- (yInlineExplicit === undefined &&
672
- isLineOrArea &&
673
- yIsContinuous &&
674
- yAxisCfgInline?.orient !== 'right'));
588
+ // ---------------------------------------------------------------------------
589
+ // Layout plan: measure real labels, freeze gutter + chrome + legend content
590
+ // ---------------------------------------------------------------------------
591
+ const measure = createMeasureFn(options.measureText);
592
+ const plan = resolveLayoutPlan(
593
+ chartSpec,
594
+ renderSpec,
595
+ options,
596
+ theme,
597
+ strategy,
598
+ watermark,
599
+ measure,
600
+ );
601
+
602
+ // Compute dimensions (accounts for chrome + legend + responsive strategy).
603
+ // The plan provides measured leftGutter, xAxisExtent, and chrome so
604
+ // dimensions.ts skips its guess blocks.
605
+ const legendStub = placeLegend(
606
+ plan.legendContent,
607
+ { x: 0, y: 0, width: options.width, height: options.height },
608
+ options.width,
609
+ theme,
610
+ plan.xAxisExtent,
611
+ );
612
+ const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
613
+ const chartArea = dims.chartArea;
614
+
615
+ // Place legend in its final position relative to the computed chart area.
616
+ const finalLegend = placeLegend(
617
+ plan.legendContent,
618
+ chartArea,
619
+ options.width,
620
+ theme,
621
+ plan.xAxisExtent,
622
+ );
623
+
624
+ // Inline y-axis labels: inset the x-scale range start by the widest tick
625
+ // label width so data marks start to the right of the labels. The plan
626
+ // already measured the real tick labels.
675
627
  let scaleArea = chartArea;
676
- if (yIsInline && yEnc && yIsContinuous && yEnc.axis !== false) {
677
- const yField = yEnc.field;
678
- const yFmt = yAxisCfgInline?.format as string | undefined;
679
- let maxAbsVal = 0;
680
- for (const row of renderSpec.data) {
681
- const v = Number(row[yField]);
682
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
683
- }
684
- let sample: string;
685
- if (yFmt) {
686
- try {
687
- sample = d3Format(yFmt)(maxAbsVal);
688
- } catch {
689
- sample = String(maxAbsVal);
690
- }
691
- } else if (maxAbsVal >= 1_000_000_000) sample = '1.5B';
692
- else if (maxAbsVal >= 1_000_000) sample = '1.5M';
693
- else if (maxAbsVal >= 1_000) sample = '1.5K';
694
- else if (maxAbsVal >= 100) sample = '100';
695
- else if (maxAbsVal >= 10) sample = '10';
696
- else sample = '0.0';
697
- const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
698
- const labelEst = negPrefix + sample;
699
- const labelWidth = estimateTextWidth(
700
- labelEst,
701
- theme.fonts.sizes.axisTick,
702
- theme.fonts.weights.normal,
703
- );
704
- const INLINE_LABEL_BREATHING_ROOM = 8;
705
- const inset = Math.ceil(labelWidth + INLINE_LABEL_BREATHING_ROOM);
706
- if (inset > 0 && inset < chartArea.width) {
707
- scaleArea = {
708
- x: chartArea.x + inset,
709
- y: chartArea.y,
710
- width: chartArea.width - inset,
711
- height: chartArea.height,
712
- };
713
- }
628
+ if (plan.inlineYLabelInset > 0 && plan.inlineYLabelInset < chartArea.width) {
629
+ scaleArea = {
630
+ x: chartArea.x + plan.inlineYLabelInset,
631
+ y: chartArea.y,
632
+ width: chartArea.width - plan.inlineYLabelInset,
633
+ height: chartArea.height,
634
+ };
714
635
  }
715
636
 
716
637
  // Compute scales
@@ -719,7 +640,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
719
640
  // Update color scale to use theme palette (only when user hasn't provided an explicit range)
720
641
  applyColorScaleRange(scales, renderSpec.encoding, theme);
721
642
 
722
- // INVARIANT 3 — post-hoc defaultColor: must run AFTER computeScales since resolution needs
643
+ // INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
723
644
  // theme context. Do not move into computeScales (would require threading theme through).
724
645
  // fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
725
646
  scales.defaultColor =
@@ -741,8 +662,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
741
662
  skipX,
742
663
  skipY,
743
664
  markType: chartSpec.markType,
665
+ totalWidth: options.width,
666
+ precomputedYTicks: plan.yTickValues.length > 0 ? plan.yTickValues : undefined,
744
667
  });
745
668
 
669
+ // Intentional post-hoc mutation: axes must resolve before we know the x-axis extent.
670
+ const xAxisExtent = axes.x?.extent ?? 0;
671
+ dims.chrome.bottomAnchorY = chartArea.y + chartArea.height + xAxisExtent;
672
+
746
673
  // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
747
674
  // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
748
675
  if (!isRadial) {
@@ -756,7 +683,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
756
683
  renderSpec.markDef,
757
684
  );
758
685
  const renderer = getChartRenderer(rendererKey);
759
- 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
+ : [];
760
689
 
761
690
  // Compute the right-side endpoint labels column for multi-series line/area
762
691
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
@@ -764,31 +693,41 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
764
693
  const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
765
694
 
766
695
  // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
767
- const obstacles: Rect[] = [];
696
+ const obstacles: PlacementObstacle[] = [];
768
697
  if (finalLegend.bounds.width > 0) {
769
- obstacles.push(finalLegend.bounds);
698
+ obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
770
699
  }
771
700
  obstacles.push(...computeMarkObstacles(marks, scales));
772
701
 
773
702
  // Add visible data label bounds as obstacles so annotations avoid overlapping them
774
703
  for (const mark of marks) {
775
704
  if ('label' in mark && mark.label?.visible) {
776
- obstacles.push(computeLabelBounds(mark.label));
705
+ obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
777
706
  }
778
707
  }
779
708
 
780
709
  // Add brand watermark as an obstacle so annotations avoid overlapping it.
781
710
  const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
782
- if (watermarkRect) obstacles.push(watermarkRect);
783
- const annotations: ResolvedAnnotation[] = computeAnnotations(
784
- chartSpec,
711
+ if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
712
+
713
+ // Add endpoint label bounds as obstacles
714
+ if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
715
+ obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
716
+ }
717
+
718
+ const annotationMeasure: AnnotationMeasureTextFn = options.measureText
719
+ ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
720
+ : heuristicMeasure;
721
+
722
+ const annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
785
723
  scales,
786
724
  chartArea,
787
725
  strategy,
788
- theme.isDark,
726
+ isDark: theme.isDark,
789
727
  obstacles,
790
- { width: dims.total.width, height: dims.total.height },
791
- );
728
+ svg: { width: dims.total.width, height: dims.total.height },
729
+ measure: annotationMeasure,
730
+ });
792
731
 
793
732
  // Compute tooltip descriptors from marks and encoding
794
733
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
package/src/index.ts CHANGED
@@ -53,6 +53,15 @@ export type { NormalizedBarListSpec } from './barlist/types';
53
53
 
54
54
  export type { NormalizedTileMapSpec } from './tilemap/types';
55
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // Annotations
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export type { AnnotationContext } from './annotations/compute';
61
+ export type { AnnotationMeasureTextFn } from './annotations/geometry';
62
+ export { heuristicMeasure } from './annotations/geometry';
63
+ export type { ObstacleKind, PlacementObstacle } from './annotations/placement';
64
+
56
65
  // ---------------------------------------------------------------------------
57
66
  // Compiler pipeline (spec validation, normalization, generic compile)
58
67
  // ---------------------------------------------------------------------------
@@ -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
+ }
@@ -19,7 +19,15 @@ import type {
19
19
  ResolvedTheme,
20
20
  TextStyle,
21
21
  } from '@opendata-ai/openchart-core';
22
+ import {
23
+ axisTitleOffset,
24
+ computeXAxisExtentFromLabels,
25
+ estimateTextWidth,
26
+ getAxisTitleOffset,
27
+ X_AXIS_TITLE_BAND_ROTATED,
28
+ } from '@opendata-ai/openchart-core';
22
29
  import type { ScaleBand } from 'd3-scale';
30
+ import { resolveBandTickAngle } from './axes/rotation';
23
31
  import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
24
32
  import {
25
33
  buildContinuousTicks,
@@ -123,6 +131,40 @@ const CONTINUOUS_TICK_FLOOR = 4;
123
131
  */
124
132
  const OVERSHOOT_TOLERANCE = 1.5;
125
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
+
126
168
  /**
127
169
  * Fit continuous ticks by re-requesting progressively fewer ticks from the
128
170
  * scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
@@ -152,10 +194,15 @@ function fitContinuousTicks(
152
194
  ): AxisTick[] {
153
195
  if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
154
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
+
155
202
  const tolerance = initialCount * OVERSHOOT_TOLERANCE;
156
- const overshoots = initialTicks.length > tolerance;
157
- const overlaps = ticksOverlap(initialTicks, fontSize, fontWeight, measureText, orientation);
158
- 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;
159
206
 
160
207
  // Enforce the floor only when the axis is long enough to fit that many
161
208
  // labels without overlap. Very short axes can fall below.
@@ -186,7 +233,8 @@ function fitContinuousTicks(
186
233
  // No candidate fit cleanly. Thin whatever most recently met the floor; if
187
234
  // nothing did, synthesize a floor-count set directly from the scale so we
188
235
  // never hand the overshot initialTicks to the middle-pruning thinner.
189
- const fallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
236
+ const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
237
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
190
238
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
191
239
  }
192
240
 
@@ -273,6 +321,15 @@ export interface AxesDataContext {
273
321
  * marks default to `'gutter'`.
274
322
  */
275
323
  markType?: import('@opendata-ai/openchart-core').MarkType;
324
+ /** Total container width for axis title offset threshold checks. */
325
+ totalWidth?: number;
326
+ /**
327
+ * Pinned y-axis tick values from the layout plan. When provided, the y-axis
328
+ * uses these values (via resolveExplicitTicks) instead of generating fresh
329
+ * ticks, so the labels match exactly what the gutter was measured for.
330
+ * User-specified `axis.values` still takes priority.
331
+ */
332
+ precomputedYTicks?: unknown[];
276
333
  }
277
334
 
278
335
  /**
@@ -369,9 +426,7 @@ export function computeAxes(
369
426
  const w = measureLabel(t.label, fontSize, fontWeight, measureText);
370
427
  if (w > maxLabelWidth) maxLabelWidth = w;
371
428
  }
372
- if (maxLabelWidth > bandwidth * 0.85) {
373
- tickAngle = -45;
374
- }
429
+ tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
375
430
  }
376
431
 
377
432
  // Thin tick labels to prevent overlap (skip for explicit tick values).
@@ -440,6 +495,15 @@ export function computeAxes(
440
495
  let allTicks: AxisTick[];
441
496
  if (axisConfig?.values) {
442
497
  allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
498
+ } else if (
499
+ dataContext?.precomputedYTicks &&
500
+ dataContext.precomputedYTicks.length > 0 &&
501
+ isContinuousY
502
+ ) {
503
+ // Pinned ticks from the layout plan -- use the same resolution path as
504
+ // explicit user values so the labels match exactly what the gutter was
505
+ // measured for.
506
+ allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
443
507
  } else if (!isContinuousY) {
444
508
  const yFieldName = dataContext?.encoding.y?.field;
445
509
  const yLabelField = axisConfig?.labelField;
@@ -458,7 +522,8 @@ export function computeAxes(
458
522
  // Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
459
523
  // When tickCount is set, we still thin if D3 overshot the requested count
460
524
  // (common with log scales where ticks(4) can return 26 values).
461
- const shouldThinY = scales.y.type !== 'band' && !axisConfig?.values;
525
+ const shouldThinY =
526
+ scales.y.type !== 'band' && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
462
527
  let ticks: AxisTick[];
463
528
  if (!shouldThinY) {
464
529
  ticks = allTicks;
@@ -525,5 +590,62 @@ export function computeAxes(
525
590
  };
526
591
  }
527
592
 
593
+ const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
594
+
595
+ if (result.x) {
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
+ });
611
+
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;
616
+ result.x.titlePosition = {
617
+ x: chartArea.x + chartArea.width / 2,
618
+ y: chartArea.y + chartArea.height + rotatedBand,
619
+ };
620
+ }
621
+ }
622
+
623
+ if (result.y?.label && result.y.labelStyle) {
624
+ const isRight = result.y.orient === 'right';
625
+ if (isRight) {
626
+ const titleOff = getAxisTitleOffset(totalWidth);
627
+ result.y.titlePosition = {
628
+ x: chartArea.x + chartArea.width + titleOff,
629
+ y: chartArea.y + chartArea.height / 2,
630
+ angle: 90,
631
+ };
632
+ } else {
633
+ const maxTickLabelWidth = result.y.ticks.reduce((max, t) => {
634
+ const w = estimateTextWidth(
635
+ t.label,
636
+ result.y!.tickLabelStyle.fontSize,
637
+ result.y!.tickLabelStyle.fontWeight ?? 400,
638
+ );
639
+ return Math.max(max, w);
640
+ }, 0);
641
+ const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
642
+ result.y.titlePosition = {
643
+ x: chartArea.x - titleOff,
644
+ y: chartArea.y + chartArea.height / 2,
645
+ angle: -90,
646
+ };
647
+ }
648
+ }
649
+
528
650
  return result;
529
651
  }