@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,245 @@
1
+ import * as React from "react";
2
+ import type { ChartFocusStop, findNearest } from "#/core/nearest.ts";
3
+ import type { ChartFocusOrder, ChartPoint } from "#/core/types.ts";
4
+
5
+ /**
6
+ * Which point the reader is on, by the pointer or the keyboard. Focus is held
7
+ * by key, not index, so it stays on the same datum when the data updates
8
+ * around it, and drops cleanly when that datum goes away.
9
+ */
10
+ interface FocusState {
11
+ readonly stopKey: string;
12
+ readonly pointKey: string;
13
+ readonly source: "pointer" | "keyboard";
14
+ readonly pinned: boolean;
15
+ }
16
+
17
+ export interface ActiveFocus {
18
+ readonly stop: ChartFocusStop;
19
+ readonly point: ChartPoint;
20
+ readonly source: "pointer" | "keyboard";
21
+ readonly pinned: boolean;
22
+ }
23
+
24
+ interface Options {
25
+ readonly stops: ReadonlyArray<ChartFocusStop>;
26
+ readonly order: ChartFocusOrder;
27
+ readonly hitTest: (x: number, y: number) => ReturnType<typeof findNearest>;
28
+ readonly onFocusChange?: (point: ChartPoint | null) => void;
29
+ readonly onSelect?: (point: ChartPoint | null) => void;
30
+ }
31
+
32
+ /** Points of a stop in screen order across the stop: top to bottom, or left to right. */
33
+ function across(
34
+ stop: ChartFocusStop,
35
+ order: ChartFocusOrder,
36
+ ): ReadonlyArray<ChartPoint> {
37
+ return [...stop.points].sort((left, right) =>
38
+ order === "y" ? left.x - right.x : left.y - right.y,
39
+ );
40
+ }
41
+
42
+ const ALONG_KEYS: Record<
43
+ ChartFocusOrder,
44
+ { next: string[]; previous: string[] }
45
+ > = {
46
+ x: { next: ["ArrowRight"], previous: ["ArrowLeft"] },
47
+ y: { next: ["ArrowDown"], previous: ["ArrowUp"] },
48
+ point: {
49
+ next: ["ArrowRight", "ArrowDown"],
50
+ previous: ["ArrowLeft", "ArrowUp"],
51
+ },
52
+ };
53
+
54
+ const ACROSS_KEYS: Record<
55
+ ChartFocusOrder,
56
+ { next: string[]; previous: string[] }
57
+ > = {
58
+ x: { next: ["ArrowDown"], previous: ["ArrowUp"] },
59
+ y: { next: ["ArrowRight"], previous: ["ArrowLeft"] },
60
+ point: { next: [], previous: [] },
61
+ };
62
+
63
+ export function useChartFocus({
64
+ stops,
65
+ order,
66
+ hitTest,
67
+ onFocusChange,
68
+ onSelect,
69
+ }: Options) {
70
+ const [state, setState] = React.useState<FocusState | null>(null);
71
+
72
+ const active = React.useMemo<ActiveFocus | null>(() => {
73
+ if (state === null) {
74
+ return null;
75
+ }
76
+ const stop = stops.find((candidate) => candidate.key === state.stopKey);
77
+ const point = stop?.points.find(
78
+ (candidate) => candidate.key === state.pointKey,
79
+ );
80
+ return stop && point
81
+ ? { stop, point, source: state.source, pinned: state.pinned }
82
+ : null;
83
+ }, [state, stops]);
84
+
85
+ const activeKey = active?.point.key;
86
+ const onFocusChangeRef = React.useRef(onFocusChange);
87
+ onFocusChangeRef.current = onFocusChange;
88
+ // biome-ignore lint/correctness/useExhaustiveDependencies: fires on a change of point, not on every render
89
+ React.useEffect(() => {
90
+ onFocusChangeRef.current?.(active?.point ?? null);
91
+ }, [activeKey]);
92
+
93
+ function focusStop(
94
+ index: number,
95
+ source: FocusState["source"],
96
+ pointIndex = 0,
97
+ ) {
98
+ const stop = stops[index];
99
+ if (stop === undefined) {
100
+ return;
101
+ }
102
+ const points = across(stop, order);
103
+ const point = points[Math.min(pointIndex, points.length - 1)];
104
+ setState({ stopKey: stop.key, pointKey: point.key, source, pinned: false });
105
+ }
106
+
107
+ function onKeyDown(event: React.KeyboardEvent<HTMLElement>) {
108
+ if (stops.length === 0) {
109
+ return;
110
+ }
111
+ const stopIndex = active ? stops.indexOf(active.stop) : -1;
112
+ const along = ALONG_KEYS[order];
113
+ const crossing = ACROSS_KEYS[order];
114
+ const { key } = event;
115
+ let handled = true;
116
+ if (along.next.includes(key)) {
117
+ focusStop(
118
+ stopIndex === -1 ? 0 : Math.min(stops.length - 1, stopIndex + 1),
119
+ "keyboard",
120
+ );
121
+ } else if (along.previous.includes(key)) {
122
+ focusStop(stopIndex === -1 ? 0 : Math.max(0, stopIndex - 1), "keyboard");
123
+ } else if (key === "Home") {
124
+ focusStop(0, "keyboard");
125
+ } else if (key === "End") {
126
+ focusStop(stops.length - 1, "keyboard");
127
+ } else if (
128
+ active &&
129
+ (crossing.next.includes(key) || crossing.previous.includes(key))
130
+ ) {
131
+ const points = across(active.stop, order);
132
+ const current = points.indexOf(active.point);
133
+ const step = crossing.next.includes(key) ? 1 : -1;
134
+ const next =
135
+ points[Math.min(points.length - 1, Math.max(0, current + step))];
136
+ setState({
137
+ stopKey: active.stop.key,
138
+ pointKey: next.key,
139
+ source: "keyboard",
140
+ pinned: false,
141
+ });
142
+ } else if ((key === "Enter" || key === " ") && active) {
143
+ setState({
144
+ stopKey: active.stop.key,
145
+ pointKey: active.point.key,
146
+ source: "keyboard",
147
+ pinned: !active.pinned,
148
+ });
149
+ onSelect?.(active.pinned ? null : active.point);
150
+ } else if (key === "Escape" && active) {
151
+ setState(
152
+ active.pinned
153
+ ? {
154
+ stopKey: active.stop.key,
155
+ pointKey: active.point.key,
156
+ source: "keyboard",
157
+ pinned: false,
158
+ }
159
+ : null,
160
+ );
161
+ } else {
162
+ handled = false;
163
+ }
164
+ if (handled) {
165
+ event.preventDefault();
166
+ }
167
+ }
168
+
169
+ function localPoint(
170
+ event: React.PointerEvent<HTMLElement> | React.MouseEvent<HTMLElement>,
171
+ ) {
172
+ const box = event.currentTarget.getBoundingClientRect();
173
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
174
+ }
175
+
176
+ function onPointerMove(event: React.PointerEvent<HTMLElement>) {
177
+ if (active?.pinned) {
178
+ return;
179
+ }
180
+ const { x, y } = localPoint(event);
181
+ const hit = hitTest(x, y);
182
+ if (hit === null) {
183
+ setState(null);
184
+ return;
185
+ }
186
+ const stop = stops[hit.stop];
187
+ const point = stop.points[hit.point];
188
+ if (stop.key !== state?.stopKey || point.key !== state?.pointKey) {
189
+ setState({
190
+ stopKey: stop.key,
191
+ pointKey: point.key,
192
+ source: "pointer",
193
+ pinned: false,
194
+ });
195
+ }
196
+ }
197
+
198
+ function onPointerLeave() {
199
+ if (!active?.pinned) {
200
+ setState(null);
201
+ }
202
+ }
203
+
204
+ function onClick(event: React.MouseEvent<HTMLElement>) {
205
+ const { x, y } = localPoint(event);
206
+ const hit = hitTest(x, y);
207
+ if (hit === null) {
208
+ setState(null);
209
+ onSelect?.(null);
210
+ return;
211
+ }
212
+ const stop = stops[hit.stop];
213
+ const point = stop.points[hit.point];
214
+ const unpin = active?.pinned === true && active.point.key === point.key;
215
+ setState({
216
+ stopKey: stop.key,
217
+ pointKey: point.key,
218
+ source: "pointer",
219
+ pinned: !unpin,
220
+ });
221
+ onSelect?.(unpin ? null : point);
222
+ }
223
+
224
+ function onFocus(event: React.FocusEvent<HTMLElement>) {
225
+ if (state === null && event.currentTarget.matches(":focus-visible")) {
226
+ focusStop(0, "keyboard");
227
+ }
228
+ }
229
+
230
+ function onBlur() {
231
+ setState(null);
232
+ }
233
+
234
+ return {
235
+ active,
236
+ handlers: {
237
+ onKeyDown,
238
+ onPointerMove,
239
+ onPointerLeave,
240
+ onClick,
241
+ onFocus,
242
+ onBlur,
243
+ },
244
+ };
245
+ }
@@ -0,0 +1,27 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * The container's width. The server and the first client render use
5
+ * `initialWidth`, so the hydrated markup matches; the real width arrives on
6
+ * the first observer callback.
7
+ */
8
+ export function useChartWidth(initialWidth: number) {
9
+ const [node, setNode] = React.useState<HTMLDivElement | null>(null);
10
+ const [width, setWidth] = React.useState(initialWidth);
11
+
12
+ React.useEffect(() => {
13
+ if (node === null || typeof ResizeObserver === "undefined") {
14
+ return;
15
+ }
16
+ const observer = new ResizeObserver((entries) => {
17
+ const measured = entries[0]?.contentRect.width;
18
+ if (measured !== undefined && measured > 0) {
19
+ setWidth(Math.round(measured));
20
+ }
21
+ });
22
+ observer.observe(node);
23
+ return () => observer.disconnect();
24
+ }, [node]);
25
+
26
+ return { ref: setNode, width, node };
27
+ }
@@ -0,0 +1,67 @@
1
+ import * as React from "react";
2
+
3
+ /** The narrowest view: a hundredth of the data. */
4
+ const MAX_ZOOM = 100;
5
+
6
+ function clampDomain(
7
+ [from, to]: readonly [number, number],
8
+ [low, high]: readonly [number, number],
9
+ ): readonly [number, number] {
10
+ const span = Math.min(
11
+ high - low,
12
+ Math.max((high - low) / MAX_ZOOM, to - from),
13
+ );
14
+ const start = Math.min(Math.max(from, low), high - span);
15
+ return [start, start + span];
16
+ }
17
+
18
+ /**
19
+ * The part of a continuous x axis on show. `null` is the whole of it. Zooming
20
+ * keeps the value under the pointer (or the focused value) where it is on
21
+ * screen; panning never leaves the data.
22
+ */
23
+ export function useChartZoom(
24
+ full: readonly [number, number] | undefined,
25
+ resetKey: unknown,
26
+ ) {
27
+ const [domain, setDomain] = React.useState<readonly [number, number] | null>(
28
+ null,
29
+ );
30
+ const keyRef = React.useRef(resetKey);
31
+ if (keyRef.current !== resetKey) {
32
+ keyRef.current = resetKey;
33
+ if (domain !== null) {
34
+ setDomain(null);
35
+ }
36
+ }
37
+
38
+ function apply(next: readonly [number, number]) {
39
+ if (full === undefined) return;
40
+ const clamped = clampDomain(next, full);
41
+ setDomain(clamped[0] <= full[0] && clamped[1] >= full[1] ? null : clamped);
42
+ }
43
+
44
+ function zoomAround(center: number, factor: number) {
45
+ if (full === undefined) return;
46
+ const [from, to] = domain ?? full;
47
+ const share = to === from ? 0.5 : (center - from) / (to - from);
48
+ const span = (to - from) * factor;
49
+ apply([center - span * share, center - span * share + span]);
50
+ }
51
+
52
+ function pan(delta: number) {
53
+ if (full === undefined || domain === null) return;
54
+ apply([domain[0] + delta, domain[1] + delta]);
55
+ }
56
+
57
+ return {
58
+ domain,
59
+ zoomAround,
60
+ pan,
61
+ reset: () => setDomain(null),
62
+ span: () => {
63
+ const [from, to] = domain ?? full ?? [0, 1];
64
+ return to - from;
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,49 @@
1
+ import * as React from "react";
2
+ import { estimateTextWidth } from "#/core/text.ts";
3
+ import type { ChartTextMeasurer } from "#/core/types.ts";
4
+
5
+ /**
6
+ * Real text metrics once mounted. Until then, and on the server, the estimate
7
+ * — the same on both sides, so hydration never sees a different layout. A web
8
+ * font that finishes loading later triggers one more measure.
9
+ */
10
+ export function useTextMeasurer(node: HTMLElement | null): ChartTextMeasurer {
11
+ const [measurer, setMeasurer] = React.useState<ChartTextMeasurer>(
12
+ () => estimateTextWidth,
13
+ );
14
+
15
+ React.useEffect(() => {
16
+ if (node === null || typeof document === "undefined") {
17
+ return;
18
+ }
19
+ const context = document.createElement("canvas").getContext("2d");
20
+ if (context === null) {
21
+ return;
22
+ }
23
+ function install() {
24
+ if (node === null || context === null) {
25
+ return;
26
+ }
27
+ const family = getComputedStyle(node).fontFamily || "sans-serif";
28
+ const cache = new Map<string, number>();
29
+ const measure: ChartTextMeasurer = (text, fontSize, fontWeight = 400) => {
30
+ const key = `${fontWeight}:${fontSize}:${text}`;
31
+ const cached = cache.get(key);
32
+ if (cached !== undefined) {
33
+ return cached;
34
+ }
35
+ context.font = `${fontWeight} ${fontSize}px ${family}`;
36
+ const width = context.measureText(text).width;
37
+ cache.set(key, width);
38
+ return width;
39
+ };
40
+ setMeasurer(() => measure);
41
+ }
42
+ install();
43
+ const fonts = document.fonts;
44
+ fonts?.addEventListener?.("loadingdone", install);
45
+ return () => fonts?.removeEventListener?.("loadingdone", install);
46
+ }, [node]);
47
+
48
+ return measurer;
49
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ type SankeyLink,
3
+ type SankeyNode,
4
+ type SankeyOptions,
5
+ sankey,
6
+ } from "#/sankey/sankey.ts";
@@ -0,0 +1,179 @@
1
+ import { sankey as sankeyLayout, sankeyLinkHorizontal } from "d3-sankey";
2
+ import { markId } from "#/core/marks/shared.ts";
3
+ import type { ChartMark, ChartPoint, SceneNode } from "#/core/types.ts";
4
+
5
+ export interface SankeyNode {
6
+ readonly id: string;
7
+ /** Display name. Defaults to the id. */
8
+ readonly label?: string;
9
+ }
10
+
11
+ export interface SankeyLink {
12
+ readonly source: string;
13
+ readonly target: string;
14
+ readonly value: number;
15
+ }
16
+
17
+ export interface SankeyOptions {
18
+ readonly id?: string;
19
+ readonly nodeWidth?: number;
20
+ readonly nodePadding?: number;
21
+ /** What the flows are, for the table heading. */
22
+ readonly label?: string;
23
+ readonly tip?: boolean;
24
+ }
25
+
26
+ interface LaidNode extends SankeyNode {
27
+ x0?: number;
28
+ x1?: number;
29
+ y0?: number;
30
+ y1?: number;
31
+ value?: number;
32
+ }
33
+
34
+ interface LaidLink {
35
+ source: LaidNode;
36
+ target: LaidNode;
37
+ value: number;
38
+ width?: number;
39
+ y0?: number;
40
+ y1?: number;
41
+ }
42
+
43
+ const LABEL_GAP = 6;
44
+
45
+ /**
46
+ * Flows between stages: each node as tall as what passes through it, each
47
+ * link as wide as its flow. Nodes and links are both focusable, so the
48
+ * keyboard reaches "Inscriptions → Missions: 240" as well as each stage.
49
+ */
50
+ export function sankey(
51
+ data: {
52
+ readonly nodes: ReadonlyArray<SankeyNode>;
53
+ readonly links: ReadonlyArray<SankeyLink>;
54
+ },
55
+ options: SankeyOptions = {},
56
+ ): ChartMark {
57
+ const names = new Map(
58
+ data.nodes.map((node) => [node.id, node.label ?? node.id]),
59
+ );
60
+ return {
61
+ id: options.id,
62
+ coordinate: "frame",
63
+ focusOrder: "point",
64
+ titles: { value: options.label },
65
+ channels: {
66
+ color: { values: data.nodes.map((node) => node.label ?? node.id) },
67
+ },
68
+ colorLegend: false,
69
+ render(context) {
70
+ const id = markId(options.id, "sankey", context);
71
+ const { plot, theme, measureText } = context;
72
+ const widest = Math.max(
73
+ 0,
74
+ ...[...names.values()].map((name) => measureText(name, theme.fontSize)),
75
+ );
76
+ const layout = sankeyLayout<LaidNode, LaidLink>()
77
+ .nodeId((node) => node.id)
78
+ .nodeWidth(options.nodeWidth ?? 12)
79
+ .nodePadding(options.nodePadding ?? 12)
80
+ .extent([
81
+ [plot.x, plot.y],
82
+ [plot.x + plot.width - widest - LABEL_GAP, plot.y + plot.height],
83
+ ]);
84
+ const graph = layout({
85
+ nodes: data.nodes.map((node) => ({ ...node })),
86
+ links: data.links.map((link) => ({ ...link })) as unknown as LaidLink[],
87
+ });
88
+ const path = sankeyLinkHorizontal<LaidNode, LaidLink>();
89
+ const nodes: SceneNode[] = [];
90
+ const points: ChartPoint[] = [];
91
+ for (const [index, link] of graph.links.entries()) {
92
+ const from = names.get(link.source.id) ?? link.source.id;
93
+ const to = names.get(link.target.id) ?? link.target.id;
94
+ nodes.push({
95
+ kind: "path",
96
+ key: `${id}:link:${link.source.id}:${link.target.id}`,
97
+ series: from,
98
+ role: "mark",
99
+ d: path(link as never) ?? "",
100
+ paint: {
101
+ fill: "none",
102
+ stroke: context.colorOf(from),
103
+ strokeOpacity: 0.35,
104
+ strokeWidth: Math.max(1, link.width ?? 1),
105
+ },
106
+ });
107
+ if (options.tip !== false) {
108
+ points.push({
109
+ key: `${id}:link:${link.source.id}:${link.target.id}`,
110
+ markId: id,
111
+ index,
112
+ datum: link,
113
+ x: ((link.source.x1 ?? 0) + (link.target.x0 ?? 0)) / 2,
114
+ y: ((link.y0 ?? 0) + (link.y1 ?? 0)) / 2,
115
+ xValue: `${from} → ${to}`,
116
+ yValue: link.value,
117
+ seriesLabel: options.label,
118
+ color: context.colorOf(from),
119
+ title: `${from} → ${to}`,
120
+ value: context.formatY(link.value),
121
+ });
122
+ }
123
+ }
124
+ for (const [index, node] of graph.nodes.entries()) {
125
+ const name = names.get(node.id) ?? node.id;
126
+ const rect = {
127
+ x: node.x0 ?? 0,
128
+ y: node.y0 ?? 0,
129
+ width: (node.x1 ?? 0) - (node.x0 ?? 0),
130
+ height: Math.max(1, (node.y1 ?? 0) - (node.y0 ?? 0)),
131
+ };
132
+ nodes.push(
133
+ {
134
+ kind: "rect",
135
+ key: `${id}:node:${node.id}`,
136
+ series: name,
137
+ role: "mark",
138
+ ...rect,
139
+ paint: { fill: context.colorOf(name) },
140
+ },
141
+ {
142
+ kind: "text",
143
+ key: `${id}:label:${node.id}`,
144
+ role: "label",
145
+ x: rect.x + rect.width + LABEL_GAP,
146
+ y: rect.y + rect.height / 2,
147
+ text: name,
148
+ paint: {
149
+ fill: theme.foreground,
150
+ fontSize: theme.fontSize,
151
+ baseline: "middle",
152
+ },
153
+ },
154
+ );
155
+ if (options.tip !== false) {
156
+ points.push({
157
+ key: `${id}:node:${node.id}`,
158
+ markId: id,
159
+ index: graph.links.length + index,
160
+ datum: node,
161
+ x: rect.x + rect.width / 2,
162
+ y: rect.y + rect.height / 2,
163
+ xValue: name,
164
+ yValue: node.value ?? 0,
165
+ seriesLabel: options.label,
166
+ color: context.colorOf(name),
167
+ title: name,
168
+ value: context.formatY(node.value ?? 0),
169
+ hit: {
170
+ kind: "rect",
171
+ rect: { ...rect, x: rect.x - 4, width: rect.width + 8 },
172
+ },
173
+ });
174
+ }
175
+ }
176
+ return { nodes, points };
177
+ },
178
+ };
179
+ }
@@ -0,0 +1 @@
1
+ export { type VoronoiOptions, voronoi } from "#/voronoi/voronoi.ts";
@@ -0,0 +1,84 @@
1
+ import { Delaunay } from "d3-delaunay";
2
+ import { isPlaceable, readChannel } from "#/core/channel.ts";
3
+ import { markId, valueOn } from "#/core/marks/shared.ts";
4
+ import type {
5
+ ChartAccessor,
6
+ ChartMark,
7
+ ChartValue,
8
+ SceneNode,
9
+ } from "#/core/types.ts";
10
+
11
+ export interface VoronoiOptions<TDatum> {
12
+ readonly id?: string;
13
+ readonly x: ChartAccessor<TDatum, ChartValue>;
14
+ readonly y: ChartAccessor<TDatum, ChartValue>;
15
+ readonly stroke?: string;
16
+ /** Fill each cell, by its own colour, instead of drawing the borders only. */
17
+ readonly fill?: (datum: TDatum, index: number) => string;
18
+ }
19
+
20
+ /**
21
+ * Each point's cell: the area closer to it than to any other. Clipped to the
22
+ * plot. The borders alone read as a mesh over a scatter; filled, the cells
23
+ * become territories.
24
+ */
25
+ export function voronoi<TDatum>(
26
+ data: ReadonlyArray<TDatum>,
27
+ options: VoronoiOptions<TDatum>,
28
+ ): ChartMark {
29
+ const xs = readChannel(data, options.x);
30
+ const ys = readChannel(data, options.y);
31
+ return {
32
+ id: options.id,
33
+ coordinate: "cartesian",
34
+ channels: {
35
+ x: { values: xs.filter(isPlaceable) },
36
+ y: { values: ys.filter(isPlaceable) },
37
+ },
38
+ render(context) {
39
+ const id = markId(options.id, "voronoi", context);
40
+ const { x: xScale, y: yScale } = context.scales;
41
+ if (xScale === undefined || yScale === undefined) {
42
+ return { nodes: [] };
43
+ }
44
+ const { plot } = context;
45
+ const indices: number[] = [];
46
+ const pixels: Array<[number, number]> = [];
47
+ for (const [index, raw] of xs.entries()) {
48
+ const x = valueOn(xScale, raw);
49
+ const y = valueOn(yScale, ys[index]);
50
+ if (x !== undefined && y !== undefined) {
51
+ indices.push(index);
52
+ pixels.push([xScale.center(x), yScale.center(y)]);
53
+ }
54
+ }
55
+ if (pixels.length === 0) {
56
+ return { nodes: [] };
57
+ }
58
+ const diagram = Delaunay.from(pixels).voronoi([
59
+ plot.x,
60
+ plot.y,
61
+ plot.x + plot.width,
62
+ plot.y + plot.height,
63
+ ]);
64
+ const nodes: SceneNode[] = indices.map((dataIndex, cell) => ({
65
+ kind: "path",
66
+ key: `${id}:${dataIndex}`,
67
+ role: "mark",
68
+ d: diagram.renderCell(cell) ?? "",
69
+ paint: options.fill
70
+ ? {
71
+ fill: options.fill(data[dataIndex], dataIndex),
72
+ stroke: context.theme.background,
73
+ strokeWidth: 1,
74
+ }
75
+ : {
76
+ fill: "none",
77
+ stroke: options.stroke ?? context.theme.grid,
78
+ strokeWidth: 1,
79
+ },
80
+ }));
81
+ return { nodes };
82
+ },
83
+ };
84
+ }