@voila.dev/chart 4.0.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 (377) hide show
  1. package/README.md +85 -0
  2. package/dist/brush/brush-layer.d.ts +16 -0
  3. package/dist/brush/brush-layer.d.ts.map +1 -0
  4. package/dist/brush/brush-layer.js +137 -0
  5. package/dist/brush/brush-layer.js.map +7 -0
  6. package/dist/brush/index.d.ts +10 -0
  7. package/dist/brush/index.d.ts.map +1 -0
  8. package/dist/brush/index.js +8 -0
  9. package/dist/brush/index.js.map +7 -0
  10. package/dist/canvas/canvas-renderer.d.ts +11 -0
  11. package/dist/canvas/canvas-renderer.d.ts.map +1 -0
  12. package/dist/canvas/canvas-renderer.js +49 -0
  13. package/dist/canvas/canvas-renderer.js.map +7 -0
  14. package/dist/canvas/index.d.ts +2 -0
  15. package/dist/canvas/index.d.ts.map +1 -0
  16. package/dist/canvas/index.js +5 -0
  17. package/dist/canvas/index.js.map +7 -0
  18. package/dist/canvas/paint-scene.d.ts +4 -0
  19. package/dist/canvas/paint-scene.d.ts.map +1 -0
  20. package/dist/canvas/paint-scene.js +130 -0
  21. package/dist/canvas/paint-scene.js.map +7 -0
  22. package/dist/canvas/resolve-color.d.ts +18 -0
  23. package/dist/canvas/resolve-color.d.ts.map +1 -0
  24. package/dist/canvas/resolve-color.js +52 -0
  25. package/dist/canvas/resolve-color.js.map +7 -0
  26. package/dist/contour/density.d.ts +20 -0
  27. package/dist/contour/density.d.ts.map +1 -0
  28. package/dist/contour/density.js +59 -0
  29. package/dist/contour/density.js.map +7 -0
  30. package/dist/contour/index.d.ts +2 -0
  31. package/dist/contour/index.d.ts.map +1 -0
  32. package/dist/contour/index.js +5 -0
  33. package/dist/contour/index.js.map +7 -0
  34. package/dist/core/channel.d.ts +11 -0
  35. package/dist/core/channel.d.ts.map +1 -0
  36. package/dist/core/channel.js +44 -0
  37. package/dist/core/channel.js.map +7 -0
  38. package/dist/core/compile.d.ts +22 -0
  39. package/dist/core/compile.d.ts.map +1 -0
  40. package/dist/core/compile.js +295 -0
  41. package/dist/core/compile.js.map +7 -0
  42. package/dist/core/define-chart.d.ts +96 -0
  43. package/dist/core/define-chart.d.ts.map +1 -0
  44. package/dist/core/define-chart.js +7 -0
  45. package/dist/core/define-chart.js.map +7 -0
  46. package/dist/core/facet.d.ts +7 -0
  47. package/dist/core/facet.d.ts.map +1 -0
  48. package/dist/core/facet.js +134 -0
  49. package/dist/core/facet.js.map +7 -0
  50. package/dist/core/format.d.ts +37 -0
  51. package/dist/core/format.d.ts.map +1 -0
  52. package/dist/core/format.js +108 -0
  53. package/dist/core/format.js.map +7 -0
  54. package/dist/core/guides/axes.d.ts +25 -0
  55. package/dist/core/guides/axes.d.ts.map +1 -0
  56. package/dist/core/guides/axes.js +204 -0
  57. package/dist/core/guides/axes.js.map +7 -0
  58. package/dist/core/marks/area.d.ts +23 -0
  59. package/dist/core/marks/area.d.ts.map +1 -0
  60. package/dist/core/marks/area.js +187 -0
  61. package/dist/core/marks/area.js.map +7 -0
  62. package/dist/core/marks/bar.d.ts +26 -0
  63. package/dist/core/marks/bar.d.ts.map +1 -0
  64. package/dist/core/marks/bar.js +200 -0
  65. package/dist/core/marks/bar.js.map +7 -0
  66. package/dist/core/marks/cell.d.ts +16 -0
  67. package/dist/core/marks/cell.d.ts.map +1 -0
  68. package/dist/core/marks/cell.js +98 -0
  69. package/dist/core/marks/cell.js.map +7 -0
  70. package/dist/core/marks/dot.d.ts +17 -0
  71. package/dist/core/marks/dot.d.ts.map +1 -0
  72. package/dist/core/marks/dot.js +125 -0
  73. package/dist/core/marks/dot.js.map +7 -0
  74. package/dist/core/marks/funnel-layout.d.ts +37 -0
  75. package/dist/core/marks/funnel-layout.d.ts.map +1 -0
  76. package/dist/core/marks/funnel-layout.js +61 -0
  77. package/dist/core/marks/funnel-layout.js.map +7 -0
  78. package/dist/core/marks/funnel.d.ts +19 -0
  79. package/dist/core/marks/funnel.d.ts.map +1 -0
  80. package/dist/core/marks/funnel.js +143 -0
  81. package/dist/core/marks/funnel.js.map +7 -0
  82. package/dist/core/marks/line.d.ts +17 -0
  83. package/dist/core/marks/line.d.ts.map +1 -0
  84. package/dist/core/marks/line.js +150 -0
  85. package/dist/core/marks/line.js.map +7 -0
  86. package/dist/core/marks/polar.d.ts +61 -0
  87. package/dist/core/marks/polar.d.ts.map +1 -0
  88. package/dist/core/marks/polar.js +436 -0
  89. package/dist/core/marks/polar.js.map +7 -0
  90. package/dist/core/marks/rect.d.ts +22 -0
  91. package/dist/core/marks/rect.d.ts.map +1 -0
  92. package/dist/core/marks/rect.js +119 -0
  93. package/dist/core/marks/rect.js.map +7 -0
  94. package/dist/core/marks/rule.d.ts +17 -0
  95. package/dist/core/marks/rule.d.ts.map +1 -0
  96. package/dist/core/marks/rule.js +73 -0
  97. package/dist/core/marks/rule.js.map +7 -0
  98. package/dist/core/marks/shared.d.ts +41 -0
  99. package/dist/core/marks/shared.d.ts.map +1 -0
  100. package/dist/core/marks/shared.js +74 -0
  101. package/dist/core/marks/shared.js.map +7 -0
  102. package/dist/core/marks/stack.d.ts +13 -0
  103. package/dist/core/marks/stack.d.ts.map +1 -0
  104. package/dist/core/marks/stack.js +28 -0
  105. package/dist/core/marks/stack.js.map +7 -0
  106. package/dist/core/marks/stats/difference.d.ts +20 -0
  107. package/dist/core/marks/stats/difference.d.ts.map +1 -0
  108. package/dist/core/marks/stats/difference.js +168 -0
  109. package/dist/core/marks/stats/difference.js.map +7 -0
  110. package/dist/core/marks/stats/distribution.d.ts +41 -0
  111. package/dist/core/marks/stats/distribution.d.ts.map +1 -0
  112. package/dist/core/marks/stats/distribution.js +354 -0
  113. package/dist/core/marks/stats/distribution.js.map +7 -0
  114. package/dist/core/marks/stats/dodge.d.ts +16 -0
  115. package/dist/core/marks/stats/dodge.d.ts.map +1 -0
  116. package/dist/core/marks/stats/dodge.js +104 -0
  117. package/dist/core/marks/stats/dodge.js.map +7 -0
  118. package/dist/core/marks/stats/hexbin.d.ts +26 -0
  119. package/dist/core/marks/stats/hexbin.d.ts.map +1 -0
  120. package/dist/core/marks/stats/hexbin.js +113 -0
  121. package/dist/core/marks/stats/hexbin.js.map +7 -0
  122. package/dist/core/marks/stats/histogram.d.ts +27 -0
  123. package/dist/core/marks/stats/histogram.d.ts.map +1 -0
  124. package/dist/core/marks/stats/histogram.js +70 -0
  125. package/dist/core/marks/stats/histogram.js.map +7 -0
  126. package/dist/core/marks/stats/regression.d.ts +13 -0
  127. package/dist/core/marks/stats/regression.d.ts.map +1 -0
  128. package/dist/core/marks/stats/regression.js +80 -0
  129. package/dist/core/marks/stats/regression.js.map +7 -0
  130. package/dist/core/marks/stats/statistics.d.ts +34 -0
  131. package/dist/core/marks/stats/statistics.d.ts.map +1 -0
  132. package/dist/core/marks/stats/statistics.js +111 -0
  133. package/dist/core/marks/stats/statistics.js.map +7 -0
  134. package/dist/core/marks/stats/waffle.d.ts +18 -0
  135. package/dist/core/marks/stats/waffle.d.ts.map +1 -0
  136. package/dist/core/marks/stats/waffle.js +117 -0
  137. package/dist/core/marks/stats/waffle.js.map +7 -0
  138. package/dist/core/marks/text.d.ts +19 -0
  139. package/dist/core/marks/text.d.ts.map +1 -0
  140. package/dist/core/marks/text.js +60 -0
  141. package/dist/core/marks/text.js.map +7 -0
  142. package/dist/core/motion/tween.d.ts +6 -0
  143. package/dist/core/motion/tween.d.ts.map +1 -0
  144. package/dist/core/motion/tween.js +98 -0
  145. package/dist/core/motion/tween.js.map +7 -0
  146. package/dist/core/nearest.d.ts +25 -0
  147. package/dist/core/nearest.d.ts.map +1 -0
  148. package/dist/core/nearest.js +151 -0
  149. package/dist/core/nearest.js.map +7 -0
  150. package/dist/core/paths.d.ts +65 -0
  151. package/dist/core/paths.d.ts.map +1 -0
  152. package/dist/core/paths.js +192 -0
  153. package/dist/core/paths.js.map +7 -0
  154. package/dist/core/scales/color.d.ts +11 -0
  155. package/dist/core/scales/color.d.ts.map +1 -0
  156. package/dist/core/scales/color.js +35 -0
  157. package/dist/core/scales/color.js.map +7 -0
  158. package/dist/core/scales/continuous.d.ts +18 -0
  159. package/dist/core/scales/continuous.d.ts.map +1 -0
  160. package/dist/core/scales/continuous.js +82 -0
  161. package/dist/core/scales/continuous.js.map +7 -0
  162. package/dist/core/scales/discrete.d.ts +22 -0
  163. package/dist/core/scales/discrete.d.ts.map +1 -0
  164. package/dist/core/scales/discrete.js +103 -0
  165. package/dist/core/scales/discrete.js.map +7 -0
  166. package/dist/core/scales/resolve.d.ts +18 -0
  167. package/dist/core/scales/resolve.d.ts.map +1 -0
  168. package/dist/core/scales/resolve.js +112 -0
  169. package/dist/core/scales/resolve.js.map +7 -0
  170. package/dist/core/scales/time.d.ts +4 -0
  171. package/dist/core/scales/time.d.ts.map +1 -0
  172. package/dist/core/scales/time.js +140 -0
  173. package/dist/core/scales/time.js.map +7 -0
  174. package/dist/core/text.d.ts +3 -0
  175. package/dist/core/text.d.ts.map +1 -0
  176. package/dist/core/text.js +29 -0
  177. package/dist/core/text.js.map +7 -0
  178. package/dist/core/theme.d.ts +8 -0
  179. package/dist/core/theme.d.ts.map +1 -0
  180. package/dist/core/theme.js +21 -0
  181. package/dist/core/theme.js.map +7 -0
  182. package/dist/core/ticks.d.ts +27 -0
  183. package/dist/core/ticks.d.ts.map +1 -0
  184. package/dist/core/ticks.js +76 -0
  185. package/dist/core/ticks.js.map +7 -0
  186. package/dist/core/types.d.ts +305 -0
  187. package/dist/core/types.d.ts.map +1 -0
  188. package/dist/core/types.js +1 -0
  189. package/dist/core/types.js.map +7 -0
  190. package/dist/force/force.d.ts +30 -0
  191. package/dist/force/force.d.ts.map +1 -0
  192. package/dist/force/force.js +143 -0
  193. package/dist/force/force.js.map +7 -0
  194. package/dist/force/index.d.ts +2 -0
  195. package/dist/force/index.d.ts.map +1 -0
  196. package/dist/force/index.js +7 -0
  197. package/dist/force/index.js.map +7 -0
  198. package/dist/geo/geo.d.ts +59 -0
  199. package/dist/geo/geo.d.ts.map +1 -0
  200. package/dist/geo/geo.js +231 -0
  201. package/dist/geo/geo.js.map +7 -0
  202. package/dist/geo/index.d.ts +2 -0
  203. package/dist/geo/index.d.ts.map +1 -0
  204. package/dist/geo/index.js +13 -0
  205. package/dist/geo/index.js.map +7 -0
  206. package/dist/hierarchy/hierarchy.d.ts +24 -0
  207. package/dist/hierarchy/hierarchy.d.ts.map +1 -0
  208. package/dist/hierarchy/hierarchy.js +279 -0
  209. package/dist/hierarchy/hierarchy.js.map +7 -0
  210. package/dist/hierarchy/index.d.ts +2 -0
  211. package/dist/hierarchy/index.d.ts.map +1 -0
  212. package/dist/hierarchy/index.js +11 -0
  213. package/dist/hierarchy/index.js.map +7 -0
  214. package/dist/index.d.ts +29 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +96 -0
  217. package/dist/index.js.map +7 -0
  218. package/dist/react/brush-contract.d.ts +23 -0
  219. package/dist/react/brush-contract.d.ts.map +1 -0
  220. package/dist/react/brush-contract.js +7 -0
  221. package/dist/react/brush-contract.js.map +7 -0
  222. package/dist/react/chart-data-table.d.ts +18 -0
  223. package/dist/react/chart-data-table.d.ts.map +1 -0
  224. package/dist/react/chart-data-table.js +55 -0
  225. package/dist/react/chart-data-table.js.map +7 -0
  226. package/dist/react/chart-legend.d.ts +17 -0
  227. package/dist/react/chart-legend.d.ts.map +1 -0
  228. package/dist/react/chart-legend.js +126 -0
  229. package/dist/react/chart-legend.js.map +7 -0
  230. package/dist/react/chart-tooltip.d.ts +18 -0
  231. package/dist/react/chart-tooltip.d.ts.map +1 -0
  232. package/dist/react/chart-tooltip.js +95 -0
  233. package/dist/react/chart-tooltip.js.map +7 -0
  234. package/dist/react/chart.d.ts +45 -0
  235. package/dist/react/chart.d.ts.map +1 -0
  236. package/dist/react/chart.js +383 -0
  237. package/dist/react/chart.js.map +7 -0
  238. package/dist/react/focus-overlay.d.ts +14 -0
  239. package/dist/react/focus-overlay.d.ts.map +1 -0
  240. package/dist/react/focus-overlay.js +147 -0
  241. package/dist/react/focus-overlay.js.map +7 -0
  242. package/dist/react/index.d.ts +7 -0
  243. package/dist/react/index.d.ts.map +1 -0
  244. package/dist/react/index.js +11 -0
  245. package/dist/react/index.js.map +7 -0
  246. package/dist/react/messages.d.ts +16 -0
  247. package/dist/react/messages.d.ts.map +1 -0
  248. package/dist/react/messages.js +33 -0
  249. package/dist/react/messages.js.map +7 -0
  250. package/dist/react/renderer.d.ts +14 -0
  251. package/dist/react/renderer.d.ts.map +1 -0
  252. package/dist/react/renderer.js +1 -0
  253. package/dist/react/renderer.js.map +7 -0
  254. package/dist/react/sr-only.d.ts +4 -0
  255. package/dist/react/sr-only.d.ts.map +1 -0
  256. package/dist/react/sr-only.js +15 -0
  257. package/dist/react/sr-only.js.map +7 -0
  258. package/dist/react/svg-renderer.d.ts +4 -0
  259. package/dist/react/svg-renderer.d.ts.map +1 -0
  260. package/dist/react/svg-renderer.js +20 -0
  261. package/dist/react/svg-renderer.js.map +7 -0
  262. package/dist/react/svg-scene.d.ts +8 -0
  263. package/dist/react/svg-scene.d.ts.map +1 -0
  264. package/dist/react/svg-scene.js +121 -0
  265. package/dist/react/svg-scene.js.map +7 -0
  266. package/dist/react/use-animated-scene.d.ts +10 -0
  267. package/dist/react/use-animated-scene.d.ts.map +1 -0
  268. package/dist/react/use-animated-scene.js +41 -0
  269. package/dist/react/use-animated-scene.js.map +7 -0
  270. package/dist/react/use-chart-focus.d.ts +29 -0
  271. package/dist/react/use-chart-focus.d.ts.map +1 -0
  272. package/dist/react/use-chart-focus.js +180 -0
  273. package/dist/react/use-chart-focus.js.map +7 -0
  274. package/dist/react/use-chart-width.d.ts +12 -0
  275. package/dist/react/use-chart-width.d.ts.map +1 -0
  276. package/dist/react/use-chart-width.js +23 -0
  277. package/dist/react/use-chart-width.js.map +7 -0
  278. package/dist/react/use-chart-zoom.d.ts +13 -0
  279. package/dist/react/use-chart-zoom.d.ts.map +1 -0
  280. package/dist/react/use-chart-zoom.js +52 -0
  281. package/dist/react/use-chart-zoom.js.map +7 -0
  282. package/dist/react/use-text-measurer.d.ts +8 -0
  283. package/dist/react/use-text-measurer.d.ts.map +1 -0
  284. package/dist/react/use-text-measurer.js +44 -0
  285. package/dist/react/use-text-measurer.js.map +7 -0
  286. package/dist/sankey/index.d.ts +2 -0
  287. package/dist/sankey/index.d.ts.map +1 -0
  288. package/dist/sankey/index.js +7 -0
  289. package/dist/sankey/index.js.map +7 -0
  290. package/dist/sankey/sankey.d.ts +29 -0
  291. package/dist/sankey/sankey.d.ts.map +1 -0
  292. package/dist/sankey/sankey.js +127 -0
  293. package/dist/sankey/sankey.js.map +7 -0
  294. package/dist/voronoi/index.d.ts +2 -0
  295. package/dist/voronoi/index.d.ts.map +1 -0
  296. package/dist/voronoi/index.js +5 -0
  297. package/dist/voronoi/index.js.map +7 -0
  298. package/dist/voronoi/voronoi.d.ts +16 -0
  299. package/dist/voronoi/voronoi.d.ts.map +1 -0
  300. package/dist/voronoi/voronoi.js +62 -0
  301. package/dist/voronoi/voronoi.js.map +7 -0
  302. package/package.json +74 -0
  303. package/src/brush/brush-layer.tsx +174 -0
  304. package/src/brush/index.ts +14 -0
  305. package/src/canvas/canvas-renderer.tsx +56 -0
  306. package/src/canvas/index.ts +1 -0
  307. package/src/canvas/paint-scene.ts +152 -0
  308. package/src/canvas/resolve-color.ts +65 -0
  309. package/src/contour/density.ts +90 -0
  310. package/src/contour/index.ts +1 -0
  311. package/src/core/channel.ts +53 -0
  312. package/src/core/compile.ts +436 -0
  313. package/src/core/define-chart.ts +128 -0
  314. package/src/core/facet.ts +189 -0
  315. package/src/core/format.ts +163 -0
  316. package/src/core/guides/axes.ts +283 -0
  317. package/src/core/marks/area.ts +238 -0
  318. package/src/core/marks/bar.ts +278 -0
  319. package/src/core/marks/cell.ts +123 -0
  320. package/src/core/marks/dot.ts +157 -0
  321. package/src/core/marks/funnel-layout.ts +113 -0
  322. package/src/core/marks/funnel.ts +172 -0
  323. package/src/core/marks/line.ts +206 -0
  324. package/src/core/marks/polar.ts +568 -0
  325. package/src/core/marks/rect.ts +156 -0
  326. package/src/core/marks/rule.ts +104 -0
  327. package/src/core/marks/shared.ts +134 -0
  328. package/src/core/marks/stack.ts +42 -0
  329. package/src/core/marks/stats/difference.ts +205 -0
  330. package/src/core/marks/stats/distribution.ts +425 -0
  331. package/src/core/marks/stats/dodge.ts +136 -0
  332. package/src/core/marks/stats/hexbin.ts +152 -0
  333. package/src/core/marks/stats/histogram.ts +108 -0
  334. package/src/core/marks/stats/regression.ts +108 -0
  335. package/src/core/marks/stats/statistics.ts +153 -0
  336. package/src/core/marks/stats/waffle.ts +141 -0
  337. package/src/core/marks/text.ts +97 -0
  338. package/src/core/motion/tween.ts +131 -0
  339. package/src/core/nearest.ts +223 -0
  340. package/src/core/paths.ts +323 -0
  341. package/src/core/scales/color.ts +51 -0
  342. package/src/core/scales/continuous.ts +111 -0
  343. package/src/core/scales/discrete.ts +156 -0
  344. package/src/core/scales/resolve.ts +158 -0
  345. package/src/core/scales/time.ts +180 -0
  346. package/src/core/text.ts +42 -0
  347. package/src/core/theme.ts +26 -0
  348. package/src/core/ticks.ts +118 -0
  349. package/src/core/types.ts +358 -0
  350. package/src/force/force.ts +200 -0
  351. package/src/force/index.ts +6 -0
  352. package/src/geo/geo.ts +346 -0
  353. package/src/geo/index.ts +10 -0
  354. package/src/hierarchy/hierarchy.ts +377 -0
  355. package/src/hierarchy/index.ts +6 -0
  356. package/src/index.ts +70 -0
  357. package/src/react/brush-contract.ts +35 -0
  358. package/src/react/chart-data-table.tsx +98 -0
  359. package/src/react/chart-legend.tsx +130 -0
  360. package/src/react/chart-tooltip.tsx +111 -0
  361. package/src/react/chart.tsx +489 -0
  362. package/src/react/focus-overlay.tsx +164 -0
  363. package/src/react/index.ts +9 -0
  364. package/src/react/messages.ts +49 -0
  365. package/src/react/renderer.ts +15 -0
  366. package/src/react/sr-only.ts +14 -0
  367. package/src/react/svg-renderer.tsx +18 -0
  368. package/src/react/svg-scene.tsx +146 -0
  369. package/src/react/use-animated-scene.ts +57 -0
  370. package/src/react/use-chart-focus.ts +245 -0
  371. package/src/react/use-chart-width.ts +27 -0
  372. package/src/react/use-chart-zoom.ts +67 -0
  373. package/src/react/use-text-measurer.ts +49 -0
  374. package/src/sankey/index.ts +6 -0
  375. package/src/sankey/sankey.ts +179 -0
  376. package/src/voronoi/index.ts +1 -0
  377. package/src/voronoi/voronoi.ts +84 -0
@@ -0,0 +1,189 @@
1
+ import { channelsOf, isMark } from "#/core/compile.ts";
2
+ import type {
3
+ ChartFacet,
4
+ ChartFacetCompileOptions,
5
+ ChartFacetOptions,
6
+ ChartSpec,
7
+ } from "#/core/define-chart.ts";
8
+ import { formatValue } from "#/core/format.ts";
9
+ import { categoryKey } from "#/core/scales/discrete.ts";
10
+ import { planScale } from "#/core/scales/resolve.ts";
11
+ import { DEFAULT_THEME } from "#/core/theme.ts";
12
+ import type {
13
+ ChartHitShape,
14
+ ChartLegendItem,
15
+ ChartMark,
16
+ ChartPoint,
17
+ ChartScene,
18
+ SceneNode,
19
+ } from "#/core/types.ts";
20
+
21
+ const DEFAULT_COLUMNS = 3;
22
+ const DEFAULT_GAP = 16;
23
+ const TITLE_GAP = 6;
24
+
25
+ function shifted(
26
+ hit: ChartHitShape | undefined,
27
+ dx: number,
28
+ dy: number,
29
+ ): ChartHitShape | undefined {
30
+ if (hit === undefined) {
31
+ return undefined;
32
+ }
33
+ return hit.kind === "rect"
34
+ ? {
35
+ kind: "rect",
36
+ rect: { ...hit.rect, x: hit.rect.x + dx, y: hit.rect.y + dy },
37
+ }
38
+ : { ...hit, cx: hit.cx + dx, cy: hit.cy + dy };
39
+ }
40
+
41
+ /**
42
+ * Small multiples. Every cell is compiled as a chart of its own, on x, y and
43
+ * colour domains planned over all the cells together, then moved to its
44
+ * place in the grid under a title. Points carry their cell, so the keyboard
45
+ * walks one cell's values and the tooltip names the cell.
46
+ */
47
+ function compileFacets(
48
+ spec: ChartSpec,
49
+ facet: ChartFacetOptions,
50
+ options: ChartFacetCompileOptions,
51
+ ): ChartScene {
52
+ const { compileCell, ...cellOptions } = options;
53
+ const { width, height } = options;
54
+ const theme = { ...DEFAULT_THEME, ...spec.theme };
55
+ const values = facet.values;
56
+ const columns = Math.max(
57
+ 1,
58
+ Math.min(facet.columns ?? DEFAULT_COLUMNS, values.length),
59
+ );
60
+ const rows = Math.max(1, Math.ceil(values.length / columns));
61
+ const gap = facet.gap ?? DEFAULT_GAP;
62
+ const title = theme.fontSize + TITLE_GAP;
63
+ const cellWidth = (width - gap * (columns - 1)) / columns;
64
+ const cellHeight = (height - gap * (rows - 1)) / rows;
65
+ const label = (value: (typeof values)[number]) =>
66
+ facet.label?.(value) ?? formatValue(value, spec.locale);
67
+
68
+ const cellMarks: ChartMark[][] = values.map((value, index) =>
69
+ facet.marks(value, index).filter(isMark),
70
+ );
71
+ const everyMark = cellMarks.flat();
72
+ const cartesian = everyMark.filter((mark) => mark.coordinate === "cartesian");
73
+ const sharedX = planScale(channelsOf(cartesian, "x"), spec.x, "x");
74
+ const sharedY = planScale(channelsOf(cartesian, "y"), spec.y, "y");
75
+ const colors = channelsOf(everyMark, "color").flatMap(
76
+ (channel) => channel.values,
77
+ );
78
+ const numericColors =
79
+ colors.length > 0 && colors.every((value) => typeof value === "number");
80
+ const colorDomain =
81
+ spec.color?.domain ??
82
+ (colors.length === 0
83
+ ? undefined
84
+ : numericColors
85
+ ? [Math.min(...(colors as number[])), Math.max(...(colors as number[]))]
86
+ : [
87
+ ...new Map(
88
+ colors.map((value) => [categoryKey(value), value]),
89
+ ).values(),
90
+ ]);
91
+
92
+ const nodes: SceneNode[] = [];
93
+ const points: ChartPoint[] = [];
94
+ const legend: ChartLegendItem[] = [];
95
+ const cells: Array<{ key: string; label: string; plot: ChartScene["plot"] }> =
96
+ [];
97
+ let first: ChartScene | undefined;
98
+
99
+ for (const [index, value] of values.entries()) {
100
+ const key = categoryKey(value);
101
+ const x = (index % columns) * (cellWidth + gap);
102
+ const y = Math.floor(index / columns) * (cellHeight + gap);
103
+ const cell = compileCell(
104
+ {
105
+ ...spec,
106
+ facet: undefined,
107
+ marks: cellMarks[index],
108
+ x: sharedX
109
+ ? { ...spec.x, domain: sharedX.domain, nice: false }
110
+ : spec.x,
111
+ y: sharedY
112
+ ? { ...spec.y, domain: sharedY.domain, nice: false }
113
+ : spec.y,
114
+ color: colorDomain
115
+ ? { ...spec.color, domain: colorDomain }
116
+ : spec.color,
117
+ },
118
+ { ...cellOptions, width: cellWidth, height: cellHeight - title },
119
+ );
120
+ first ??= cell;
121
+ const dy = y + title;
122
+ nodes.push(
123
+ {
124
+ kind: "text",
125
+ key: `facet-title:${key}`,
126
+ role: "facet-title",
127
+ x: x + cell.plot.x,
128
+ y,
129
+ text: label(value),
130
+ paint: {
131
+ fill: theme.foreground,
132
+ fontSize: theme.fontSize,
133
+ fontWeight: 600,
134
+ baseline: "top",
135
+ },
136
+ },
137
+ {
138
+ kind: "group",
139
+ key: `facet:${key}`,
140
+ translate: { x, y: dy },
141
+ children: cell.nodes,
142
+ },
143
+ );
144
+ for (const point of cell.points) {
145
+ points.push({
146
+ ...point,
147
+ key: `${key}|${point.key}`,
148
+ x: point.x + x,
149
+ y: point.y + dy,
150
+ hit: shifted(point.hit, x, dy),
151
+ facet: key,
152
+ title: `${label(value)} · ${point.title}`,
153
+ });
154
+ }
155
+ legend.push(...cell.legend);
156
+ cells.push({
157
+ key,
158
+ label: label(value),
159
+ plot: { ...cell.plot, x: cell.plot.x + x, y: cell.plot.y + dy },
160
+ });
161
+ }
162
+
163
+ const distinctLegend = [
164
+ ...new Map(legend.map((item) => [item.key, item])).values(),
165
+ ];
166
+ return {
167
+ ...(first ??
168
+ compileCell({ ...spec, facet: undefined, marks: [] }, cellOptions)),
169
+ width,
170
+ height,
171
+ plot: { x: 0, y: 0, width, height },
172
+ nodes,
173
+ points,
174
+ legend: distinctLegend,
175
+ cells,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Small multiples: the same chart once per value, on a grid, every cell on
181
+ * the same scales. Pass the result as the definition's `facet`.
182
+ */
183
+ export function facet(options: ChartFacetOptions): ChartFacet {
184
+ return {
185
+ options,
186
+ compile: (spec, compileOptions) =>
187
+ compileFacets(spec, options, compileOptions),
188
+ };
189
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Number formatting for axes, labels and tooltips. The whole app writes its
3
+ * numbers in French, so that is the default here too — pass an explicit locale
4
+ * when a chart lives on a page that does not.
5
+ */
6
+
7
+ const DEFAULT_LOCALE = "fr-FR";
8
+ /** Above this magnitude an axis tick reads better as "12 k" than as "12 000". */
9
+ const COMPACT_THRESHOLD = 10_000;
10
+
11
+ const formatterCache = new Map<string, Intl.NumberFormat>();
12
+
13
+ function formatterFor(
14
+ locale: string,
15
+ options: Intl.NumberFormatOptions,
16
+ ): Intl.NumberFormat {
17
+ const cacheKey = `${locale}:${JSON.stringify(options)}`;
18
+ const cached = formatterCache.get(cacheKey);
19
+ if (cached !== undefined) {
20
+ return cached;
21
+ }
22
+ const created = new Intl.NumberFormat(locale, options);
23
+ formatterCache.set(cacheKey, created);
24
+ return created;
25
+ }
26
+
27
+ export interface FormatNumberOptions {
28
+ readonly locale?: string;
29
+ readonly maximumFractionDigits?: number;
30
+ readonly minimumFractionDigits?: number;
31
+ }
32
+
33
+ export function formatNumber(
34
+ value: number,
35
+ options: FormatNumberOptions = {},
36
+ ): string {
37
+ const { locale = DEFAULT_LOCALE, ...digits } = options;
38
+ return formatterFor(locale, digits).format(value);
39
+ }
40
+
41
+ /** "12 k", "3,4 M" — for axis ticks, where width is the scarce resource. */
42
+ export function formatCompactNumber(
43
+ value: number,
44
+ locale: string = DEFAULT_LOCALE,
45
+ ): string {
46
+ return formatterFor(locale, {
47
+ notation: "compact",
48
+ maximumFractionDigits: 1,
49
+ }).format(value);
50
+ }
51
+
52
+ export function formatPercentage(
53
+ fraction: number,
54
+ locale: string = DEFAULT_LOCALE,
55
+ ): string {
56
+ return formatterFor(locale, {
57
+ style: "percent",
58
+ maximumFractionDigits: 1,
59
+ }).format(fraction);
60
+ }
61
+
62
+ /**
63
+ * What a value axis uses when the caller has no opinion: plain digits until the
64
+ * numbers get long, compact notation after that, and at most one decimal so a
65
+ * tick step of 0.5 still reads correctly.
66
+ */
67
+ export function formatTickValue(
68
+ value: number,
69
+ locale: string = DEFAULT_LOCALE,
70
+ ): string {
71
+ if (Math.abs(value) >= COMPACT_THRESHOLD) {
72
+ return formatCompactNumber(value, locale);
73
+ }
74
+ return formatNumber(value, { locale, maximumFractionDigits: 2 });
75
+ }
76
+
77
+ /** Renders an unknown datum field as a label without throwing on `null`. */
78
+ export function formatLabel(value: unknown): string {
79
+ if (value === null || value === undefined) {
80
+ return "";
81
+ }
82
+ if (typeof value === "number") {
83
+ return formatTickValue(value);
84
+ }
85
+ return String(value);
86
+ }
87
+
88
+ /**
89
+ * A value as a tooltip, the live region or the data table says it: dates in
90
+ * full, numbers grouped, strings as they are.
91
+ */
92
+ export function formatValue(
93
+ value: unknown,
94
+ locale: string = DEFAULT_LOCALE,
95
+ ): string {
96
+ if (value instanceof Date) {
97
+ return formatDate(value, locale);
98
+ }
99
+ if (typeof value === "number") {
100
+ return formatNumber(value, { locale, maximumFractionDigits: 2 });
101
+ }
102
+ return formatLabel(value);
103
+ }
104
+
105
+ const formatters = new Map<string, Intl.DateTimeFormat>();
106
+
107
+ export function dateFormat(
108
+ locale: string,
109
+ options: Intl.DateTimeFormatOptions,
110
+ ): Intl.DateTimeFormat {
111
+ const key = `${locale}:${JSON.stringify(options)}`;
112
+ const cached = formatters.get(key);
113
+ if (cached !== undefined) {
114
+ return cached;
115
+ }
116
+ const created = new Intl.DateTimeFormat(locale, {
117
+ ...options,
118
+ timeZone: "UTC",
119
+ });
120
+ formatters.set(key, created);
121
+ return created;
122
+ }
123
+
124
+ const MS_PER_DAY = 86_400_000;
125
+
126
+ /** A date written in full, for a tooltip or a table cell: "3 mars 2026", with the time when there is one. */
127
+ export function formatDate(value: Date, locale: string): string {
128
+ const time = value.getTime();
129
+ const hasTime = time % MS_PER_DAY !== 0;
130
+ return dateFormat(locale, {
131
+ day: "numeric",
132
+ month: "long",
133
+ year: "numeric",
134
+ ...(hasTime ? { hour: "2-digit", minute: "2-digit" } : {}),
135
+ }).format(value);
136
+ }
137
+
138
+ /**
139
+ * How to write the dates of one channel in full, judged on all of them: a
140
+ * series of month buckets reads "mars 2026", of years "2026", anything finer
141
+ * the full date.
142
+ */
143
+ export function dateValueFormatter(
144
+ values: ReadonlyArray<unknown>,
145
+ locale: string,
146
+ ): ((value: Date) => string) | undefined {
147
+ const dates = values.filter((value): value is Date => value instanceof Date);
148
+ if (dates.length === 0) {
149
+ return undefined;
150
+ }
151
+ const firstOfMonth = dates.every(
152
+ (date) => date.getTime() % MS_PER_DAY === 0 && date.getUTCDate() === 1,
153
+ );
154
+ if (firstOfMonth && dates.every((date) => date.getUTCMonth() === 0)) {
155
+ const year = dateFormat(locale, { year: "numeric" });
156
+ return (value) => year.format(value);
157
+ }
158
+ if (firstOfMonth) {
159
+ const month = dateFormat(locale, { month: "long", year: "numeric" });
160
+ return (value) => month.format(value);
161
+ }
162
+ return undefined;
163
+ }
@@ -0,0 +1,283 @@
1
+ import type { ChartAxisOptions } from "#/core/define-chart.ts";
2
+ import type {
3
+ ChartPositionScale,
4
+ ChartRect,
5
+ ChartTextMeasurer,
6
+ ChartTheme,
7
+ ChartValue,
8
+ SceneNode,
9
+ } from "#/core/types.ts";
10
+
11
+ /**
12
+ * The axes as scene nodes: grid lines behind the marks, tick labels and
13
+ * titles in front. They are measured before the plot exists, so the margins
14
+ * fit the widest label instead of a guessed constant.
15
+ */
16
+
17
+ const TICK_GAP = 8;
18
+ const TITLE_GAP = 6;
19
+ const LABEL_SPACING = 8;
20
+ const EDGE = 8;
21
+ /** A category label on a horizontal bar chart never takes more of the width than this. */
22
+ const MAX_CATEGORY_SHARE = 0.35;
23
+ const ELLIPSIS = "…";
24
+
25
+ export interface AxisMeasureInput {
26
+ readonly scale: ChartPositionScale;
27
+ readonly options: ChartAxisOptions | undefined;
28
+ readonly title: string | undefined;
29
+ readonly theme: ChartTheme;
30
+ readonly measureText: ChartTextMeasurer;
31
+ }
32
+
33
+ export function axisTicks(
34
+ scale: ChartPositionScale,
35
+ options: ChartAxisOptions | undefined,
36
+ ): ReadonlyArray<ChartValue> {
37
+ const ticks = options?.ticks;
38
+ if (Array.isArray(ticks)) {
39
+ return ticks;
40
+ }
41
+ return scale.ticks(typeof ticks === "number" ? ticks : undefined);
42
+ }
43
+
44
+ export function tickLabel(
45
+ scale: ChartPositionScale,
46
+ options: ChartAxisOptions | undefined,
47
+ value: ChartValue,
48
+ ): string {
49
+ return options?.tickFormat?.(value) ?? scale.format(value);
50
+ }
51
+
52
+ /** Cuts a label down to `maxWidth`, ending on an ellipsis. */
53
+ export function truncate(
54
+ text: string,
55
+ maxWidth: number,
56
+ fontSize: number,
57
+ measureText: ChartTextMeasurer,
58
+ ): string {
59
+ if (measureText(text, fontSize) <= maxWidth) {
60
+ return text;
61
+ }
62
+ let kept = text;
63
+ while (kept.length > 1 && measureText(kept + ELLIPSIS, fontSize) > maxWidth) {
64
+ kept = kept.slice(0, -1);
65
+ }
66
+ return kept + ELLIPSIS;
67
+ }
68
+
69
+ function hidden(options: ChartAxisOptions | undefined): boolean {
70
+ return options?.axis === false;
71
+ }
72
+
73
+ /** Room the left axis needs: its widest tick label, the gap, and the title. */
74
+ export function leftMargin(
75
+ input: AxisMeasureInput,
76
+ chartWidth: number,
77
+ ): number {
78
+ const { scale, options, title, theme, measureText } = input;
79
+ if (hidden(options)) {
80
+ return EDGE;
81
+ }
82
+ const discrete = scale.kind === "band" || scale.kind === "point";
83
+ const widest = Math.max(
84
+ 0,
85
+ ...axisTicks(scale, options).map((tick) =>
86
+ measureText(tickLabel(scale, options, tick), theme.fontSize),
87
+ ),
88
+ );
89
+ const labels = discrete
90
+ ? Math.min(widest, chartWidth * MAX_CATEGORY_SHARE)
91
+ : widest;
92
+ return EDGE + labels + TICK_GAP + (title ? theme.fontSize + TITLE_GAP : 0);
93
+ }
94
+
95
+ export function bottomMargin(input: AxisMeasureInput): number {
96
+ const { options, title, theme } = input;
97
+ if (hidden(options)) {
98
+ return EDGE;
99
+ }
100
+ return (
101
+ TICK_GAP +
102
+ theme.fontSize +
103
+ EDGE / 2 +
104
+ (title ? theme.fontSize + TITLE_GAP : 0)
105
+ );
106
+ }
107
+
108
+ /** Half the last x label hangs past the plot's right edge; leave it room. */
109
+ export function rightMargin(input: AxisMeasureInput): number {
110
+ const { scale, options, theme, measureText } = input;
111
+ if (hidden(options) || scale.kind === "band" || scale.kind === "point") {
112
+ return EDGE;
113
+ }
114
+ const ticks = axisTicks(scale, options);
115
+ const last = ticks[ticks.length - 1];
116
+ if (last === undefined) {
117
+ return EDGE;
118
+ }
119
+ const half = measureText(tickLabel(scale, options, last), theme.fontSize) / 2;
120
+ const overhang = half - (scale.range[1] - scale.map(last));
121
+ return Math.max(EDGE, overhang + 2);
122
+ }
123
+
124
+ export interface AxisNodes {
125
+ readonly grid: ReadonlyArray<SceneNode>;
126
+ readonly labels: ReadonlyArray<SceneNode>;
127
+ }
128
+
129
+ /**
130
+ * Every `step`-th label, so neighbours never overlap. The step is the same
131
+ * across the axis: thinning by skipping single labels reads as noise.
132
+ */
133
+ function thinned(
134
+ ticks: ReadonlyArray<ChartValue>,
135
+ widths: ReadonlyArray<number>,
136
+ positions: ReadonlyArray<number>,
137
+ ): ReadonlyArray<number> {
138
+ if (ticks.length < 2) {
139
+ return ticks.map((_tick, index) => index);
140
+ }
141
+ const spacing = Math.abs(positions[1] - positions[0]) || 1;
142
+ const widest = Math.max(...widths);
143
+ const step = Math.max(1, Math.ceil((widest + LABEL_SPACING) / spacing));
144
+ return ticks
145
+ .map((_tick, index) => index)
146
+ .filter((index) => index % step === 0);
147
+ }
148
+
149
+ export function bottomAxis(
150
+ input: AxisMeasureInput,
151
+ plot: ChartRect,
152
+ grid: boolean,
153
+ ): AxisNodes {
154
+ const { scale, options, title, theme, measureText } = input;
155
+ const ticks = axisTicks(scale, options);
156
+ const positions = ticks.map((tick) => scale.center(tick));
157
+ const gridNodes: SceneNode[] = grid
158
+ ? ticks.map((tick, index) => ({
159
+ kind: "line",
160
+ key: `grid-x:${tickLabel(scale, options, tick)}`,
161
+ role: "grid",
162
+ x1: positions[index],
163
+ y1: plot.y,
164
+ x2: positions[index],
165
+ y2: plot.y + plot.height,
166
+ paint: { stroke: theme.grid, strokeWidth: 1 },
167
+ }))
168
+ : [];
169
+ if (hidden(options)) {
170
+ return { grid: gridNodes, labels: [] };
171
+ }
172
+ const texts = ticks.map((tick) => tickLabel(scale, options, tick));
173
+ const widths = texts.map((text) => measureText(text, theme.fontSize));
174
+ const baseline = plot.y + plot.height + TICK_GAP;
175
+ const labels: SceneNode[] = thinned(ticks, widths, positions).map(
176
+ (index) => ({
177
+ kind: "text",
178
+ key: `tick-x:${texts[index]}`,
179
+ role: "axis",
180
+ x: positions[index],
181
+ y: baseline,
182
+ text: texts[index],
183
+ paint: {
184
+ fill: theme.muted,
185
+ fontSize: theme.fontSize,
186
+ textAnchor: "middle",
187
+ baseline: "top",
188
+ },
189
+ }),
190
+ );
191
+ if (title) {
192
+ labels.push({
193
+ kind: "text",
194
+ key: "title-x",
195
+ role: "axis-title",
196
+ x: plot.x + plot.width / 2,
197
+ y: baseline + theme.fontSize + TITLE_GAP,
198
+ text: title,
199
+ paint: {
200
+ fill: theme.muted,
201
+ fontSize: theme.fontSize,
202
+ fontWeight: 500,
203
+ textAnchor: "middle",
204
+ baseline: "top",
205
+ },
206
+ });
207
+ }
208
+ return { grid: gridNodes, labels };
209
+ }
210
+
211
+ export function leftAxis(
212
+ input: AxisMeasureInput,
213
+ plot: ChartRect,
214
+ grid: boolean,
215
+ chartWidth: number,
216
+ ): AxisNodes {
217
+ const { scale, options, title, theme, measureText } = input;
218
+ const ticks = axisTicks(scale, options);
219
+ const positions = ticks.map((tick) => scale.center(tick));
220
+ const gridNodes: SceneNode[] = grid
221
+ ? ticks.map((tick, index) => ({
222
+ kind: "line",
223
+ key: `grid-y:${tickLabel(scale, options, tick)}`,
224
+ role: "grid",
225
+ x1: plot.x,
226
+ y1: positions[index],
227
+ x2: plot.x + plot.width,
228
+ y2: positions[index],
229
+ paint: { stroke: theme.grid, strokeWidth: 1 },
230
+ }))
231
+ : [];
232
+ if (hidden(options)) {
233
+ return { grid: gridNodes, labels: [] };
234
+ }
235
+ const discrete = scale.kind === "band" || scale.kind === "point";
236
+ const maxWidth = discrete
237
+ ? chartWidth * MAX_CATEGORY_SHARE
238
+ : Number.POSITIVE_INFINITY;
239
+ const texts = ticks.map((tick) =>
240
+ truncate(
241
+ tickLabel(scale, options, tick),
242
+ maxWidth,
243
+ theme.fontSize,
244
+ measureText,
245
+ ),
246
+ );
247
+ const heights = texts.map(() => theme.fontSize);
248
+ const labels: SceneNode[] = thinned(ticks, heights, positions).map(
249
+ (index) => ({
250
+ kind: "text",
251
+ key: `tick-y:${texts[index]}`,
252
+ role: "axis",
253
+ x: plot.x - TICK_GAP,
254
+ y: positions[index],
255
+ text: texts[index],
256
+ paint: {
257
+ fill: theme.muted,
258
+ fontSize: theme.fontSize,
259
+ textAnchor: "end",
260
+ baseline: "middle",
261
+ },
262
+ }),
263
+ );
264
+ if (title) {
265
+ labels.push({
266
+ kind: "text",
267
+ key: "title-y",
268
+ role: "axis-title",
269
+ x: EDGE / 2 + theme.fontSize / 2,
270
+ y: plot.y + plot.height / 2,
271
+ text: title,
272
+ rotate: -90,
273
+ paint: {
274
+ fill: theme.muted,
275
+ fontSize: theme.fontSize,
276
+ fontWeight: 500,
277
+ textAnchor: "middle",
278
+ baseline: "middle",
279
+ },
280
+ });
281
+ }
282
+ return { grid: gridNodes, labels };
283
+ }