basalt-ui 1.30.2 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/MIGRATING.md +256 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +5 -5
  6. package/configs/oxlint-plugin.js +371 -4
  7. package/configs/oxlint-plugin.test.ts +327 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +47 -82
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +138 -145
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +68 -111
  47. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  48. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  49. package/dist/charts/kinds/band-chrome.js +71 -0
  50. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  51. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  52. package/dist/charts/kinds/donut-layout.js +52 -0
  53. package/dist/charts/layout/auto-margin.d.ts +7 -2
  54. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  55. package/dist/charts/layout/auto-margin.js +12 -3
  56. package/dist/charts/layout/end-labels.d.ts +64 -0
  57. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  58. package/dist/charts/layout/end-labels.js +76 -0
  59. package/dist/charts/primitives/Axes.d.ts +32 -4
  60. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  61. package/dist/charts/primitives/Axes.js +62 -16
  62. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  63. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  64. package/dist/charts/primitives/CartesianChart.js +264 -145
  65. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  66. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartCard.js +56 -21
  68. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  69. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartFrame.js +116 -34
  71. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  72. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  73. package/dist/charts/primitives/ChartLegend.js +86 -73
  74. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  75. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  76. package/dist/charts/primitives/ChartTooltip.js +20 -4
  77. package/dist/charts/primitives/Crosshair.js +2 -2
  78. package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
  79. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  80. package/dist/charts/primitives/HoverOverlay.js +54 -27
  81. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  82. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  83. package/dist/charts/primitives/chart-card-context.d.ts +33 -0
  84. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  85. package/dist/charts/primitives/chart-card-context.js +18 -0
  86. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  87. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  88. package/dist/charts/primitives/chart-frame-layout.js +75 -34
  89. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  90. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  91. package/dist/charts/primitives/chart-layout.js +175 -0
  92. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  93. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  94. package/dist/charts/primitives/chart-tier.js +42 -13
  95. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  96. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  97. package/dist/charts/primitives/legend-disclosure.js +153 -0
  98. package/dist/charts/series.d.ts +4 -1
  99. package/dist/charts/series.d.ts.map +1 -1
  100. package/dist/charts/utils/ticks.d.ts +27 -8
  101. package/dist/charts/utils/ticks.d.ts.map +1 -1
  102. package/dist/charts/utils/ticks.js +35 -11
  103. package/dist/common/errors.d.ts +11 -1
  104. package/dist/common/errors.d.ts.map +1 -1
  105. package/dist/common/errors.js +16 -2
  106. package/dist/common/hit-floor.d.ts +10 -0
  107. package/dist/common/hit-floor.d.ts.map +1 -0
  108. package/dist/common/hit-floor.js +6 -0
  109. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  110. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  111. package/dist/common/use-measured-widths.d.ts +37 -0
  112. package/dist/common/use-measured-widths.d.ts.map +1 -0
  113. package/dist/common/use-measured-widths.js +51 -0
  114. package/dist/common/use-media-query.d.ts +6 -0
  115. package/dist/common/use-media-query.d.ts.map +1 -0
  116. package/dist/common/use-media-query.js +40 -0
  117. package/dist/common/validate.d.ts +9 -0
  118. package/dist/common/validate.d.ts.map +1 -1
  119. package/dist/common/validate.js +9 -4
  120. package/dist/content/article-layout.d.ts +4 -2
  121. package/dist/content/article-layout.d.ts.map +1 -1
  122. package/dist/content/article-layout.js +43 -3
  123. package/dist/content/article-layout.module.css +45 -4
  124. package/dist/content/article-layout.module.css.d.ts +3 -0
  125. package/dist/content/code-block.d.ts.map +1 -1
  126. package/dist/content/code-block.js +40 -2
  127. package/dist/content/code-block.module.css +32 -2
  128. package/dist/content/prose.module.css +13 -8
  129. package/dist/controls/actions.d.ts +29 -6
  130. package/dist/controls/actions.d.ts.map +1 -1
  131. package/dist/controls/actions.js +158 -27
  132. package/dist/controls/actions.module.css +14 -1
  133. package/dist/controls/actions.module.css.d.ts +1 -0
  134. package/dist/controls/controls.module.css +13 -31
  135. package/dist/controls/enum-filter.js +2 -2
  136. package/dist/controls/filter-context.d.ts +10 -0
  137. package/dist/controls/filter-context.d.ts.map +1 -1
  138. package/dist/controls/filter-context.js +2 -0
  139. package/dist/controls/filter-pill.d.ts.map +1 -1
  140. package/dist/controls/filter-pill.js +1 -0
  141. package/dist/controls/index.d.ts +1 -1
  142. package/dist/controls/index.d.ts.map +1 -1
  143. package/dist/controls/index.js +0 -2
  144. package/dist/controls/multi-select-filter.js +2 -2
  145. package/dist/controls/sync-button.module.css +2 -1
  146. package/dist/dashboard/section.d.ts.map +1 -1
  147. package/dist/dashboard/section.js +1 -0
  148. package/dist/dashboard/section.module.css +1 -26
  149. package/dist/dashboard/stat-card.d.ts +1 -1
  150. package/dist/dashboard/stat-card.d.ts.map +1 -1
  151. package/dist/dashboard/stat-card.js +4 -1
  152. package/dist/dashboard/stat-card.module.css +6 -9
  153. package/dist/dashboard/stat-group.module.css +33 -66
  154. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  155. package/dist/dashboard/widget-grid.module.css +11 -38
  156. package/dist/dashboard/widget-header.d.ts +14 -4
  157. package/dist/dashboard/widget-header.d.ts.map +1 -1
  158. package/dist/dashboard/widget-header.js +23 -6
  159. package/dist/dashboard/widget-header.module.css +145 -133
  160. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  161. package/dist/data/data-table-state.d.ts +83 -0
  162. package/dist/data/data-table-state.d.ts.map +1 -0
  163. package/dist/data/data-table-state.js +139 -0
  164. package/dist/data/data-table.d.ts +56 -2
  165. package/dist/data/data-table.d.ts.map +1 -1
  166. package/dist/data/data-table.js +436 -336
  167. package/dist/data/data-table.module.css +94 -0
  168. package/dist/data/data-table.module.css.d.ts +11 -0
  169. package/dist/data/virtual-list.d.ts.map +1 -1
  170. package/dist/data/virtual-list.js +10 -0
  171. package/dist/forms/form-layout.d.ts.map +1 -1
  172. package/dist/forms/form-layout.js +3 -3
  173. package/dist/forms/form-layout.module.css +20 -5
  174. package/dist/forms/form-layout.module.css.d.ts +1 -0
  175. package/dist/guard/index.d.ts +8 -2
  176. package/dist/guard/index.d.ts.map +1 -1
  177. package/dist/guard/index.js +68 -2
  178. package/dist/guard/types.d.ts +3 -3
  179. package/dist/guard/types.d.ts.map +1 -1
  180. package/dist/index.d.ts +1 -1
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +3 -1
  183. package/dist/notifications/center.d.ts.map +1 -1
  184. package/dist/provider/index.d.ts +20 -1
  185. package/dist/provider/index.d.ts.map +1 -1
  186. package/dist/provider/index.js +30 -8
  187. package/dist/shell/app-brand.d.ts.map +1 -1
  188. package/dist/shell/app-brand.js +1 -0
  189. package/dist/shell/app-brand.module.css +2 -2
  190. package/dist/shell/app-header.module.css +18 -8
  191. package/dist/shell/app-main.module.css +54 -3
  192. package/dist/shell/app-main.module.css.d.ts +1 -0
  193. package/dist/shell/app-mobile-nav.module.css +16 -4
  194. package/dist/shell/app-sidebar.module.css +4 -6
  195. package/dist/shell/aside-docking.d.ts +31 -0
  196. package/dist/shell/aside-docking.d.ts.map +1 -0
  197. package/dist/shell/aside-docking.js +19 -0
  198. package/dist/shell/index.d.ts +1 -0
  199. package/dist/shell/index.d.ts.map +1 -1
  200. package/dist/shell/index.js +56 -16
  201. package/dist/shell/mobile-nav-model.d.ts +1 -1
  202. package/dist/shell/page-aside.d.ts +22 -3
  203. package/dist/shell/page-aside.d.ts.map +1 -1
  204. package/dist/shell/page-aside.js +27 -13
  205. package/dist/shell/page-bar.d.ts.map +1 -1
  206. package/dist/shell/page-bar.js +10 -5
  207. package/dist/shell/page-bar.module.css +10 -34
  208. package/dist/shell/use-breakpoint.d.ts +3 -4
  209. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  210. package/dist/shell/use-breakpoint.js +3 -25
  211. package/dist/shell/use-size-class.d.ts +6 -0
  212. package/dist/shell/use-size-class.d.ts.map +1 -0
  213. package/dist/shell/use-size-class.js +19 -0
  214. package/dist/state/persisted-or-local.d.ts +4 -1
  215. package/dist/state/persisted-or-local.d.ts.map +1 -1
  216. package/dist/state/persisted-or-local.js +4 -3
  217. package/dist/state/persisted.d.ts +10 -0
  218. package/dist/state/persisted.d.ts.map +1 -1
  219. package/dist/state/persisted.js +44 -14
  220. package/dist/styles.css +41 -0
  221. package/dist/surfaces.d.ts +4 -4
  222. package/dist/surfaces.d.ts.map +1 -1
  223. package/dist/surfaces.js +11 -3
  224. package/dist/theme/controls.module.css +7 -0
  225. package/dist/theme/ctl-theme.d.ts +1 -0
  226. package/dist/theme/ctl-theme.d.ts.map +1 -1
  227. package/dist/theme/ctl-theme.js +12 -4
  228. package/dist/theme/floating.module.css +37 -0
  229. package/dist/theme/floating.module.css.d.ts +3 -0
  230. package/dist/theme/index.d.ts +2 -2
  231. package/dist/theme/index.d.ts.map +1 -1
  232. package/dist/theme/index.js +34 -5
  233. package/dist/theme/nav-link.module.css +7 -0
  234. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  235. package/dist/tokens/index.d.ts +7 -16
  236. package/dist/tokens/index.d.ts.map +1 -1
  237. package/dist/tokens/index.js +23 -28
  238. package/dist/tokens/palette.d.ts +5 -27
  239. package/dist/tokens/palette.d.ts.map +1 -1
  240. package/dist/tokens/palette.js +8 -30
  241. package/dist/tokens/size-classes.d.ts +40 -0
  242. package/dist/tokens/size-classes.d.ts.map +1 -0
  243. package/dist/tokens/size-classes.js +29 -0
  244. package/dist/tokens.css +8 -4
  245. package/llms.txt +3 -3
  246. package/package.json +18 -17
  247. package/src/agent-chat/composer.tsx +74 -47
  248. package/src/agent-chat/thread-workspace.tsx +8 -11
  249. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  250. package/src/charts/cursor/touch-pin.ts +138 -0
  251. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  252. package/src/charts/hooks/use-hysteresis.ts +26 -0
  253. package/src/charts/hooks/useBandPlot.ts +90 -27
  254. package/src/charts/hooks/useChartCursor.ts +211 -26
  255. package/src/charts/index.ts +0 -8
  256. package/src/charts/kinds/BandStrip.tsx +25 -61
  257. package/src/charts/kinds/Donut.tsx +153 -62
  258. package/src/charts/kinds/DualPanel.tsx +21 -26
  259. package/src/charts/kinds/Heatmap.tsx +61 -22
  260. package/src/charts/kinds/MirroredBars.tsx +23 -65
  261. package/src/charts/kinds/band-chrome.tsx +91 -0
  262. package/src/charts/kinds/donut-layout.ts +82 -0
  263. package/src/charts/layout/auto-margin.ts +24 -5
  264. package/src/charts/layout/end-labels.ts +135 -0
  265. package/src/charts/primitives/Axes.tsx +112 -21
  266. package/src/charts/primitives/CartesianChart.tsx +282 -111
  267. package/src/charts/primitives/ChartCard.tsx +119 -48
  268. package/src/charts/primitives/ChartFrame.tsx +204 -60
  269. package/src/charts/primitives/ChartLegend.tsx +115 -78
  270. package/src/charts/primitives/ChartTooltip.tsx +24 -3
  271. package/src/charts/primitives/Crosshair.tsx +2 -2
  272. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  273. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  274. package/src/charts/primitives/HoverOverlay.tsx +62 -20
  275. package/src/charts/primitives/chart-card-context.ts +46 -0
  276. package/src/charts/primitives/chart-frame-layout.ts +207 -182
  277. package/src/charts/primitives/chart-layout.ts +318 -0
  278. package/src/charts/primitives/chart-tier.tsx +66 -21
  279. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  280. package/src/charts/series.ts +4 -1
  281. package/src/charts/utils/ticks.ts +95 -19
  282. package/src/common/errors.ts +25 -1
  283. package/src/common/hit-floor.ts +9 -0
  284. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  285. package/src/common/use-measured-widths.ts +88 -0
  286. package/src/common/use-media-query.ts +56 -0
  287. package/src/common/validate.ts +17 -4
  288. package/src/content/article-layout.module.css +45 -4
  289. package/src/content/article-layout.module.css.d.ts +3 -0
  290. package/src/content/article-layout.tsx +60 -5
  291. package/src/content/code-block.module.css +32 -2
  292. package/src/content/code-block.tsx +47 -3
  293. package/src/content/prose.module.css +13 -8
  294. package/src/controls/actions.module.css +14 -1
  295. package/src/controls/actions.module.css.d.ts +1 -0
  296. package/src/controls/actions.tsx +223 -75
  297. package/src/controls/controls.module.css +13 -31
  298. package/src/controls/enum-filter.tsx +2 -2
  299. package/src/controls/filter-context.tsx +11 -0
  300. package/src/controls/filter-pill.tsx +1 -0
  301. package/src/controls/index.ts +0 -1
  302. package/src/controls/multi-select-filter.tsx +2 -2
  303. package/src/controls/sync-button.module.css +2 -1
  304. package/src/dashboard/section.module.css +1 -26
  305. package/src/dashboard/section.tsx +1 -0
  306. package/src/dashboard/stat-card.module.css +6 -9
  307. package/src/dashboard/stat-card.tsx +6 -3
  308. package/src/dashboard/stat-group.module.css +33 -66
  309. package/src/dashboard/widget-grid.module.css +11 -38
  310. package/src/dashboard/widget-grid.tsx +7 -6
  311. package/src/dashboard/widget-header.module.css +145 -133
  312. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  313. package/src/dashboard/widget-header.tsx +45 -11
  314. package/src/data/data-table-state.ts +218 -0
  315. package/src/data/data-table.module.css +94 -0
  316. package/src/data/data-table.module.css.d.ts +11 -0
  317. package/src/data/data-table.tsx +547 -299
  318. package/src/data/virtual-list.tsx +31 -0
  319. package/src/forms/form-layout.module.css +20 -5
  320. package/src/forms/form-layout.module.css.d.ts +1 -0
  321. package/src/forms/form-layout.tsx +12 -10
  322. package/src/guard/index.ts +188 -3
  323. package/src/guard/types.ts +3 -2
  324. package/src/index.ts +2 -0
  325. package/src/notifications/center.tsx +3 -0
  326. package/src/provider/index.tsx +60 -7
  327. package/src/shell/app-brand.module.css +2 -2
  328. package/src/shell/app-brand.tsx +1 -0
  329. package/src/shell/app-header.module.css +18 -8
  330. package/src/shell/app-main.module.css +54 -3
  331. package/src/shell/app-main.module.css.d.ts +1 -0
  332. package/src/shell/app-mobile-nav.module.css +16 -4
  333. package/src/shell/app-sidebar.module.css +4 -6
  334. package/src/shell/aside-docking.ts +54 -0
  335. package/src/shell/index.tsx +178 -129
  336. package/src/shell/mobile-nav-model.ts +1 -1
  337. package/src/shell/page-aside.tsx +70 -21
  338. package/src/shell/page-bar.module.css +10 -34
  339. package/src/shell/page-bar.tsx +10 -5
  340. package/src/shell/use-breakpoint.ts +9 -45
  341. package/src/shell/use-size-class.ts +31 -0
  342. package/src/state/persisted-or-local.ts +10 -4
  343. package/src/state/persisted.ts +76 -17
  344. package/src/styles.css +41 -0
  345. package/src/surfaces.ts +9 -1
  346. package/src/theme/controls.module.css +7 -0
  347. package/src/theme/ctl-theme.tsx +15 -3
  348. package/src/theme/floating.module.css +37 -0
  349. package/src/theme/floating.module.css.d.ts +3 -0
  350. package/src/theme/index.ts +46 -8
  351. package/src/theme/nav-link.module.css +7 -0
  352. package/src/theme-lab/derive-controls.tsx +2 -0
  353. package/src/tokens/index.ts +20 -30
  354. package/src/tokens/palette.ts +6 -28
  355. package/src/tokens/size-classes.ts +46 -0
  356. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  357. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -1,6 +1,6 @@
1
1
  import { Group } from '@visx/group'
2
- import type { PointerEvent, ReactNode } from 'react'
3
- import { memo, useMemo, useState } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { memo, useMemo } from 'react'
4
4
  import { assertRequiredProps } from '../../common/validate'
5
5
  import type { BasaltProps } from '../../common/props'
6
6
  import {
@@ -11,7 +11,8 @@ import {
11
11
  } from '../primitives/ChartTooltip'
12
12
  import { ChartFrame } from '../primitives/ChartFrame'
13
13
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
14
- import { useChartTierMetrics } from '../primitives/chart-tier'
14
+ import { useChartMetrics } from '../primitives/chart-tier'
15
+ import { useDiscreteCursor } from '../hooks/use-discrete-cursor'
15
16
  import type { ChartState } from '../primitives/ChartPending'
16
17
  import { maxTextWidth } from '../utils/measure-text'
17
18
  import { thinLabels, xLabelPxFor } from '../utils/ticks'
@@ -20,8 +21,9 @@ import { VX, alpha } from '../../tokens'
20
21
  /** A single resolved heatmap cell — the unit the tooltip and hover operate on. */
21
22
  type HeatmapCell = { row: string; col: string; value: number }
22
23
 
23
- /** A hovered cell plus the viewport anchor `ChartTooltipFloat` positions against. */
24
- type HeatmapTip = HeatmapCell & { anchor: { x: number; y: number } }
24
+ /** One grid position, data or not — `useDiscreteCursor`'s target unit. `value` is `undefined` for
25
+ * an (row, col) pair with no data, which stays a keyboard stop but never shows a tooltip. */
26
+ type CellTarget = { row: string; col: string; value: number | undefined }
25
27
 
26
28
  export type HeatmapProps<T> = BasaltProps & {
27
29
  data: T[]
@@ -115,6 +117,13 @@ const LEGEND_LABEL_H = 16
115
117
  * right gutter reserves half the widest column label, so no two painted labels overlap and the
116
118
  * last one cannot clip.
117
119
  *
120
+ * Tap-to-pin, keyboard stepping and focus over the grid come from `useDiscreteCursor`
121
+ * (`docs/CHARTS-SPEC.md` §4, Tooltip), the discrete counterpart of `HoverOverlay`. Its targets are
122
+ * the FULL row-major grid (data cells and empty ones alike) so Up/Down can jump one row via a fixed
123
+ * `columns` stride; only a cell WITH data renders a tooltip or takes pointer input, matching today's
124
+ * hover behavior — an empty cell is still a keyboard stop (so the grid reads as one rectangle), it
125
+ * just carries no readout.
126
+ *
118
127
  * Composes `ChartFrame` purely for measuring (`height`/`aspectRatio`/`fill`, the same three
119
128
  * sizing modes every other kind exposes) — `legend={false}` and an empty `series` opt out of
120
129
  * `ChartFrame`'s own derived legend, since Heatmap already ships its own gradient strip.
@@ -177,9 +186,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
177
186
  // The row/column labels and the gradient legend's endpoints read like axis ticks, so they take
178
187
  // the tier's tick font for the same reason `Axes.tsx` does — a 360px heatmap that painted its
179
188
  // categories at the desktop size would be the one chart still ignoring §8.
180
- const { axisFont } = useChartTierMetrics()
181
-
182
- const [tip, setTip] = useState<HeatmapTip | null>(null)
189
+ const { axisFont } = useChartMetrics()
183
190
 
184
191
  const rows = useMemo(() => rowsProp ?? firstSeen(data, getRow), [rowsProp, data, getRow])
185
192
  const cols = useMemo(() => colsProp ?? firstSeen(data, getCol), [colsProp, data, getCol])
@@ -196,6 +203,36 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
196
203
  return { lookup: map, max: m }
197
204
  }, [data, getRow, getCol, getValue])
198
205
 
206
+ // The FULL row-major grid (gaps included) so `columns: cols.length` gives `useDiscreteCursor` a
207
+ // real row stride for Up/Down — a targets list built only from cells WITH data would shift that
208
+ // stride the moment a row has a gap.
209
+ const cellTargets = useMemo<CellTarget[]>(() => {
210
+ const out: CellTarget[] = []
211
+ for (const row of rows) {
212
+ for (const col of cols) out.push({ row, col, value: lookup.get(cellKey(row, col)) })
213
+ }
214
+ return out
215
+ }, [rows, cols, lookup])
216
+ const cursor = useDiscreteCursor<CellTarget>({
217
+ targets: cellTargets,
218
+ getKey: (c) => cellKey(c.row, c.col),
219
+ columns: cols.length,
220
+ ariaLabel: 'Heatmap cells — use arrow keys to navigate, Escape to dismiss',
221
+ })
222
+ // Resolved once here (not inline in the JSX below) so `value` narrows to `number` — a keyboard
223
+ // stop on an empty cell has a `tip` but no readout to show.
224
+ const activeTip =
225
+ cursor.tip !== null && cursor.tip.target.value !== undefined
226
+ ? {
227
+ anchor: cursor.tip.anchor,
228
+ cell: {
229
+ row: cursor.tip.target.row,
230
+ col: cursor.tip.target.col,
231
+ value: cursor.tip.target.value,
232
+ },
233
+ }
234
+ : null
235
+
199
236
  const rowLabels = useMemo(() => rows.map(rowLabel), [rows, rowLabel])
200
237
  const colLabels = useMemo(() => cols.map(colLabel), [cols, colLabel])
201
238
 
@@ -223,23 +260,27 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
223
260
  [rowLabels, cellH, axisFont],
224
261
  )
225
262
 
226
- const show = (row: string, col: string, value: number, event: PointerEvent<SVGRectElement>) => {
227
- setTip({ row, col, value, anchor: { x: event.clientX, y: event.clientY } })
228
- }
229
- const hide = () => setTip(null)
230
-
231
263
  return (
232
264
  <div style={{ position: 'relative' }}>
233
- <svg width={width} height={height}>
265
+ <svg width={width} height={height} {...cursor.hostProps}>
234
266
  <Group left={PAD_LEFT} top={PAD_TOP}>
235
267
  {rows.flatMap((row, ri) =>
236
268
  cols.map((col, ci) => {
237
269
  const value = lookup.get(cellKey(row, col))
238
270
  const has = value !== undefined
239
271
  const cellFill = has ? alpha(color, intensity(value, max)) : alpha(VX.neutral, 0.04)
272
+ const target: CellTarget = { row, col, value }
273
+ const isActive = cursor.tip?.target.row === row && cursor.tip?.target.col === col
240
274
  return (
241
275
  <rect
242
276
  key={cellKey(row, col)}
277
+ id={cursor.optionId(target)}
278
+ // An SVG `<rect>` has no `<option>` counterpart — `useDiscreteCursor`'s
279
+ // listbox/option pair (see its JSDoc) is a role over an arbitrary shape.
280
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
281
+ role="option"
282
+ aria-selected={isActive}
283
+ aria-label={`${rowLabels[ri]}: ${colLabels[ci]}: ${value !== undefined ? formatValue(value) : 'no data'}`}
243
284
  x={ci * cellW + cellGap / 2}
244
285
  y={ri * cellH + cellGap / 2}
245
286
  width={Math.max(0, cellW - cellGap)}
@@ -247,9 +288,7 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
247
288
  rx={cellRadius}
248
289
  fill={cellFill}
249
290
  style={{ cursor: has ? 'pointer' : 'default' }}
250
- onPointerMove={(e) => has && show(row, col, value, e)}
251
- onPointerLeave={hide}
252
- onPointerCancel={hide}
291
+ {...(has ? cursor.pointerProps(target) : {})}
253
292
  />
254
293
  )
255
294
  }),
@@ -328,17 +367,17 @@ function HeatmapPlot<T>(props: HeatmapPlotProps<T>) {
328
367
  )}
329
368
  </svg>
330
369
 
331
- {tip !== null && (
332
- <ChartTooltipFloat anchor={tip.anchor}>
333
- <TooltipHeader date={rowLabel(tip.row)} label={colLabel(tip.col)} />
370
+ {activeTip !== null && (
371
+ <ChartTooltipFloat anchor={activeTip.anchor}>
372
+ <TooltipHeader date={rowLabel(activeTip.cell.row)} label={colLabel(activeTip.cell.col)} />
334
373
  <TooltipBody>
335
374
  <TooltipRow
336
375
  color={alpha(color, 0.9)}
337
376
  shape="bar"
338
377
  label="Value"
339
- value={formatValue(tip.value)}
378
+ value={formatValue(activeTip.cell.value)}
340
379
  />
341
- {renderTooltip?.(tip)}
380
+ {renderTooltip?.(activeTip.cell)}
342
381
  </TooltipBody>
343
382
  </ChartTooltipFloat>
344
383
  )}
@@ -10,22 +10,17 @@ import type { CursorResolution } from '../cursor/resolve'
10
10
  import { useBandPlot } from '../hooks/useBandPlot'
11
11
  import type { BandFold, BandTooltipConfig } from '../hooks/useBandPlot'
12
12
  import { probeAxisLabels } from '../layout/auto-margin'
13
- import { AxisBottomDate, AxisLeftNumeric } from '../primitives/Axes'
13
+ import { AxisLeftNumeric } from '../primitives/Axes'
14
14
  import { ChartFrame, resolveLegend } from '../primitives/ChartFrame'
15
15
  import type { ResponsiveChartHeight } from '../primitives/ChartFrame'
16
- import {
17
- ChartTooltipFloat,
18
- TooltipBody,
19
- TooltipHeader,
20
- TooltipRow,
21
- } from '../primitives/ChartTooltip'
16
+ import { TooltipRows } from '../primitives/ChartTooltip'
22
17
  import { Crosshair } from '../primitives/Crosshair'
23
18
  import { HatchPattern, hatchFill, hatchSizeFor } from '../primitives/HatchPattern'
24
- import { HoverOverlay } from '../primitives/HoverOverlay'
25
19
  import type { ChartState } from '../primitives/ChartPending'
26
20
  import { deriveTooltipRows } from '../series'
27
21
  import type { ChartLegendConfig, ChartSeries } from '../series'
28
22
  import { fmtAxisDate } from '../utils/format'
23
+ import { BandAxisOverlay, BandTooltip, clampFraction } from './band-chrome'
29
24
 
30
25
  /** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
31
26
  export type MirroredBarPane = {
@@ -242,7 +237,7 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
242
237
  ...(tooltip !== undefined && { tooltip }),
243
238
  })
244
239
 
245
- const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
240
+ const { bands, margin, plotWidth, point, crosshairX, step, bandWidth } = band
246
241
 
247
242
  // ── Pass 2: the pane rects, now that the plot height is known ───────────────────────────────
248
243
  const barBand = Math.max(plot.height - margin.top - margin.bottom, 2)
@@ -346,8 +341,6 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
346
341
 
347
342
  if (plotWidth <= 0 || bands.length === 0) return null
348
343
 
349
- const cfg = tooltip === false ? undefined : tooltip
350
- const badge = cfg?.label === undefined || point === null ? null : cfg.label(point)
351
344
  // Each pane's row falls back to ITS OWN axis formatter, never the other pane's — the two panes
352
345
  // are in different units by construction (an explicit per-series `formatValue` still wins).
353
346
  const visibleSeries = series
@@ -362,7 +355,13 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
362
355
 
363
356
  return (
364
357
  <>
365
- <svg ref={band.svgRef} width={plot.width} height={plot.height}>
358
+ <svg
359
+ ref={band.svgRef}
360
+ width={plot.width}
361
+ height={plot.height}
362
+ // `touch-action` on the plot `<svg>`: Chrome ignores it on the overlay `<rect>` (`R2C-2`).
363
+ style={{ touchAction: 'pan-y' }}
364
+ >
366
365
  <defs>
367
366
  <HatchPattern
368
367
  id={hatchId}
@@ -383,19 +382,21 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
383
382
 
384
383
  {/* One axis per pane, each in its own scale's units — a single shared axis would be
385
384
  wrong for at least one of them whenever the two domains differ, which is always. */}
386
- {upVisible && (
385
+ {upVisible && band.yPlacement !== 'none' && (
387
386
  <AxisLeftNumeric
388
387
  scale={upAxisScale}
389
388
  numTicks={upTicks}
390
389
  tickFormat={(v) => up.format(Number(v))}
390
+ inside={band.yPlacement === 'inside'}
391
391
  />
392
392
  )}
393
- {downVisible && (
393
+ {downVisible && band.yPlacement !== 'none' && (
394
394
  <Group top={baseline}>
395
395
  <AxisLeftNumeric
396
396
  scale={downScale}
397
397
  numTicks={downTicks}
398
398
  tickFormat={(v) => down.format(Number(v))}
399
+ inside={band.yPlacement === 'inside'}
399
400
  />
400
401
  </Group>
401
402
  )}
@@ -411,66 +412,23 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
411
412
 
412
413
  {crosshairX !== null && <Crosshair x={crosshairX} top={0} bottom={barBand} />}
413
414
 
414
- <AxisBottomDate
415
- top={barBand}
416
- scale={scale}
417
- tickValues={band.tickValues}
418
- tickFormat={(v) => formatX(String(v))}
419
- />
420
-
421
- <HoverOverlay
422
- width={plotWidth}
415
+ <BandAxisOverlay
416
+ band={band}
423
417
  height={barBand}
424
- onMove={cursor.onPointerMove}
425
- onLeave={cursor.onPointerLeave}
426
- onKeyDown={cursor.onKeyDown}
427
- onBlur={cursor.onBlur}
428
- valueMax={Math.max(bands.length - 1, 0)}
429
- {...(point !== null && {
430
- valueNow: bands.indexOf(point),
431
- valueText: formatX(getX(point)),
432
- })}
433
- {...(ariaLabel !== undefined && { ariaLabel })}
418
+ getX={getX}
419
+ formatX={formatX}
420
+ ariaLabel={ariaLabel}
434
421
  />
435
422
  </Group>
436
423
  </svg>
437
424
 
438
- {band.showTooltip && point !== null && (
439
- <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
440
- <TooltipHeader
441
- date={getX(point)}
442
- {...(cfg?.formatHeader !== undefined && {
443
- format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
444
- })}
445
- {...(badge !== null && { label: badge.text, labelColor: badge.color })}
446
- />
447
- <TooltipBody>
448
- {cfg?.prependRows?.(point, { hidden })}
449
- {rows.map((row) => (
450
- <TooltipRow
451
- key={row.key}
452
- color={row.color}
453
- label={row.label}
454
- value={row.value}
455
- shape={row.shape}
456
- dashed={row.dashed}
457
- {...(row.strokeWidth !== undefined && { strokeWidth: row.strokeWidth })}
458
- />
459
- ))}
460
- {cfg?.extraRows?.(point, { hidden })}
461
- </TooltipBody>
462
- </ChartTooltipFloat>
463
- )}
425
+ <BandTooltip band={band} tooltip={tooltip} getX={getX} hidden={hidden}>
426
+ <TooltipRows rows={rows} />
427
+ </BandTooltip>
464
428
  </>
465
429
  )
466
430
  }
467
431
 
468
- /** A 0..1 share, non-finite included — see `BandStrip`'s identical guard. */
469
- function clampFraction(value: number | undefined): number {
470
- if (value === undefined || !Number.isFinite(value)) return 0
471
- return Math.min(Math.max(value, 0), 1)
472
- }
473
-
474
432
  /** A drawable value, or null. See the call site for why NaN is not drawable. */
475
433
  function finiteOrNull(v: number | null | undefined): number | null {
476
434
  return v === null || v === undefined || Number.isNaN(v) ? null : v
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { BandPlot, BandTooltipConfig } from '../hooks/useBandPlot'
3
+ import { AxisBottomDate } from '../primitives/Axes'
4
+ import { ChartTooltipFloat, TooltipBody, TooltipHeader } from '../primitives/ChartTooltip'
5
+ import { cursorSliderProps, HoverOverlay } from '../primitives/HoverOverlay'
6
+
7
+ /**
8
+ * The chrome `BandStrip` and `MirroredBars` share around their own marks — the slot axis, the
9
+ * keyboard/pointer overlay and the tooltip shell — so the two `useBandPlot` kinds cannot drift in
10
+ * any of them. Internal; neither is exported from a barrel.
11
+ *
12
+ * theme-allow-file hand-rolled-plot — the slot axis and overlay of the two declared banded
13
+ * exceptions (`BandStrip`, `MirroredBars`), which carry the same waiver for the same reason.
14
+ */
15
+
16
+ /** A 0..1 share. Non-finite (a 0/0 fold count) reads as "nothing is absent" — the conservative
17
+ * end, since the alternative is a band that does not render at all. */
18
+ export function clampFraction(value: number | undefined): number {
19
+ if (value === undefined || !Number.isFinite(value)) return 0
20
+ return Math.min(Math.max(value, 0), 1)
21
+ }
22
+
23
+ /** The slot axis under a `height`-tall band row, plus the hover/keyboard overlay over it. */
24
+ export function BandAxisOverlay<T>({
25
+ band,
26
+ height,
27
+ getX,
28
+ formatX,
29
+ ariaLabel,
30
+ }: {
31
+ band: BandPlot<T>
32
+ height: number
33
+ getX: (d: T) => string
34
+ formatX: (key: string) => string
35
+ ariaLabel: string | undefined
36
+ }) {
37
+ return (
38
+ <>
39
+ <AxisBottomDate
40
+ top={height}
41
+ scale={band.scale}
42
+ tickValues={band.tickValues}
43
+ tickFormat={(v) => formatX(String(v))}
44
+ anchorTerminals={band.xAnchorTerminals}
45
+ {...(band.xWrapWidth !== undefined && { wrapWidth: band.xWrapWidth })}
46
+ />
47
+ <HoverOverlay
48
+ width={band.plotWidth}
49
+ height={height}
50
+ {...cursorSliderProps({ cursor: band.cursor, data: band.bands, getX, formatX, ariaLabel })}
51
+ />
52
+ </>
53
+ )
54
+ }
55
+
56
+ /** The tooltip shell: header (+ badge), the consumer's prepended rows, the kind's own derived
57
+ * `children` rows, the consumer's extra rows. Renders nothing off-hover or with `tooltip: false`. */
58
+ export function BandTooltip<T>({
59
+ band,
60
+ tooltip,
61
+ getX,
62
+ hidden,
63
+ children,
64
+ }: {
65
+ band: BandPlot<T>
66
+ tooltip: BandTooltipConfig<T> | false | undefined
67
+ getX: (d: T) => string
68
+ hidden: ReadonlySet<string>
69
+ children: ReactNode
70
+ }) {
71
+ const { point } = band
72
+ if (!band.showTooltip || point === null) return null
73
+ const cfg = tooltip === false ? undefined : tooltip
74
+ const badge = cfg?.label === undefined ? null : cfg.label(point)
75
+ return (
76
+ <ChartTooltipFloat anchor={band.tooltipAnchor} ariaLive={band.ariaLive}>
77
+ <TooltipHeader
78
+ date={getX(point)}
79
+ {...(cfg?.formatHeader !== undefined && {
80
+ format: (key: string) => cfg.formatHeader?.(key, point) ?? key,
81
+ })}
82
+ {...(badge !== null && { label: badge.text, labelColor: badge.color })}
83
+ />
84
+ <TooltipBody>
85
+ {cfg?.prependRows?.(point, { hidden })}
86
+ {children}
87
+ {cfg?.extraRows?.(point, { hidden })}
88
+ </TooltipBody>
89
+ </ChartTooltipFloat>
90
+ )
91
+ }
@@ -0,0 +1,82 @@
1
+ import type { ContainerClass } from '../../tokens/size-classes'
2
+
3
+ /** Aspect (frame W/H) above which the legend moves beside the ring. */
4
+ export const SIDE_LEGEND_ASPECT = 1.5
5
+ /** Aspect a side layout must fall below before it stacks again — the gap stops a legend whose own
6
+ * placement changes the measured height from flipping the layout back and forth. */
7
+ export const STACK_LEGEND_ASPECT = 1.35
8
+ /** Most slices kept before the rest fold into "Other" (so 7 or more slices fold). */
9
+ export const DONUT_MAX_SLICES = 6
10
+ export const OTHER_KEY = '__other'
11
+ /** Side layout: the ring's diameter never exceeds this share of the frame width. */
12
+ export const SIDE_RING_MAX_WIDTH_SHARE = 0.55
13
+ /** Space kept between the ring's outer edge and the plot box. */
14
+ export const RING_PADDING = 4
15
+
16
+ export type DonutLayout = { side: boolean }
17
+
18
+ /** Side legend only on a wide-enough frame (`micro`/`compact` always stack under the ring).
19
+ * `wasSide` applies hysteresis: an already-side layout stays side until W/H falls below
20
+ * {@link STACK_LEGEND_ASPECT}. */
21
+ export function resolveDonutLayout(input: {
22
+ frameW: number
23
+ frameH: number
24
+ containerClass: ContainerClass
25
+ wasSide?: boolean
26
+ }): DonutLayout {
27
+ const { frameW, frameH, containerClass, wasSide = false } = input
28
+ const roomy = containerClass !== 'micro' && containerClass !== 'compact'
29
+ if (!roomy || frameH <= 0) return { side: false }
30
+ const aspect = frameW / frameH
31
+ return { side: wasSide ? aspect >= STACK_LEGEND_ASPECT : aspect > SIDE_LEGEND_ASPECT }
32
+ }
33
+
34
+ /** Ring outer radius: never negative and never wider than the plot; the side layout also caps the
35
+ * diameter at {@link SIDE_RING_MAX_WIDTH_SHARE} of the frame width. */
36
+ export function resolveDonutRadius(input: {
37
+ plotW: number
38
+ plotH: number
39
+ frameW: number
40
+ side: boolean
41
+ }): number {
42
+ const { plotW, plotH, frameW, side } = input
43
+ const diameter = side
44
+ ? Math.min(plotW, plotH, SIDE_RING_MAX_WIDTH_SHARE * frameW)
45
+ : Math.min(plotW, plotH)
46
+ return Math.max(0, diameter / 2 - RING_PADDING)
47
+ }
48
+
49
+ /** Share of `total` as a whole percent; an empty total is 0%. */
50
+ export function sharePercent(value: number, total: number): number {
51
+ return total > 0 ? Math.round((value / total) * 100) : 0
52
+ }
53
+
54
+ /** A slice value a ring can draw: non-finite or negative counts as 0. */
55
+ const safeValue = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0)
56
+
57
+ /** The key for the synthesized "Other" bucket — {@link OTHER_KEY}, prefixed until no input key uses it. */
58
+ export function resolveOtherKey(keys: readonly string[]): string {
59
+ const taken = new Set(keys)
60
+ let key = OTHER_KEY
61
+ while (taken.has(key)) key = `_${key}`
62
+ return key
63
+ }
64
+
65
+ /** Keeps the top {@link DONUT_MAX_SLICES} by value (in original order) and sums the rest into one
66
+ * trailing "Other" entry. Non-finite and negative values count as 0. */
67
+ export function foldOther<T extends { key: string; value: number }>(
68
+ slices: readonly T[],
69
+ ): { kept: T[]; other: number | null } {
70
+ const safe = slices.map((s) => ({ ...s, value: safeValue(s.value) }))
71
+ if (safe.length <= DONUT_MAX_SLICES) return { kept: safe, other: null }
72
+ const top = new Set(
73
+ safe
74
+ .toSorted((a, b) => b.value - a.value)
75
+ .slice(0, DONUT_MAX_SLICES)
76
+ .map((s) => s.key),
77
+ )
78
+ return {
79
+ kept: safe.filter((s) => top.has(s.key)),
80
+ other: safe.reduce((sum, s) => (top.has(s.key) ? sum : sum + s.value), 0),
81
+ }
82
+ }
@@ -124,15 +124,20 @@ export type AutoMarginInput = {
124
124
  /** Degrees of counter-clockwise rotation applied to the x tick labels. Default 0. */
125
125
  rotate?: number
126
126
  /** Tick-label font size. Default `VX.axisFont`. A phone-tier chart passes its own smaller size
127
- * (`chartTierMetrics().axisFont`) — the measured label must be the painted label. */
127
+ * (`chartMetrics().axisFont`) — the measured label must be the painted label. */
128
128
  fontPx?: number
129
129
  /**
130
130
  * Per-side FLOORS. Default `VX.margin`. The phone tier passes its tightened set
131
- * (`chartTierMetrics().margin`) so a static 44px left gutter does not spend an eighth of a 360px
131
+ * (`chartMetrics().margin`) so a static 44px left gutter does not spend an eighth of a 360px
132
132
  * chart on a three-character label. The law itself does not move: a side may still only ever
133
133
  * grow past its floor.
134
134
  */
135
135
  floor?: ChartMargin
136
+ /** X labels anchored inward at the terminals (`AxisBottomDate anchorTerminals`): the last label
137
+ * no longer centres on the right edge, so the half-label right reservation is dropped. */
138
+ anchorTerminals?: boolean
139
+ /** Lines the tallest (wrapped) x label takes. Default 1. */
140
+ bottomLines?: number
136
141
  /** Explicit per-side overrides — applied LAST, so an escape hatch always wins. */
137
142
  override?: Partial<ChartMargin>
138
143
  }
@@ -157,7 +162,16 @@ export type AutoMarginInput = {
157
162
  */
158
163
  export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
159
164
  const fontPx = input.fontPx ?? VX.axisFont
160
- const { left = [], right = [], bottom = [], rotate = 0, floor = VX.margin, override } = input
165
+ const {
166
+ left = [],
167
+ right = [],
168
+ bottom = [],
169
+ rotate = 0,
170
+ floor = VX.margin,
171
+ anchorTerminals = false,
172
+ bottomLines = 1,
173
+ override,
174
+ } = input
161
175
 
162
176
  const leftWidth = maxTextWidth(left, fontPx)
163
177
  const rightWidth = maxTextWidth(right, fontPx)
@@ -166,7 +180,8 @@ export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
166
180
  // Measured EXACTLY as painted: through the same `dx`/`dy` `primitives/Axes.tsx` nudges a rotated
167
181
  // label by, and from the baseline the axis anchors it at.
168
182
  const rotated = rotatedLabelExtents({ labelWidth: bottomWidth, fontPx, rotate })
169
- const bottomExtent = rotate === 0 ? lineHeight(fontPx) + AXIS_TICK_GAP : rotated.below
183
+ const bottomExtent =
184
+ rotate === 0 ? lineHeight(fontPx) * Math.max(bottomLines, 1) + AXIS_TICK_GAP : rotated.below
170
185
 
171
186
  return {
172
187
  top: override?.top ?? floor.top,
@@ -175,7 +190,11 @@ export function autoMargin(input: AutoMarginInput = {}): ChartMargin {
175
190
  Math.ceil(
176
191
  Math.max(
177
192
  floor.right,
178
- right.length > 0 ? rightWidth + AXIS_TICK_GAP : rotate === 0 ? bottomWidth / 2 : 0,
193
+ right.length > 0
194
+ ? rightWidth + AXIS_TICK_GAP
195
+ : rotate === 0 && !anchorTerminals
196
+ ? bottomWidth / 2
197
+ : 0,
179
198
  ),
180
199
  ),
181
200
  bottom: override?.bottom ?? Math.ceil(Math.max(floor.bottom, bottomExtent)),