@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,152 +0,0 @@
1
- /**
2
- * Auto-rotation and thinning policy for band-scale x-axis tick labels.
3
- *
4
- * Band categories are data: every bar should keep its label. The policy
5
- * steepens the label angle until everything fits — flat, then -45°, then -90°
6
- * (vertical) — and only thins (uniform every-Nth) when even the steepest
7
- * usable angle cannot fit every label, which takes dozens of categories at
8
- * tiny steps.
9
- *
10
- * The collision geometry differs by orientation, and getting it right is what
11
- * makes thinning almost never necessary:
12
- *
13
- * - FLAT labels share a baseline, so adjacent labels collide when the widest
14
- * label's width exceeds the anchor spacing.
15
- * - ROTATED labels are PARALLEL RIBBONS: every label leans at the same angle
16
- * from its own anchor, so adjacent labels are parallel segments. They clear
17
- * each other when EITHER the perpendicular separation between the ribbons
18
- * (`spacing * |sin θ|`) exceeds one line height, OR the offset along the
19
- * text direction (`spacing * |cos θ|`) exceeds the label length — finite
20
- * segments slide past each other end-to-end at shallow angles. Earlier
21
- * revisions modeled rotated labels as 1-D horizontal spans, which reported
22
- * phantom collisions for long labels (dropping "2025" beside
23
- * "2026 (to wk 17)") even though the rendered diagonals never touch.
24
- *
25
- * The layout planner and computeAxes must agree on these decisions: the
26
- * planner reserves the bottom margin for the rotated label footprint, and
27
- * computeAxes emits the same angle. Sharing this module keeps the reserved
28
- * space matched to the rendered labels (a mismatch spilled rotated ticks into
29
- * the source line).
30
- */
31
-
32
- import { TICK_LINE_HEIGHT_FACTOR, X_AXIS_ROTATED_EXTENT_CAP } from '@opendata-ai/openchart-core';
33
-
34
- /** Band fraction above which flat labels are considered too wide to fit. */
35
- const BAND_FIT_FRACTION = 0.85;
36
-
37
- /** Diagonal angle tried first when flat labels don't fit. */
38
- const AUTO_ROTATE_ANGLE = -45;
39
-
40
- /** Vertical angle used when diagonal ribbons would touch. */
41
- const STEEP_ROTATE_ANGLE = -90;
42
-
43
- /** Minimum visible gap between adjacent label ribbons, in px. */
44
- const LABEL_GAP = 4;
45
-
46
- /**
47
- * Perpendicular separation between adjacent rotated label ribbons.
48
- * Labels anchor at points `spacing` apart; leaning at `angleDeg` makes them
49
- * parallel lines whose perpendicular distance is `spacing * |sin(angle)|`.
50
- */
51
- function ribbonSeparation(spacing: number, angleDeg: number): number {
52
- return spacing * Math.abs(Math.sin((angleDeg * Math.PI) / 180));
53
- }
54
-
55
- /**
56
- * Offset between adjacent labels measured along the text direction.
57
- * Finite labels clear each other end-to-end when this exceeds the label
58
- * length, even if the ribbons are closer than a line height — the escape
59
- * route that matters at shallow angles.
60
- */
61
- function alongTextClearance(spacing: number, angleDeg: number): number {
62
- return spacing * Math.abs(Math.cos((angleDeg * Math.PI) / 180));
63
- }
64
-
65
- /** Whether every label fits at the given rotation without touching. */
66
- function rotatedLabelsFit(
67
- spacing: number,
68
- angleDeg: number,
69
- fontSize: number,
70
- maxLabelWidth: number,
71
- ): boolean {
72
- return (
73
- ribbonSeparation(spacing, angleDeg) >= fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP ||
74
- alongTextClearance(spacing, angleDeg) >= maxLabelWidth + LABEL_GAP
75
- );
76
- }
77
-
78
- /**
79
- * Decide the effective x tick angle for a band scale: the shallowest angle in
80
- * the ladder (flat → -45° → -90°) at which adjacent labels don't touch.
81
- *
82
- * The flat trigger compares the widest label against the bandwidth (does the
83
- * flat label fit its band?) — the long-standing rule, kept for stability.
84
- * The rotated steps use the parallel-ribbon fit test above.
85
- *
86
- * -90° projects the full label length vertically, so labels longer than the
87
- * reserved-extent cap would clip into the source line. For those the ladder
88
- * stays at -45° (smaller vertical footprint) and lets the stride safety net
89
- * thin the ribbons instead.
90
- *
91
- * @param explicitAngle - User-specified labelAngle (wins if defined).
92
- * @param maxLabelWidth - Widest tick label width in pixels.
93
- * @param bandwidth - Band width in pixels (excludes inner padding).
94
- * @param labelCount - Number of category labels (rotation needs >= 2).
95
- * @param spacing - Distance between the closest adjacent DRAWN tick anchors.
96
- * This is the band step when every category gets a tick, but a multiple of
97
- * it when an explicit tickCount capped the tick set upstream.
98
- * @param fontSize - Tick label font size (ribbon thickness term).
99
- * @returns The explicit angle, `-45`, `-90`, or `undefined` (no rotation).
100
- */
101
- export function resolveBandTickAngle(
102
- explicitAngle: number | undefined,
103
- maxLabelWidth: number,
104
- bandwidth: number,
105
- labelCount: number,
106
- spacing: number,
107
- fontSize: number,
108
- ): number | undefined {
109
- if (explicitAngle !== undefined) return explicitAngle;
110
- if (labelCount < 2) return undefined;
111
- if (maxLabelWidth <= bandwidth * BAND_FIT_FRACTION) return undefined;
112
- if (rotatedLabelsFit(spacing, AUTO_ROTATE_ANGLE, fontSize, maxLabelWidth)) {
113
- return AUTO_ROTATE_ANGLE;
114
- }
115
- return maxLabelWidth <= X_AXIS_ROTATED_EXTENT_CAP ? STEEP_ROTATE_ANGLE : AUTO_ROTATE_ANGLE;
116
- }
117
-
118
- /**
119
- * Uniform every-Nth retention stride for band tick labels — the safety net
120
- * for when even the resolved angle cannot fit every label (dozens of
121
- * categories at tiny steps, an explicit shallow labelAngle on a crowded axis,
122
- * or labels too long for the -90° escalation). Returns 1 (keep every label)
123
- * whenever labels fit.
124
- *
125
- * Callers keep indices counting back from the LAST label
126
- * (`(lastIdx - i) % stride === 0`) so the most recent category — usually the
127
- * most editorially important — is always labeled.
128
- *
129
- * Flat labels collide along their shared baseline, so the stride is driven by
130
- * the widest label's width. Rotated labels escape by either route — ribbon
131
- * separation or along-text clearance — and both scale linearly with the
132
- * stride, so the cheaper one wins.
133
- */
134
- export function bandLabelStride(
135
- maxLabelWidth: number,
136
- angleDeg: number | undefined,
137
- fontSize: number,
138
- spacing: number,
139
- ): number {
140
- if (spacing <= 0) return 1;
141
- if (angleDeg === undefined || angleDeg === 0) {
142
- return Math.max(1, Math.ceil((maxLabelWidth + LABEL_GAP) / spacing));
143
- }
144
- const separation = ribbonSeparation(spacing, angleDeg);
145
- const clearance = alongTextClearance(spacing, angleDeg);
146
- const required = fontSize * TICK_LINE_HEIGHT_FACTOR + LABEL_GAP;
147
- const byRibbon = separation > 0 ? Math.ceil(required / separation) : Number.POSITIVE_INFINITY;
148
- const byLength =
149
- clearance > 0 ? Math.ceil((maxLabelWidth + LABEL_GAP) / clearance) : Number.POSITIVE_INFINITY;
150
- const stride = Math.min(byRibbon, byLength);
151
- return Number.isFinite(stride) ? Math.max(1, stride) : 1;
152
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * Tick label overlap detection and density thinning.
3
- *
4
- * Horizontal orientation (x-axis): checks label width against adjacent
5
- * positions. Vertical orientation (y-axis): checks font-based label height
6
- * against adjacent positions, ignoring text width so wide numeric labels
7
- * don't trigger aggressive thinning.
8
- */
9
-
10
- import type { AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
11
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
-
13
- /**
14
- * Minimum gap between adjacent tick labels as a multiple of font size.
15
- * At the default 11px axis font, this yields ~5-6px of breathing room.
16
- * Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
17
- * with a small number of categories that clearly have room for all labels.
18
- */
19
- const MIN_TICK_GAP_FACTOR = 0.5;
20
-
21
- /** Always show at least this many ticks, even if they overlap. */
22
- const MIN_TICK_COUNT = 2;
23
-
24
- /** Measure a single label's width using real measurement or heuristic fallback. */
25
- export function measureLabel(
26
- text: string,
27
- fontSize: number,
28
- fontWeight: number,
29
- measureText?: MeasureTextFn,
30
- ): number {
31
- return measureText
32
- ? measureText(text, fontSize, fontWeight).width
33
- : estimateTextWidth(text, fontSize, fontWeight);
34
- }
35
-
36
- /** Check whether any adjacent tick labels overlap along the axis direction. */
37
- export function ticksOverlap(
38
- ticks: AxisTick[],
39
- fontSize: number,
40
- fontWeight: number,
41
- measureText?: MeasureTextFn,
42
- orientation: 'horizontal' | 'vertical' = 'horizontal',
43
- ): boolean {
44
- if (ticks.length < 2) return false;
45
- const minGap = fontSize * MIN_TICK_GAP_FACTOR;
46
-
47
- if (orientation === 'vertical') {
48
- // Y-axis: labels are stacked vertically. Check if vertical extent
49
- // (based on font height) overlaps between adjacent ticks.
50
- // Positions decrease going up in SVG coords, so sort ascending.
51
- const sorted = [...ticks].sort((a, b) => a.position - b.position);
52
- const labelHeight = fontSize * 1.2; // lineHeight
53
- for (let i = 0; i < sorted.length - 1; i++) {
54
- const aBottom = sorted[i].position + labelHeight / 2;
55
- const bTop = sorted[i + 1].position - labelHeight / 2;
56
- if (aBottom + minGap > bTop) return true;
57
- }
58
- return false;
59
- }
60
-
61
- for (let i = 0; i < ticks.length - 1; i++) {
62
- const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
63
- const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
64
- const aRight = ticks[i].position + aWidth / 2;
65
- const bLeft = ticks[i + 1].position - bWidth / 2;
66
- if (aRight + minGap > bLeft) return true;
67
- }
68
- return false;
69
- }
70
-
71
- /**
72
- * Thin a tick array by removing every other tick until labels don't overlap.
73
- * Always keeps first and last tick. O(log n) iterations max.
74
- * Returns the original array if no thinning is needed.
75
- */
76
- export function thinTicksUntilFit(
77
- ticks: AxisTick[],
78
- fontSize: number,
79
- fontWeight: number,
80
- measureText?: MeasureTextFn,
81
- orientation: 'horizontal' | 'vertical' = 'horizontal',
82
- ): AxisTick[] {
83
- if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
84
-
85
- let current = ticks;
86
- while (current.length > MIN_TICK_COUNT) {
87
- // Keep first, last, and every other tick in between
88
- const thinned = [current[0]];
89
- for (let i = 2; i < current.length - 1; i += 2) {
90
- thinned.push(current[i]);
91
- }
92
- if (current.length > 1) thinned.push(current[current.length - 1]);
93
- current = thinned;
94
-
95
- if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
96
- }
97
- return current;
98
- }
@@ -1,409 +0,0 @@
1
- /**
2
- * Tick generation: produces raw AxisTick[] from a resolved scale.
3
- *
4
- * Pure with respect to layout dimensions — positions come from the scale,
5
- * not from the chart area. Density thinning lives in ./thinning.ts.
6
- */
7
-
8
- import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
9
- import {
10
- buildTemporalFormatter,
11
- formatDate,
12
- resolveNumberFormatter,
13
- } from '@opendata-ai/openchart-core';
14
- import type { ScaleBand } from 'd3-scale';
15
- import { resolveFieldFormatter } from '../../format/field-format';
16
- import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../scales';
17
-
18
- /**
19
- * Target pixels-per-tick for continuous axes. The target count is computed as
20
- * `axisLength / PX_PER_TICK[density]` and then clamped into the count range.
21
- *
22
- * Rationale:
23
- * - Observable Plot uses 50px/tick on y, 80px/tick on x as its baseline.
24
- * - ONS editorial guidance recommends 6-10 y-gridlines at desktop, 3-6 mobile.
25
- * - The Economist / FT / NYT typically show 4-6 labeled y-ticks on finished charts.
26
- *
27
- * Y gets tighter spacing than X because vertical label extent is the font height
28
- * (~14px) versus horizontal label extent which can be 60-100px for dates/abbreviated
29
- * numbers. X uses wider spacing so labels don't need aggressive rotation or thinning.
30
- *
31
- * "full" is the publication-ready default; "reduced" and "minimal" step down as the
32
- * responsive breakpoint system shifts to smaller containers.
33
- *
34
- * Y full is set to 40px/tick (tighter than Observable Plot's 50) because chart areas
35
- * are measured after chrome subtraction. A 400px container with title+subtitle leaves
36
- * ~270px of chart area; 55px/tick would only produce 4 ticks. 40px/tick reaches 5-6
37
- * on typical chart areas (150-300px) and the overlap check acts as a safety net.
38
- *
39
- * @internal — these are tuning constants, not part of the configuration API.
40
- * Consumers should configure tick density through `axis.tickCount` on the spec.
41
- */
42
- const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
43
- full: 40,
44
- reduced: 70,
45
- minimal: 120,
46
- };
47
-
48
- const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
49
- full: 110,
50
- reduced: 160,
51
- minimal: 220,
52
- };
53
-
54
- /**
55
- * Count clamps per density. The lower bound keeps a chart from collapsing to
56
- * a single label on very short axes; the upper bound stops tall/wide charts
57
- * from growing a ladder of ticks past the point of editorial usefulness.
58
- *
59
- * The upper bound is deliberately <=6 for y on standard tiers: D3's
60
- * `scale.ticks(n)` only produces "nice" step sizes (1, 2, 5 × 10^k), and for
61
- * many domains the jump from step=10 to step=5 happens between count 6 and 7.
62
- * Requesting 7 can give back 10, which reads as visually dense. Capping at 6
63
- * keeps the editorial ~5 gridline average regardless of domain shape.
64
- *
65
- * @internal — see PX_PER_TICK comment.
66
- */
67
- const Y_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
68
- full: [4, 6],
69
- reduced: [3, 5],
70
- minimal: [2, 3],
71
- };
72
-
73
- const X_TICK_COUNT_RANGE: Record<AxisLabelDensity, [number, number]> = {
74
- full: [3, 6],
75
- reduced: [3, 5],
76
- minimal: [2, 3],
77
- };
78
-
79
- /**
80
- * Fallback tick counts for callers that don't have an axis length handy
81
- * (categorical band-scale thinning uses this as a cap, and `continuousTicks`
82
- * uses it when no `targetCount` is provided).
83
- *
84
- * @internal
85
- */
86
- const TICK_COUNTS: Record<AxisLabelDensity, number> = {
87
- full: 7,
88
- reduced: 5,
89
- minimal: 3,
90
- };
91
-
92
- /**
93
- * Compute a target tick count for a continuous axis from its pixel length and
94
- * density tier. Uses the Plot-style pixels-per-tick heuristic, then clamps
95
- * into the density's count range.
96
- */
97
- export function targetTickCount(
98
- axisLength: number,
99
- density: AxisLabelDensity,
100
- orientation: 'x' | 'y',
101
- ): number {
102
- const pxPerTick = orientation === 'y' ? Y_PX_PER_TICK[density] : X_PX_PER_TICK[density];
103
- const [min, max] =
104
- orientation === 'y' ? Y_TICK_COUNT_RANGE[density] : X_TICK_COUNT_RANGE[density];
105
- const raw = Math.round(axisLength / pxPerTick);
106
- return Math.max(min, Math.min(max, raw));
107
- }
108
-
109
- /** Set of continuous numeric scale types that should format as numbers. */
110
- const NUMERIC_SCALE_TYPES = new Set([
111
- 'linear',
112
- 'log',
113
- 'pow',
114
- 'sqrt',
115
- 'symlog',
116
- 'quantile',
117
- 'quantize',
118
- 'threshold',
119
- ]);
120
-
121
- /** Set of temporal scale types. */
122
- const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
123
-
124
- interface TickContext {
125
- step?: number;
126
- maxAbsTick?: number;
127
- }
128
-
129
- /** Format a tick value based on the scale type. */
130
- function formatTickLabel(
131
- value: unknown,
132
- resolvedScale: ResolvedScale,
133
- compact = false,
134
- tickContext?: TickContext,
135
- ): string {
136
- const axisConfig = resolvedScale.channel.axis || undefined;
137
- const formatStr = axisConfig?.format;
138
- const suffix = axisConfig?.labelSuffix ?? '';
139
-
140
- if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
141
- // Explicit user axis.format always wins, compact or not.
142
- const temporalFmt = buildTemporalFormatter(formatStr);
143
- if (temporalFmt) return temporalFmt(value as Date) + suffix;
144
- const useUtc = resolvedScale.type === 'utc';
145
- return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
146
- }
147
-
148
- if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
149
- const num = value as number;
150
- if (formatStr) {
151
- const ctx = {
152
- ...resolvedScale.formatContext,
153
- step: tickContext?.step,
154
- stepReference: tickContext?.maxAbsTick,
155
- };
156
- const fmt = resolveNumberFormatter(formatStr, ctx);
157
- if (fmt) return fmt(num) + suffix;
158
- }
159
- const fmt = resolveFieldFormatter({
160
- channelFormat: resolvedScale.channel.format,
161
- context: {
162
- ...resolvedScale.formatContext,
163
- step: tickContext?.step,
164
- stepReference: tickContext?.maxAbsTick,
165
- },
166
- });
167
- return fmt(num) + suffix;
168
- }
169
-
170
- return String(value) + suffix;
171
- }
172
-
173
- function computeTickContext(ticks: unknown[]): TickContext | undefined {
174
- if (ticks.length < 2) return undefined;
175
- const nums = ticks.filter((v) => typeof v === 'number' && Number.isFinite(v)) as number[];
176
- if (nums.length < 2) return undefined;
177
-
178
- const firstGap = Math.abs(nums[1] - nums[0]);
179
- if (firstGap === 0) return undefined;
180
-
181
- let uniform = true;
182
- for (let i = 2; i < nums.length; i++) {
183
- const gap = Math.abs(nums[i] - nums[i - 1]);
184
- const relDiff = Math.abs(gap - firstGap) / Math.max(firstGap, 1e-15);
185
- if (relDiff > 1e-9) {
186
- uniform = false;
187
- break;
188
- }
189
- }
190
-
191
- if (!uniform) return undefined;
192
-
193
- return {
194
- step: firstGap,
195
- maxAbsTick: Math.max(Math.abs(nums[0]), Math.abs(nums[nums.length - 1])),
196
- };
197
- }
198
-
199
- /**
200
- * Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog).
201
- *
202
- * `targetCount` lets callers that know the axis pixel length pass a
203
- * density-appropriate count (see `targetTickCount`). When omitted, falls back
204
- * to the coarse `TICK_COUNTS` tier, which is only used by tests and callers
205
- * that don't have an axis length.
206
- */
207
- export function continuousTicks(
208
- resolvedScale: ResolvedScale,
209
- density: AxisLabelDensity,
210
- targetCount?: number,
211
- ): AxisTick[] {
212
- const scale = resolvedScale.scale as D3ContinuousScale;
213
-
214
- // Discretizing scales (quantile, quantize, threshold) don't have .ticks().
215
- // Use their domain thresholds as ticks instead.
216
- if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
217
- const domain = scale.domain() as unknown[];
218
- return domain.map((value: unknown) => ({
219
- value,
220
- position: (scale as D3ContinuousScale)(value as number & Date) as number,
221
- label: formatTickLabel(value, resolvedScale),
222
- }));
223
- }
224
-
225
- const axCfg = resolvedScale.channel.axis || undefined;
226
- const count = axCfg?.tickCount ?? targetCount ?? TICK_COUNTS[density];
227
- return buildContinuousTicks(resolvedScale, count);
228
- }
229
-
230
- /**
231
- * Build positioned, labeled ticks for a continuous scale at an exact count.
232
- * Exposed so callers that need to re-request ticks at a lower count (for
233
- * overlap-driven density adaptation) can regenerate without manual pruning.
234
- * D3's `scale.ticks(n)` always returns evenly-spaced round values, so
235
- * requesting a smaller `n` never produces squished neighbors — unlike
236
- * "keep first+last, drop middle" pruning which can stack the last tick
237
- * next to an endpoint and cascade to 2 ticks.
238
- *
239
- * `compact` switches temporal labels to the compact format table (bare '%b'
240
- * months etc.); numeric and string labels ignore it.
241
- */
242
- export function buildContinuousTicks(
243
- resolvedScale: ResolvedScale,
244
- count: number,
245
- compact = false,
246
- ): AxisTick[] {
247
- const scale = resolvedScale.scale as D3ContinuousScale;
248
- if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
249
- return continuousTicks(resolvedScale, 'full');
250
- }
251
- const raw: unknown[] = scale.ticks(count);
252
-
253
- // D3 log scales ignore the count hint and return ticks at every sub-power
254
- // position (e.g. 5, 6, 7, 8, 9, 10, 20, 30... for a domain of [5, 25000]).
255
- // Filter down to powers of the base only when the raw set overshoots.
256
- let ticks = raw;
257
- if (resolvedScale.type === 'log' && raw.length > count) {
258
- const base = resolvedScale.channel.scale?.base ?? 10;
259
- const logBase = Math.log(base);
260
- const powered = raw.filter((v) => {
261
- const n = v as number;
262
- if (n <= 0) return false;
263
- const exp = Math.log(n) / logBase;
264
- return Math.abs(exp - Math.round(exp)) < 1e-9;
265
- });
266
- // Only use the filtered set if it has at least 2 ticks; otherwise fall back
267
- // to raw ticks. This handles domains like [5, 9] (no powers of 10 at all) or
268
- // [5, 50] (only one power: 10) where filtering would leave too few meaningful ticks.
269
- if (powered.length >= 2) {
270
- ticks = powered;
271
- }
272
- }
273
-
274
- const tickCtx = computeTickContext(ticks);
275
-
276
- return ticks.map((value: unknown) => ({
277
- value,
278
- position: scale(value as number & Date) as number,
279
- label: formatTickLabel(value, resolvedScale, compact, tickCtx),
280
- }));
281
- }
282
-
283
- /** True if this scale supports regenerating ticks at an arbitrary count. */
284
- export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
285
- const scale = resolvedScale.scale as D3ContinuousScale;
286
- return 'ticks' in scale && typeof scale.ticks === 'function';
287
- }
288
-
289
- /**
290
- * Generate ticks for a band/point/ordinal scale.
291
- *
292
- * For horizontal x-axis band scales, thinning is geometry-aware: if
293
- * `bandwidth` and `fontSize`/`fontWeight` are provided, labels are only
294
- * thinned when the estimated label footprint (accounting for `labelAngle`)
295
- * actually exceeds the bandwidth. When labels are rotated, their horizontal
296
- * footprint shrinks by |cos(angle)|, so far fewer need to be removed.
297
- * Falls back to a density-count cap when geometry info is unavailable.
298
- */
299
- export function categoricalTicks(
300
- resolvedScale: ResolvedScale,
301
- density: AxisLabelDensity,
302
- orientation: 'horizontal' | 'vertical' = 'horizontal',
303
- subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
304
- ): AxisTick[] {
305
- const scale = resolvedScale.scale as D3CategoricalScale;
306
- const domain: string[] = scale.domain();
307
- const catAxisCfg = resolvedScale.channel.axis || undefined;
308
- const explicitTickCount = catAxisCfg?.tickCount;
309
-
310
- let selectedValues = domain;
311
-
312
- if (resolvedScale.type === 'band' && orientation === 'horizontal') {
313
- // Horizontal band scales delegate thinning to the caller (computeAxes)
314
- // which knows the effective label angle after auto-rotation. Only apply
315
- // an explicit tickCount cap here; the angle ladder + stride safety net
316
- // (see ./rotation) handle collisions downstream.
317
- if (explicitTickCount && domain.length > explicitTickCount) {
318
- const step = Math.ceil(domain.length / explicitTickCount);
319
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
320
- }
321
- } else if (resolvedScale.type !== 'band') {
322
- // Point/ordinal scales: thin by density count
323
- const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
324
- if (domain.length > maxTicks) {
325
- const step = Math.ceil(domain.length / maxTicks);
326
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
327
- }
328
- }
329
- // vertical band scale (horizontal bar y-axis): always show all labels
330
-
331
- let subtitleMap: Map<string, string> | undefined;
332
- if (subtitleContext) {
333
- const { data, fieldName, labelField } = subtitleContext;
334
- if (data.length > 0) {
335
- subtitleMap = new Map();
336
- for (const row of data) {
337
- const key = String(row[fieldName] ?? '');
338
- if (!subtitleMap.has(key)) {
339
- const val = row[labelField];
340
- if (val != null) {
341
- subtitleMap.set(key, String(val));
342
- }
343
- }
344
- }
345
- }
346
- }
347
-
348
- const ticks = selectedValues.map((value: string) => {
349
- // Band scales: use the center of the band
350
- const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
351
- const pos = bandScale
352
- ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
353
- : ((scale(value) as number | undefined) ?? 0);
354
-
355
- const tick: AxisTick = {
356
- value,
357
- position: pos,
358
- label: value,
359
- };
360
-
361
- if (subtitleMap) {
362
- const subtitle = subtitleMap.get(value);
363
- if (subtitle !== undefined) {
364
- tick.subtitle = subtitle;
365
- }
366
- }
367
-
368
- return tick;
369
- });
370
-
371
- return ticks;
372
- }
373
-
374
- /**
375
- * Resolve explicit tick values from axis config into positioned ticks.
376
- *
377
- * Deliberately never compact: this renders the layout planner's precomputed
378
- * ticks, and planner/renderer gutter agreement holds only because both sides
379
- * format identically. Making this compact without making the planner compact
380
- * recreates the reserved-space divergence bug class behind the 7.9.x saga.
381
- */
382
- export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
383
- const scale = resolvedScale.scale;
384
- const tickCtx = computeTickContext(values);
385
- return values.map((value) => {
386
- let position: number;
387
- if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
388
- const d = value instanceof Date ? value : new Date(String(value));
389
- position = (scale as D3ContinuousScale)(d as number & Date) as number;
390
- } else if (
391
- resolvedScale.type === 'band' ||
392
- resolvedScale.type === 'point' ||
393
- resolvedScale.type === 'ordinal'
394
- ) {
395
- const s = String(value);
396
- const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
397
- position = bandScale
398
- ? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
399
- : ((scale(s as string & number) as number | undefined) ?? 0);
400
- } else {
401
- position = (scale as D3ContinuousScale)(value as number & Date) as number;
402
- }
403
- return {
404
- value,
405
- position,
406
- label: formatTickLabel(value, resolvedScale, false, tickCtx),
407
- };
408
- });
409
- }