basalt-ui 1.30.2 → 1.31.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 (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/MIGRATING.md +172 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -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 +54 -41
  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 +145 -131
  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 +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +214 -177
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +25 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -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)),
@@ -0,0 +1,135 @@
1
+ import type { ContainerClass } from '../../tokens/size-classes'
2
+ import type { ChartSeries } from '../series'
3
+ import { measureText } from '../utils/measure-text'
4
+
5
+ /** Space between the plot edge and an end label's first glyph. */
6
+ export const END_LABEL_GAP = 6
7
+
8
+ /** End labels are for a handful of lines; past this the legend alone names them. */
9
+ export const MAX_END_LABELS = 3
10
+
11
+ /** Line box of one end label, as a multiple of its font size — the collision gap. */
12
+ export const END_LABEL_LINE_HEIGHT = 1.3
13
+
14
+ /** The gutter may take at most this share of the plot width; past it the labels are not drawn. */
15
+ const MAX_GUTTER_SHARE = 0.25
16
+
17
+ /**
18
+ * Greedy collision nudging for direct labels. Each `ys` entry is a label's preferred CENTRE; the
19
+ * result keeps input order and holds every label at least `minGap` from its neighbours, inside
20
+ * `[min, max]`. Sorted by y, each label yields to the one above it, then a backward pass pulls the
21
+ * stack up off the bottom edge. Returns `null` when the labels cannot all fit — the caller draws
22
+ * none (the legend still names every series) rather than a partial or overlapping set.
23
+ */
24
+ export function nudgeLabels(input: {
25
+ ys: readonly number[]
26
+ minGap: number
27
+ min: number
28
+ max: number
29
+ }): number[] | null {
30
+ const { ys, minGap, min, max } = input
31
+ if (ys.length === 0) return []
32
+ if (ys.some((y) => !Number.isFinite(y))) return null
33
+ if ((ys.length - 1) * minGap > max - min) return null
34
+
35
+ const order = ys.map((y, i) => ({ y, i })).toSorted((a, b) => a.y - b.y)
36
+
37
+ let prev = Number.NEGATIVE_INFINITY
38
+ const forward = order.map(({ y, i }) => {
39
+ prev = Math.max(y, min, prev + minGap)
40
+ return { i, y: prev }
41
+ })
42
+
43
+ let next = Number.POSITIVE_INFINITY
44
+ const back = forward.toReversed().map(({ i, y }) => {
45
+ next = Math.min(y, max, next - minGap)
46
+ return { i, y: next }
47
+ })
48
+ if (next < min) return null
49
+
50
+ return back.toSorted((a, b) => a.i - b.i).map((p) => p.y)
51
+ }
52
+
53
+ /**
54
+ * The right gutter the end labels need: the widest label plus the gap. `null` when it would eat
55
+ * more than a quarter of the plot — a label longer than that has no honest place in a gutter.
56
+ */
57
+ export function endLabelGutter(input: {
58
+ labels: readonly string[]
59
+ fontPx: number
60
+ plotWidth: number
61
+ }): number | null {
62
+ const { labels, fontPx, plotWidth } = input
63
+ if (labels.length === 0) return null
64
+ const widest = labels.reduce((max, label) => Math.max(max, measureText(label, fontPx)), 0)
65
+ const gutter = Math.ceil(widest) + END_LABEL_GAP
66
+ return gutter > plotWidth * MAX_GUTTER_SHARE ? null : gutter
67
+ }
68
+
69
+ /** One drawn end label: the series it names and its nudged centre y. */
70
+ export type EndLabel<T> = { series: ChartSeries<T>; y: number }
71
+
72
+ /**
73
+ * Whether and how a chart draws end-of-line labels. Eligible only on a wide container with one y
74
+ * axis, no consumer `margin.right`, no header legend already naming every series for free (R2C-10:
75
+ * end labels REPLACE the legend, they never duplicate it), and 2..{@link MAX_END_LABELS} visible
76
+ * series that are all lines — a single line has its own title doing that job already (the same law
77
+ * that already suppresses a single-entry legend, `ChartFrame.resolveLegend`'s JSDoc). Each label
78
+ * sits at its line's last non-null value, nudged apart; the plan is `null` — no gutter reserved,
79
+ * nothing drawn — when any rule fails, a series has no plottable value, the stack cannot fit, or
80
+ * the gutter would be too wide. The gutter is measured from the label text.
81
+ */
82
+ export function planEndLabels<T>(input: {
83
+ containerClass: ContainerClass
84
+ hasY2: boolean
85
+ hasMarginRight: boolean
86
+ /** A `ChartCard` header slot already named every visible series — end labels would only repeat
87
+ * it, at the cost of a right-hand gutter the header slot cost nothing (R2C-10). */
88
+ hasHeaderLegend: boolean
89
+ visible: readonly ChartSeries<T>[]
90
+ data: readonly T[]
91
+ yScale: (value: number) => number
92
+ yMax: number
93
+ fontPx: number
94
+ /** Plot width before any gutter is taken. */
95
+ plotWidth: number
96
+ }): { gutter: number; labels: EndLabel<T>[] } | null {
97
+ const {
98
+ containerClass,
99
+ hasY2,
100
+ hasMarginRight,
101
+ hasHeaderLegend,
102
+ visible,
103
+ data,
104
+ yScale,
105
+ yMax,
106
+ fontPx,
107
+ plotWidth,
108
+ } = input
109
+ if (containerClass !== 'wide' || hasY2 || hasMarginRight || hasHeaderLegend) return null
110
+ if (visible.length < 2 || visible.length > MAX_END_LABELS) return null
111
+ if (!visible.every((s) => s.mark === 'line')) return null
112
+
113
+ const gutter = endLabelGutter({ labels: visible.map((s) => s.label), fontPx, plotWidth })
114
+ if (gutter === null) return null
115
+
116
+ const placed = visible.flatMap((s) => {
117
+ const last = data.findLast((d) => s.getValue(d) !== null)
118
+ if (last === undefined) return []
119
+ const value = s.getValue(last)
120
+ if (value === null) return []
121
+ const y = yScale(value)
122
+ return Number.isFinite(y) ? [{ series: s, y }] : []
123
+ })
124
+ if (placed.length !== visible.length) return null
125
+
126
+ const lineHeight = Math.ceil(fontPx * END_LABEL_LINE_HEIGHT)
127
+ const ys = nudgeLabels({
128
+ ys: placed.map((p) => p.y),
129
+ minGap: lineHeight,
130
+ min: lineHeight / 2,
131
+ max: yMax - lineHeight / 2,
132
+ })
133
+ if (ys === null) return null
134
+ return { gutter, labels: placed.map((p, i) => ({ series: p.series, y: ys[i] ?? p.y })) }
135
+ }
@@ -1,46 +1,109 @@
1
1
  import { AxisBottom, AxisLeft, AxisRight } from '@visx/axis'
2
- import type { AxisScale, TickFormatter } from '@visx/axis'
2
+ import type { AxisScale, TickFormatter, TickRendererProps } from '@visx/axis'
3
3
  import { VX } from '../../tokens'
4
4
  import { ROTATED_LABEL_OFFSET } from '../layout/auto-margin'
5
5
  import { fmtAxisDate } from '../utils/format'
6
- import { useChartTierMetrics } from './chart-tier'
6
+ import { useChartMetrics } from './chart-tier'
7
7
 
8
8
  /**
9
9
  * Tick label font — mono, per `docs/DESIGN-SPEC.md` §5 ("ticks mono 10.5px faint"). Not a `VX.*`
10
10
  * ref (the token layer is off-limits to font-family additions here) — a plain reference to the
11
- * `--basalt-font-mono` var that `styles.css` already defines.
11
+ * `--basalt-font-mono` var that `styles.css` already defines. Exported so `CartesianChart`'s own
12
+ * end-of-line labels (the same mono tick font, painted outside any `Axis*` primitive here) read it
13
+ * rather than keeping a second, driftable copy of the literal.
12
14
  */
13
- const TICK_FONT_FAMILY = 'var(--basalt-font-mono)'
15
+ export const TICK_FONT_FAMILY = 'var(--basalt-font-mono)'
16
+
17
+ /** Horizontal padding between the tick's gridline and the near edge of its label's halo. */
18
+ const INSIDE_LABEL_PAD_X = 4
19
+ /** Gap kept between the tick's gridline and the near edge of an inside label. */
20
+ const INSIDE_LABEL_GAP = 3
21
+ /** Halo stroke width, px — wide enough to clear a mark drawn directly under the digits. */
22
+ const INSIDE_LABEL_HALO_WIDTH = 3
23
+ /**
24
+ * A tick within this many line-heights of the plot's top edge (y=0) flips its label BELOW the
25
+ * tick line instead of above it — the SVG viewBox is sized exactly to the plot height, so an
26
+ * "above" label at the very top tick would otherwise clip against it.
27
+ */
28
+ const TOP_CLEARANCE_LINES = 1
29
+
30
+ /** Tick label colour. Default `VX.faint`; a dual-axis chart tints it with its series' colour. */
31
+ type TickColor = { tickColor?: string }
32
+ const DEFAULT_TICK_COLOR = VX.faint
33
+
34
+ /**
35
+ * Custom tick renderer for an inside-placed y label: a `paint-order: stroke` halo (the surface
36
+ * colour stroked wide, under the fill) instead of an opaque background chip — the chip covered
37
+ * whatever mark sat under it (P1-5), a halo only pushes the mark's edge outward one stroke-width.
38
+ * Flips to sit BELOW its tick line near the plot's top edge instead of clipping against the SVG's
39
+ * own viewBox.
40
+ */
41
+ function InsideTickLabel({ x, y, formattedValue, fontSize, fill }: TickRendererProps) {
42
+ if (formattedValue === undefined) return null
43
+ const size = typeof fontSize === 'number' ? fontSize : VX.axisFont
44
+ const lineHeight = Math.round(size * 1.3)
45
+ const nearTop = y <= lineHeight * TOP_CLEARANCE_LINES
46
+ const centerY = nearTop
47
+ ? y + INSIDE_LABEL_GAP + lineHeight / 2
48
+ : y - INSIDE_LABEL_GAP - lineHeight / 2
49
+ return (
50
+ <text
51
+ x={x + INSIDE_LABEL_PAD_X}
52
+ y={centerY}
53
+ dominantBaseline="middle"
54
+ textAnchor="start"
55
+ fill={fill}
56
+ stroke={VX.surface.panel}
57
+ strokeWidth={INSIDE_LABEL_HALO_WIDTH}
58
+ paintOrder="stroke"
59
+ fontFamily={TICK_FONT_FAMILY}
60
+ fontSize={size}
61
+ >
62
+ {formattedValue}
63
+ </text>
64
+ )
65
+ }
14
66
 
15
67
  /** Themed left numeric axis — baked-in theme colors + font size. The tick font tracks the ambient
16
68
  * chart tier (`docs/CHARTS-SPEC.md` §8); a caller measuring its own gutter must measure at the
17
- * SAME size (`chartTierMetrics().axisFont` into `autoMargin`'s `fontPx`), or the measured label
69
+ * SAME size (`chartMetrics().axisFont` into `autoMargin`'s `fontPx`), or the measured label
18
70
  * and the painted one stop being the same string's width. */
19
71
  export function AxisLeftNumeric({
20
72
  scale,
21
73
  numTicks = 5,
22
74
  tickFormat,
23
75
  tickValues,
24
- }: {
76
+ inside = false,
77
+ tickColor = DEFAULT_TICK_COLOR,
78
+ }: TickColor & {
25
79
  scale: AxisScale
26
80
  numTicks?: number
27
81
  tickFormat?: TickFormatter<number>
28
82
  /** Exact tick positions (e.g. a compass axis at 0/90/180/270). Overrides `numTicks`. */
29
83
  tickValues?: readonly number[]
84
+ /** Labels drawn over the grid, left-aligned on a surface-coloured background chip (above their
85
+ * line, or below it for a tick within one line-height of the plot's top edge) — the caller
86
+ * reserves no gutter for them (`docs/CHARTS-SPEC.md` §8). */
87
+ inside?: boolean
30
88
  }) {
31
- const { axisFont } = useChartTierMetrics()
89
+ const { axisFont } = useChartMetrics()
32
90
  return (
33
91
  <AxisLeft
34
92
  scale={scale}
35
93
  numTicks={numTicks}
36
94
  {...(tickFormat !== undefined && { tickFormat })}
37
95
  {...(tickValues !== undefined && { tickValues: [...tickValues] })}
38
- tickLabelProps={{
39
- fill: VX.faint,
40
- fontFamily: TICK_FONT_FAMILY,
41
- fontSize: axisFont,
42
- dx: -4,
43
- }}
96
+ {...(inside && { tickLength: 0, hideTicks: true, tickComponent: InsideTickLabel })}
97
+ tickLabelProps={
98
+ inside
99
+ ? { fill: tickColor, fontSize: axisFont }
100
+ : {
101
+ fill: tickColor,
102
+ fontFamily: TICK_FONT_FAMILY,
103
+ fontSize: axisFont,
104
+ dx: -4,
105
+ }
106
+ }
44
107
  stroke={VX.surface.border}
45
108
  tickStroke={VX.surface.border}
46
109
  />
@@ -54,7 +117,8 @@ export function AxisRightNumeric({
54
117
  numTicks = 5,
55
118
  tickFormat,
56
119
  tickValues,
57
- }: {
120
+ tickColor = DEFAULT_TICK_COLOR,
121
+ }: TickColor & {
58
122
  scale: AxisScale
59
123
  /** Left offset inside the Group (typically xMax). Required since AxisRight needs positioning. */
60
124
  left: number
@@ -63,7 +127,7 @@ export function AxisRightNumeric({
63
127
  /** Exact tick positions (e.g. a compass axis at 0/90/180/270). Overrides `numTicks`. */
64
128
  tickValues?: readonly number[]
65
129
  }) {
66
- const { axisFont } = useChartTierMetrics()
130
+ const { axisFont } = useChartMetrics()
67
131
  return (
68
132
  <AxisRight
69
133
  left={left}
@@ -72,7 +136,7 @@ export function AxisRightNumeric({
72
136
  {...(tickFormat !== undefined && { tickFormat })}
73
137
  {...(tickValues !== undefined && { tickValues: [...tickValues] })}
74
138
  tickLabelProps={{
75
- fill: VX.faint,
139
+ fill: tickColor,
76
140
  fontFamily: TICK_FONT_FAMILY,
77
141
  fontSize: axisFont,
78
142
  dx: 4,
@@ -104,7 +168,7 @@ export function AxisBottomNumeric({
104
168
  /** Exact tick positions (e.g. a compass axis at 0/90/180/270). Overrides `numTicks`. */
105
169
  tickValues?: readonly number[]
106
170
  }) {
107
- const { axisFont } = useChartTierMetrics()
171
+ const { axisFont } = useChartMetrics()
108
172
  return (
109
173
  <AxisBottom
110
174
  top={top}
@@ -134,6 +198,18 @@ export function AxisBottomNumeric({
134
198
  */
135
199
  const ROTATED_OFFSET = ROTATED_LABEL_OFFSET
136
200
 
201
+ /** `textAnchor` of one x label: inward at the two terminals when asked, centred otherwise. */
202
+ export function terminalAnchor(input: {
203
+ anchorTerminals: boolean
204
+ index: number
205
+ count: number
206
+ }): 'start' | 'middle' | 'end' {
207
+ const { anchorTerminals, index, count } = input
208
+ if (!anchorTerminals || count < 2) return 'middle'
209
+ if (index === 0) return 'start'
210
+ return index === count - 1 ? 'end' : 'middle'
211
+ }
212
+
137
213
  /** Themed bottom date axis — baked-in smartTicks + DD.MM formatting. */
138
214
  export function AxisBottomDate({
139
215
  scale,
@@ -141,10 +217,17 @@ export function AxisBottomDate({
141
217
  tickValues,
142
218
  tickFormat = fmtAxisDate,
143
219
  rotate,
220
+ anchorTerminals = false,
221
+ wrapWidth,
144
222
  }: {
145
223
  scale: AxisScale
146
224
  top: number
147
225
  tickValues: string[]
226
+ /** First label anchored `start`, last `end`, so the terminals stay inside the plot instead of
227
+ * overflowing it. Ignored when rotated. */
228
+ anchorTerminals?: boolean
229
+ /** Wrap each label to this many px (SVG tspans) instead of thinning or rotating it. */
230
+ wrapWidth?: number
148
231
  /**
149
232
  * Defaults to `fmtAxisDate` (DD.MM). Override for a sub-day window, where DD.MM collapses every
150
233
  * tick to the same label.
@@ -157,7 +240,7 @@ export function AxisBottomDate({
157
240
  */
158
241
  rotate?: 45 | 90
159
242
  }) {
160
- const { axisFont } = useChartTierMetrics()
243
+ const { axisFont } = useChartMetrics()
161
244
  const rotated = rotate === undefined ? undefined : ROTATED_OFFSET[rotate]
162
245
  return (
163
246
  <AxisBottom
@@ -165,14 +248,22 @@ export function AxisBottomDate({
165
248
  scale={scale}
166
249
  tickValues={tickValues}
167
250
  tickFormat={tickFormat}
168
- tickLabelProps={{
251
+ tickLabelProps={(_value, index, values) => ({
169
252
  fill: VX.faint,
170
253
  fontFamily: TICK_FONT_FAMILY,
171
254
  fontSize: axisFont,
172
- textAnchor: rotated === undefined ? 'middle' : 'end',
255
+ textAnchor:
256
+ rotated !== undefined
257
+ ? 'end'
258
+ : terminalAnchor({ anchorTerminals, index, count: values.length }),
173
259
  ...(rotate !== undefined && { angle: -rotate }),
174
260
  ...(rotated !== undefined && { dx: rotated.dx, dy: rotated.dy }),
175
- }}
261
+ // A wrapped label's default baseline anchors its LAST line, which stacks the earlier ones
262
+ // UPWARD into the axis line and ticks (R2C-7) — `autoMargin`'s `bottomLines` reserves room
263
+ // BELOW the axis, so the reservation and the paint have to agree on which line anchors.
264
+ ...(wrapWidth !== undefined &&
265
+ rotated === undefined && { width: wrapWidth, verticalAnchor: 'start', dy: '-0.35em' }),
266
+ })}
176
267
  stroke={VX.surface.border}
177
268
  tickStroke={VX.surface.border}
178
269
  />