@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20

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 (271) hide show
  1. package/dist/index.d.ts +140 -2
  2. package/dist/index.js +1403 -210
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -721
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -370
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -539
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -626
  133. package/src/compile/size-scale.ts +0 -139
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1450
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -747
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. 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
- }