@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.11

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 (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. package/src/transforms/window.ts +0 -185
@@ -1,757 +0,0 @@
1
- /**
2
- * Axis computation: tick positions, labels, and axis lines.
3
- *
4
- * Generates ticks manually (no d3-axis) so we have full control over
5
- * responsive tick density and formatting. Tick generation and label
6
- * thinning live in sibling modules under ./axes/.
7
- */
8
-
9
- import type {
10
- AxisLabelDensity,
11
- AxisLayout,
12
- AxisTick,
13
- DataRow,
14
- Encoding,
15
- Gridline,
16
- LayoutStrategy,
17
- MeasureTextFn,
18
- Rect,
19
- ResolvedTheme,
20
- TextStyle,
21
- } from '@opendata-ai/openchart-core';
22
- import {
23
- axisTitleOffset,
24
- computeXAxisExtentFromLabels,
25
- estimateTextWidth,
26
- getAxisTitleOffset,
27
- truncateRotatedLabel,
28
- X_AXIS_TITLE_BAND,
29
- X_AXIS_TITLE_BAND_ROTATED,
30
- } from '@opendata-ai/openchart-core';
31
- import type { ScaleBand } from 'd3-scale';
32
- import { bandLabelStride, resolveBandTickAngle } from './axes/rotation';
33
- import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
34
- import {
35
- buildContinuousTicks,
36
- categoricalTicks,
37
- continuousTicks,
38
- resolveExplicitTicks,
39
- scaleSupportsTickCount,
40
- targetTickCount,
41
- } from './axes/ticks';
42
- import type { ResolvedScales } from './scales';
43
-
44
- // Re-export pure helpers so external consumers (and tests) continue to import
45
- // them from './layout/axes'.
46
- export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
47
-
48
- /** Scale types that render as a categorical (non-continuous) y-axis. */
49
- const CATEGORICAL_SCALE_TYPES = new Set(['band', 'point', 'ordinal']);
50
-
51
- /**
52
- * Whether the y-axis renders tick labels inline (above their gridline inside
53
- * the plot) rather than in a left gutter.
54
- *
55
- * This is the single source of truth shared by margin reservation (plan.ts,
56
- * dimensions.ts) and the renderer's title placement. All three must agree or
57
- * the reserved left margin won't match where the title is drawn.
58
- *
59
- * Editorial line/area charts with a continuous y-axis default to inline ticks.
60
- * Continuity is decided by the RESOLVED scale type, not the field type: an
61
- * explicit `scale.type` override (e.g. a band scale forced onto a quantitative
62
- * field) wins, mirroring buildPositionalScale() in scales.ts. Right-side
63
- * (dual-axis) y-axes always use the gutter. An explicit axis.tickPosition wins
64
- * over the default.
65
- */
66
- export function yTickPositionIsInline(
67
- yChannel:
68
- | {
69
- type?: string;
70
- scale?: { type?: string };
71
- axis?: unknown;
72
- }
73
- | undefined,
74
- markType: string,
75
- /**
76
- * Resolved continuity of the y-scale, when the caller already has the built
77
- * scale (computeAxes). When omitted, continuity is inferred from the encoding
78
- * (explicit scale.type override, else field type) so the reservation paths
79
- * predict the same answer computeAxes will produce.
80
- */
81
- resolvedContinuous?: boolean,
82
- ): boolean {
83
- if (!yChannel) return false;
84
- const axisCfg = (yChannel.axis as Record<string, unknown> | undefined) ?? undefined;
85
- const explicit = axisCfg?.tickPosition as 'inline' | 'gutter' | undefined;
86
- if (explicit) return explicit === 'inline';
87
-
88
- const isLineOrArea = markType === 'line' || markType === 'area';
89
- if (!isLineOrArea) return false;
90
- if ((axisCfg?.orient as string | undefined) === 'right') return false;
91
-
92
- if (resolvedContinuous !== undefined) return resolvedContinuous;
93
-
94
- // Predict the resolved scale type: explicit override wins, else field type.
95
- const explicitScaleType = yChannel.scale?.type;
96
- return explicitScaleType
97
- ? !CATEGORICAL_SCALE_TYPES.has(explicitScaleType)
98
- : yChannel.type === 'quantitative' || yChannel.type === 'temporal';
99
- }
100
-
101
- // ---------------------------------------------------------------------------
102
- // Constants
103
- // ---------------------------------------------------------------------------
104
-
105
- /**
106
- * Height thresholds for reducing y-axis tick density.
107
- * Below these pixel heights, we step down the density regardless of the
108
- * width-based strategy. This prevents overlapping y-axis labels in short
109
- * containers like thumbnail previews.
110
- *
111
- * These thresholds apply to the chart area height (after chrome/margins),
112
- * not the total container height. A 400px container with title+subtitle
113
- * leaves ~270px of chart area; a 320px container leaves ~186px. The old
114
- * HEIGHT_REDUCED_THRESHOLD of 200 kicked in on nearly every common chart
115
- * size, producing only 3 ticks. Lowering to 100 keeps 'full' density for
116
- * all but the most compact thumbnail-style containers.
117
- */
118
- const HEIGHT_MINIMAL_THRESHOLD = 80;
119
- const HEIGHT_REDUCED_THRESHOLD = 100;
120
-
121
- /**
122
- * Width thresholds for reducing x-axis tick density.
123
- * Mirrors the height logic for the x-axis: narrow containers get fewer ticks.
124
- */
125
- const WIDTH_MINIMAL_THRESHOLD = 150;
126
- const WIDTH_REDUCED_THRESHOLD = 300;
127
-
128
- /** Ordered densities from most to fewest ticks. */
129
- const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
130
-
131
- /** Marks grounded on the x baseline that warrant a domain line. */
132
- const GROUNDED_MARKS = new Set(['bar', 'tick', 'lollipop']);
133
-
134
- /**
135
- * Compute effective axis tick density by considering available space.
136
- *
137
- * The width-based breakpoint system sets a base density, but it doesn't know
138
- * about the actual chart area dimensions (which shrink after chrome/legend
139
- * allocation). This function steps density down further when the axis
140
- * dimension is too small for the requested tick count.
141
- *
142
- * @param baseDensity - The density from the responsive layout strategy.
143
- * @param axisLength - Available pixels along this axis (height for y, width for x).
144
- * @param minimalThreshold - Below this pixel size, force minimal density.
145
- * @param reducedThreshold - Below this pixel size, cap at reduced density.
146
- * @returns The effective density, never looser than the base.
147
- */
148
- export function effectiveDensity(
149
- baseDensity: AxisLabelDensity,
150
- axisLength: number,
151
- minimalThreshold: number,
152
- reducedThreshold: number,
153
- ): AxisLabelDensity {
154
- let density = baseDensity;
155
-
156
- if (axisLength < minimalThreshold) {
157
- density = 'minimal';
158
- } else if (axisLength < reducedThreshold) {
159
- // Don't increase density beyond what the base strategy allows.
160
- // If base is already 'minimal', keep it.
161
- const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
162
- const reducedIdx = DENSITY_ORDER.indexOf('reduced');
163
- density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
164
- }
165
-
166
- return density;
167
- }
168
-
169
- /**
170
- * Floor tick count for continuous axes when the axis is long enough to show
171
- * more than a min/max pair. Keeps the editorial ~5 target when possible.
172
- * Very short axes bypass this floor and can legitimately fall to 2.
173
- */
174
- const CONTINUOUS_TICK_FLOOR = 4;
175
-
176
- /**
177
- * How much D3 is allowed to overshoot what we asked for before we treat the
178
- * output as "too dense" and step down. D3's `scale.ticks(n)` only produces
179
- * nice step sizes (1, 2, 5 × 10^k, or calendar units for time), so a request
180
- * for n=6 can come back with 12 quarterly dates or 10 step-5 values. Accepting
181
- * up to 1.5× target catches the obvious overshoots without trimming acceptable
182
- * ones.
183
- *
184
- * The reference point stays fixed to the initial requested count even as we
185
- * iterate downward — we're measuring "did this candidate land near the target
186
- * the caller actually wanted?", not "is the candidate near what we just asked
187
- * for on this iteration?". If a candidate at n=3 returns 8 ticks, it's still
188
- * a 1.3× overshoot of the target-6, which is fine.
189
- */
190
- const OVERSHOOT_TOLERANCE = 1.5;
191
-
192
- /**
193
- * Quantitative axes must always render at least this many tick labels. A lone
194
- * tick reads as a broken axis (a real iPhone blog chart degenerated to a
195
- * single "80" when D3's `scale.ticks(2)` collapsed a tight domain like
196
- * [73, 88] to one nice value). This floor holds at every width.
197
- */
198
- const MIN_QUANTITATIVE_TICKS = 2;
199
-
200
- /**
201
- * Ensure a continuous scale yields at least MIN_QUANTITATIVE_TICKS ticks.
202
- *
203
- * D3's `scale.ticks(n)` picks nice step sizes, so a tight domain can collapse
204
- * to a single value at low counts (`[73, 88].ticks(2)` → `[80]`). When that
205
- * happens, request progressively higher counts until at least two ticks
206
- * appear. Prefers the smallest count that clears the floor, which keeps the
207
- * ticks spread toward the domain endpoints rather than clustered.
208
- */
209
- function ensureMinContinuousTicks(
210
- scale: ResolvedScales['x' | 'y'],
211
- ticks: AxisTick[],
212
- requestedCount: number,
213
- compact = false,
214
- ): AxisTick[] {
215
- if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
216
- if (!scale || !scaleSupportsTickCount(scale)) return ticks;
217
-
218
- let best = ticks;
219
- for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
220
- const candidate = buildContinuousTicks(scale, n, compact);
221
- if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
222
- if (candidate.length > best.length) best = candidate;
223
- }
224
- return best;
225
- }
226
-
227
- /**
228
- * Fit continuous ticks by re-requesting progressively fewer ticks from the
229
- * scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
230
- * stepping `n` down keeps spacing uniform — unlike middle-pruning which can
231
- * strand the last tick next to an endpoint and cascade to 2 ticks.
232
- *
233
- * Two conditions trigger a step-down:
234
- * 1. The label heuristic detects overlap at the initial count.
235
- * 2. D3 overshot the requested count by more than OVERSHOOT_TOLERANCE.
236
- * (Time scales jump between calendar units; linear scales jump between
237
- * nice step sizes. Either can return 2× what we asked for.)
238
- *
239
- * Falls back to overlap-safe thinning on the best-so-far candidate if no
240
- * count produces a clean fit. The fallback starts from the smallest candidate
241
- * that still meets the floor, so `thinTicksUntilFit` never receives the
242
- * overshot initial set (which was the bug this function exists to avoid).
243
- */
244
- function fitContinuousTicks(
245
- scale: ResolvedScales['x' | 'y'],
246
- initialTicks: AxisTick[],
247
- initialCount: number,
248
- fontSize: number,
249
- fontWeight: number,
250
- axisLength: number,
251
- orientation: 'horizontal' | 'vertical',
252
- measureText?: MeasureTextFn,
253
- ): AxisTick[] {
254
- if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
255
-
256
- // Floor the initial set: a tight domain can make D3 return a single nice
257
- // value, which would otherwise pass straight through (no overlap, no
258
- // overshoot) and render as a lone tick.
259
- const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
260
-
261
- const tolerance = initialCount * OVERSHOOT_TOLERANCE;
262
- const overshoots = flooredTicks.length > tolerance;
263
- const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
264
- if (!overshoots && !overlaps) return flooredTicks;
265
-
266
- // Compact temporal formats are a rung between "full labels overlap" and
267
- // "drop a tick": at each candidate count, prefer the full format, then try
268
- // the compact table at the SAME count before stepping the count down. Every
269
- // returned set is internally consistent — one format family per layout pass.
270
- // X-axes only: the planner/renderer y pair must keep formatting identically
271
- // (see resolveExplicitTicks), so vertical axes never compact — including
272
- // the fallback path below.
273
- const useCompact =
274
- (scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
275
-
276
- const fitsCleanly = (candidate: AxisTick[]): boolean =>
277
- candidate.length <= tolerance &&
278
- !ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
279
-
280
- // Compact-saves-the-count case: full format overlapped at the initial
281
- // count, but compact labels may fit without giving up a single tick.
282
- if (useCompact) {
283
- const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
284
- if (fitsCleanly(compactAtInitial)) return compactAtInitial;
285
- }
286
-
287
- // Enforce the floor only when the axis is long enough to fit that many
288
- // labels without overlap. Very short axes can fall below.
289
- const minThreshold =
290
- orientation === 'vertical' ? HEIGHT_MINIMAL_THRESHOLD : WIDTH_MINIMAL_THRESHOLD;
291
- const floor = axisLength >= minThreshold ? CONTINUOUS_TICK_FLOOR : 2;
292
-
293
- // Track the smallest candidate that meets the floor. If no candidate fits
294
- // cleanly, we thin this instead of the overshot `initialTicks` so the
295
- // fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
296
- // runs against the full-format candidate, exactly as before the compact rung.
297
- let bestWithinFloor: AxisTick[] | undefined;
298
- let bestWithinFloorCount: number | undefined;
299
- for (let n = initialCount - 1; n >= 2; n--) {
300
- const candidate = buildContinuousTicks(scale, n);
301
- if (fitsCleanly(candidate)) return candidate;
302
- if (useCompact) {
303
- const compact = buildContinuousTicks(scale, n, true);
304
- if (fitsCleanly(compact)) return compact;
305
- }
306
- if (candidate.length >= floor) {
307
- bestWithinFloor = candidate;
308
- bestWithinFloorCount = n;
309
- }
310
- }
311
-
312
- // No candidate fit cleanly. Thin whatever most recently met the floor; if
313
- // nothing did, synthesize a floor-count set directly from the scale so we
314
- // never hand the overshot initialTicks to the middle-pruning thinner.
315
- // Compacting axes rebuild the fallback compact: if the axis is so starved
316
- // that we're thinning below the floor, shorter labels are strictly better.
317
- const rawFallback = useCompact
318
- ? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
319
- : (bestWithinFloor ?? buildContinuousTicks(scale, floor));
320
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
321
- return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
322
- }
323
-
324
- /**
325
- * Apply the uniform band-label stride (see bandLabelStride in ./axes/rotation):
326
- * keep every Nth label counting back from the LAST one, so the most recent
327
- * category is always labeled. Stride 1 — the overwhelmingly common case once
328
- * the angle ladder has run — keeps everything.
329
- */
330
- function applyBandLabelStride(ticks: AxisTick[], stride: number): AxisTick[] {
331
- if (stride <= 1 || ticks.length < 3) return ticks;
332
- const lastIdx = ticks.length - 1;
333
- return ticks.filter((_, i) => (lastIdx - i) % stride === 0);
334
- }
335
-
336
- // ---------------------------------------------------------------------------
337
- // Public API
338
- // ---------------------------------------------------------------------------
339
-
340
- /** Output of computeAxes. */
341
- export interface AxesResult {
342
- x?: AxisLayout;
343
- y?: AxisLayout;
344
- }
345
-
346
- /** Optional data context for axis computation (enables labelField subtitles). */
347
- export interface AxesDataContext {
348
- /** The data rows for subtitle lookup. */
349
- data: DataRow[];
350
- /** The encoding object to resolve field names. */
351
- encoding: Encoding;
352
- /**
353
- * When true, skip generating ticks/labels/title for the x-axis. Used by
354
- * sparkline display mode when the user hasn't explicitly opted into axes.
355
- */
356
- skipX?: boolean;
357
- /** Same as skipX, for the y-axis. */
358
- skipY?: boolean;
359
- /**
360
- * The chart's primary mark type. Used to default tickPosition: line and area
361
- * y-axes default to `'inline'` (labels above gridlines, no gutter); other
362
- * marks default to `'gutter'`.
363
- */
364
- markType?: import('@opendata-ai/openchart-core').MarkType;
365
- /** Total container width for axis title offset threshold checks. */
366
- totalWidth?: number;
367
- /**
368
- * Pinned y-axis tick values from the layout plan. When provided, the y-axis
369
- * uses these values (via resolveExplicitTicks) instead of generating fresh
370
- * ticks, so the labels match exactly what the gutter was measured for.
371
- * User-specified `axis.values` still takes priority.
372
- */
373
- precomputedYTicks?: unknown[];
374
- }
375
-
376
- /**
377
- * Compute axis layouts with tick positions, labels, and axis lines.
378
- *
379
- * @param scales - Resolved scales from computeScales.
380
- * @param chartArea - The chart drawing area.
381
- * @param strategy - Responsive layout strategy.
382
- * @param theme - Resolved theme for styling.
383
- * @param measureText - Optional real text measurement from the adapter.
384
- * @param dataContext - Optional data context for labelField subtitle support.
385
- */
386
- export function computeAxes(
387
- scales: ResolvedScales,
388
- chartArea: Rect,
389
- strategy: LayoutStrategy,
390
- theme: ResolvedTheme,
391
- measureText?: MeasureTextFn,
392
- dataContext?: AxesDataContext,
393
- ): AxesResult {
394
- const result: AxesResult = {};
395
- const baseDensity = strategy.axisLabelDensity;
396
-
397
- // Compute per-axis density based on available space.
398
- // Y-axis density adapts to chart height; X-axis density adapts to chart width.
399
- const yDensity = effectiveDensity(
400
- baseDensity,
401
- chartArea.height,
402
- HEIGHT_MINIMAL_THRESHOLD,
403
- HEIGHT_REDUCED_THRESHOLD,
404
- );
405
- const xDensity = effectiveDensity(
406
- baseDensity,
407
- chartArea.width,
408
- WIDTH_MINIMAL_THRESHOLD,
409
- WIDTH_REDUCED_THRESHOLD,
410
- );
411
-
412
- const tickLabelStyle: TextStyle = {
413
- fontFamily: theme.fonts.family,
414
- fontSize: theme.fonts.sizes.axisTick,
415
- fontWeight: theme.fonts.weights.normal,
416
- fill: theme.colors.axis,
417
- lineHeight: 1.2,
418
- fontVariant: 'tabular-nums',
419
- };
420
-
421
- const axisLabelStyle: TextStyle = {
422
- fontFamily: theme.fonts.family,
423
- fontSize: theme.fonts.sizes.body,
424
- fontWeight: theme.fonts.weights.medium,
425
- fill: theme.colors.text,
426
- lineHeight: 1.3,
427
- };
428
-
429
- const { fontSize } = tickLabelStyle;
430
- const { fontWeight } = tickLabelStyle;
431
-
432
- if (scales.x && !dataContext?.skipX && scales.x.channel.axis !== false) {
433
- const axisConfig = scales.x.channel.axis;
434
- const isContinuousX =
435
- scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
436
-
437
- const xTargetCount = isContinuousX
438
- ? targetTickCount(chartArea.width, xDensity, 'x')
439
- : undefined;
440
-
441
- // Use explicit tick values from axis config if provided
442
- let allTicks: AxisTick[];
443
- if (axisConfig?.values) {
444
- allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
445
- } else if (!isContinuousX) {
446
- // For band scales, generate all ticks first (no thinning). Rotation and
447
- // thinning are resolved below once we know the effective label angle.
448
- allTicks = categoricalTicks(scales.x, xDensity, 'horizontal');
449
- } else {
450
- allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
451
- }
452
-
453
- // Gridlines use the full tick set so they remain visible even when labels
454
- // are thinned to prevent overlap.
455
- const gridlines: Gridline[] = allTicks.map((t) => ({
456
- position: t.position,
457
- major: true,
458
- }));
459
-
460
- // Band-label policy: choose the shallowest angle at which every label
461
- // fits (flat → -45° → -90°), then apply the uniform stride safety net —
462
- // stride 1 (keep everything) except at extreme category counts. See
463
- // ./axes/rotation for the geometry.
464
- let tickAngle = axisConfig?.labelAngle;
465
- let bandStride = 1;
466
- if (scales.x.type === 'band' && allTicks.length > 1) {
467
- const bandScale = scales.x.scale as ScaleBand<string>;
468
- const bandwidth = bandScale.bandwidth();
469
- // Anchor spacing between the ticks actually drawn — not the raw band
470
- // step. categoricalTicks may have applied an explicit tickCount cap, so
471
- // retained ticks can sit a multiple of the step apart, and explicit
472
- // axis.values subsets can be non-uniform: collision geometry cares
473
- // about the closest adjacent pair.
474
- let anchorSpacing = Number.POSITIVE_INFINITY;
475
- for (let i = 1; i < allTicks.length; i++) {
476
- const d = Math.abs(allTicks[i].position - allTicks[i - 1].position);
477
- if (d > 0 && d < anchorSpacing) anchorSpacing = d;
478
- }
479
- if (!Number.isFinite(anchorSpacing)) anchorSpacing = bandScale.step();
480
- let maxLabelWidth = 0;
481
- for (const t of allTicks) {
482
- const w = measureLabel(t.label, fontSize, fontWeight, measureText);
483
- if (w > maxLabelWidth) maxLabelWidth = w;
484
- }
485
- tickAngle = resolveBandTickAngle(
486
- axisConfig?.labelAngle,
487
- maxLabelWidth,
488
- bandwidth,
489
- allTicks.length,
490
- anchorSpacing,
491
- fontSize,
492
- );
493
- bandStride = bandLabelStride(maxLabelWidth, tickAngle, fontSize, anchorSpacing);
494
- }
495
-
496
- // Thin tick labels to prevent overlap (skip for explicit tick values).
497
- const hasExplicitValues = !!axisConfig?.values;
498
- let ticks: AxisTick[];
499
- if (hasExplicitValues) {
500
- ticks = allTicks;
501
- } else if (scales.x.type === 'band') {
502
- ticks = applyBandLabelStride(allTicks, bandStride);
503
- } else if (isContinuousX) {
504
- ticks = fitContinuousTicks(
505
- scales.x,
506
- allTicks,
507
- xTargetCount ?? allTicks.length,
508
- fontSize,
509
- fontWeight,
510
- chartArea.width,
511
- 'horizontal',
512
- measureText,
513
- );
514
- } else {
515
- ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
516
- }
517
-
518
- // Truncate rotated labels that would overflow the capped reservation.
519
- // computeXAxisExtentFromLabels clamps the reserved band to
520
- // X_AXIS_ROTATED_EXTENT_CAP, but nothing shortened the drawn string, so a
521
- // long label rendered straight past the axis and through the source line.
522
- // Clip to the same budget the reservation is computed from. `fullLabel`
523
- // carries the original text so tooltips and the a11y table stay complete.
524
- if (tickAngle && Math.abs(tickAngle) > 10) {
525
- // MeasureTextFn returns { width }, not a number — adapt it the same way
526
- // measureLabel does, or every comparison against the width budget is NaN
527
- // and the search collapses every label to a bare ellipsis.
528
- const measureWidth = (text: string, size: number, weight?: number) =>
529
- measureLabel(text, size, weight ?? fontWeight, measureText);
530
- ticks = ticks.map((t) => {
531
- const truncated = truncateRotatedLabel(
532
- t.label,
533
- tickAngle,
534
- fontSize,
535
- fontWeight,
536
- measureWidth,
537
- );
538
- return truncated === t.label ? t : { ...t, label: truncated, fullLabel: t.label };
539
- });
540
- }
541
-
542
- const axisTitle = axisConfig?.title;
543
- const xLabelColor = axisConfig?.labelColor;
544
- // X-axis defaults to gutter (no inline mode is sensible for the x axis
545
- // because tick labels need horizontal room around their x position).
546
- const xTickPosition = axisConfig?.tickPosition ?? 'gutter';
547
-
548
- const xDomainLine =
549
- axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? '') ? undefined : false);
550
-
551
- result.x = {
552
- ticks,
553
- gridlines: (axisConfig?.grid ?? dataContext?.markType === 'point') ? gridlines : [],
554
- label: axisTitle,
555
- labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
556
- tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
557
- tickAngle,
558
- start: { x: chartArea.x, y: chartArea.y + chartArea.height },
559
- end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
560
- orient: axisConfig?.orient,
561
- domainLine: xDomainLine,
562
- tickMarks: axisConfig?.ticks,
563
- offset: axisConfig?.offset,
564
- titlePadding: axisConfig?.titlePadding,
565
- labelPadding: axisConfig?.labelPadding,
566
- labelOverlap: axisConfig?.labelOverlap,
567
- labelFlush: axisConfig?.labelFlush,
568
- tickPosition: xTickPosition,
569
- };
570
- }
571
-
572
- if (scales.y && !dataContext?.skipY && scales.y.channel.axis !== false) {
573
- const axisConfig = scales.y.channel.axis;
574
- const isContinuousY =
575
- scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
576
-
577
- // Target count from pixels-per-tick when we have a continuous y-axis.
578
- const yTargetCount = isContinuousY
579
- ? targetTickCount(chartArea.height, yDensity, 'y')
580
- : undefined;
581
-
582
- // Use explicit tick values from axis config if provided
583
- let allTicks: AxisTick[];
584
- if (axisConfig?.values) {
585
- allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
586
- } else if (
587
- dataContext?.precomputedYTicks &&
588
- dataContext.precomputedYTicks.length > 0 &&
589
- isContinuousY
590
- ) {
591
- // Pinned ticks from the layout plan -- use the same resolution path as
592
- // explicit user values so the labels match exactly what the gutter was
593
- // measured for.
594
- allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
595
- } else if (!isContinuousY) {
596
- const yFieldName = dataContext?.encoding.y?.field;
597
- const yLabelField = axisConfig?.labelField;
598
- allTicks = categoricalTicks(
599
- scales.y,
600
- yDensity,
601
- 'vertical',
602
- yFieldName && yLabelField && dataContext
603
- ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
604
- : undefined,
605
- );
606
- } else {
607
- allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
608
- }
609
-
610
- // Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
611
- // When tickCount is set, we still thin if D3 overshot the requested count
612
- // (common with log scales where ticks(4) can return 26 values).
613
- const shouldThinY =
614
- scales.y.type !== 'band' && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
615
- let ticks: AxisTick[];
616
- if (!shouldThinY) {
617
- ticks = allTicks;
618
- } else if (isContinuousY) {
619
- // Continuous y-axis: re-request ticks at a lower count on overlap so
620
- // spacing stays uniform and we don't collapse to min/max.
621
- ticks = fitContinuousTicks(
622
- scales.y,
623
- allTicks,
624
- yTargetCount ?? allTicks.length,
625
- fontSize,
626
- fontWeight,
627
- chartArea.height,
628
- 'vertical',
629
- measureText,
630
- );
631
- } else {
632
- ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical');
633
- }
634
-
635
- // Gridlines match the tick set so every gridline has a label.
636
- const gridlines: Gridline[] = ticks.map((t) => ({
637
- position: t.position,
638
- major: true,
639
- }));
640
-
641
- const axisTitle = axisConfig?.title;
642
- const tickAngle = axisConfig?.labelAngle;
643
- const yLabelColor = axisConfig?.labelColor;
644
- // Editorial line/area y-axes default to inline tick labels above their
645
- // gridlines. Other mark types keep the classic gutter placement. Right-side
646
- // y-axes (dual-axis) always use gutter. Uses the shared predicate so the
647
- // margin-reservation paths (plan.ts, dimensions.ts) agree with this; here
648
- // we pass the RESOLVED scale continuity since the scale is already built.
649
- const isContinuousYAxis =
650
- scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
651
- const yTickPosition: 'inline' | 'gutter' = yTickPositionIsInline(
652
- dataContext?.encoding.y,
653
- dataContext?.markType ?? '',
654
- isContinuousYAxis,
655
- )
656
- ? 'inline'
657
- : 'gutter';
658
-
659
- // Inline mode hides the axis line and tick marks by default; the gridlines
660
- // themselves serve as the visual axis. Explicit user overrides win.
661
- const yDomainLine = axisConfig?.domain ?? (yTickPosition === 'inline' ? false : undefined);
662
- const yTickMarks = axisConfig?.ticks ?? (yTickPosition === 'inline' ? false : undefined);
663
-
664
- result.y = {
665
- ticks,
666
- // Y-axis gridlines are shown by default (standard editorial practice).
667
- // Not for heatmaps: the cells tile the plot, so a gridline can only draw
668
- // *through* them. The cell grid already is the grid. Explicit `grid: true`
669
- // still wins.
670
- gridlines: (axisConfig?.grid ?? dataContext?.markType !== 'rect') ? gridlines : [],
671
- label: axisTitle,
672
- labelStyle: yLabelColor ? { ...axisLabelStyle, fill: yLabelColor } : axisLabelStyle,
673
- tickLabelStyle: yLabelColor ? { ...tickLabelStyle, fill: yLabelColor } : tickLabelStyle,
674
- tickAngle,
675
- start: { x: chartArea.x, y: chartArea.y },
676
- end: { x: chartArea.x, y: chartArea.y + chartArea.height },
677
- orient: axisConfig?.orient,
678
- domainLine: yDomainLine,
679
- tickMarks: yTickMarks,
680
- offset: axisConfig?.offset,
681
- titlePadding: axisConfig?.titlePadding,
682
- labelPadding: axisConfig?.labelPadding,
683
- labelOverlap: axisConfig?.labelOverlap,
684
- labelFlush: axisConfig?.labelFlush,
685
- tickPosition: yTickPosition,
686
- };
687
- }
688
-
689
- const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
690
-
691
- if (result.x) {
692
- const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
693
-
694
- // Single source of truth for the x-axis vertical extent (rotated or flat).
695
- // computeXAxisExtentFromLabels applies the correct rotated-extent math
696
- // (textWidth*|sin θ| + lineHeight*|cos θ|), so the reserved bottom margin
697
- // matches what the labels actually occupy.
698
- result.x.extent = computeXAxisExtentFromLabels({
699
- labels: result.x.ticks.map((t) => t.label),
700
- tickAngle: result.x.tickAngle,
701
- hasTitle: !!result.x.label,
702
- tickFontSize: result.x.tickLabelStyle.fontSize,
703
- tickFontWeight: result.x.tickLabelStyle.fontWeight,
704
- xAxisHeight: theme.spacing.xAxisHeight,
705
- measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText),
706
- });
707
-
708
- if (result.x.label) {
709
- const titleBand = isRotated ? X_AXIS_TITLE_BAND_ROTATED : X_AXIS_TITLE_BAND;
710
- const tickBand = result.x.extent - titleBand;
711
- const titleY = isRotated ? tickBand + 14 : tickBand + 9;
712
- result.x.titlePosition = {
713
- x: chartArea.x + chartArea.width / 2,
714
- y: chartArea.y + chartArea.height + titleY,
715
- };
716
- }
717
- }
718
-
719
- if (result.y?.label && result.y.labelStyle) {
720
- const isRight = result.y.orient === 'right';
721
- if (isRight) {
722
- const titleOff = getAxisTitleOffset(totalWidth);
723
- result.y.titlePosition = {
724
- x: chartArea.x + chartArea.width + titleOff,
725
- y: chartArea.y + chartArea.height / 2,
726
- angle: 90,
727
- };
728
- } else {
729
- const maxTickLabelWidth = result.y.ticks.reduce((max, t) => {
730
- const w = estimateTextWidth(
731
- t.label,
732
- result.y!.tickLabelStyle.fontSize,
733
- result.y!.tickLabelStyle.fontWeight ?? 400,
734
- );
735
- return Math.max(max, w);
736
- }, 0);
737
- // Inline y-ticks live inside the plot (no left gutter), so the title
738
- // clears only the chart edge — pass the inline flag so the offset drops
739
- // the tick-label width and the viewport floor. Omitting it reserved a
740
- // gutter-sized offset that pushed the rotated title off the left edge of
741
- // the container on narrow/large-font charts.
742
- const titleOff = axisTitleOffset(
743
- maxTickLabelWidth,
744
- result.y.labelStyle.fontSize,
745
- totalWidth,
746
- result.y.tickPosition === 'inline',
747
- );
748
- result.y.titlePosition = {
749
- x: chartArea.x - titleOff,
750
- y: chartArea.y + chartArea.height / 2,
751
- angle: -90,
752
- };
753
- }
754
- }
755
-
756
- return result;
757
- }