@twoux/ui 0.0.0-stage → 0.1.1

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 (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,1331 @@
1
+ /**
2
+ * TwoUX charts, part two: the forms data-heavy B2B and finance screens need beyond charts.tsx.
3
+ * Donut, heatmap, funnel, scatter, share bar, ranked list, change list, price chart, waterfall, bullet chart, treemap, sankey,
4
+ * histogram, box plot and candlestick. Same rules as charts.tsx: own SVG, one axis, series colours
5
+ * in fixed order (c1…c8), status colours only for good and bad, text never in a series colour,
6
+ * one tab stop per chart with arrow keys between marks, and a table helper for every chart.
7
+ */
8
+ import * as React from "react"
9
+ import { cn } from "../lib/cn"
10
+ import { niceTicks, seriesColor, Sparkline } from "./charts"
11
+ import { fmt, MINUS, pct, NEUTRAL, textW, svgCls, halo, useWidth, useActive, niceStep, Tip, type TipRow } from "../lib/chart-kit"
12
+
13
+ /* ---------------------------------------------------------------- shared */
14
+
15
+ /** "+£1,200" / "−£800": a real minus sign, never a hyphen. */
16
+ export const signed = (n: number, f: (n: number) => string = fmt) => `${n > 0 ? "+" : n < 0 ? MINUS : ""}${f(Math.abs(n))}`
17
+
18
+ /** Ticks for a range that need not start at zero. */
19
+ export function rangeTicks(min: number, max: number, count = 4) {
20
+ if (max <= min) max = min + 1
21
+ const step = niceStep((max - min) / count)
22
+ const lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step
23
+ return Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => +(lo + i * step).toFixed(10))
24
+ }
25
+
26
+ /** A legend whose keys mirror the marks: filled box, hollow box or line. */
27
+ export function KeyLegend({ items }: { items: { label: string; color: string; shape?: "box" | "hollow" | "line" }[] }) {
28
+ if (items.length < 2) return null
29
+ return (
30
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
31
+ {items.map((it) => (
32
+ <li key={it.label} className="inline-flex items-center gap-control text-sm text-ink-2">
33
+ <span
34
+ aria-hidden="true"
35
+ className={cn("shrink-0", it.shape === "line" ? "h-3 w-0.5 rounded-full bg-(--c)" : it.shape === "hollow" ? "size-2.5 rounded-xs border-2 border-(--c)" : "size-2.5 rounded-xs bg-(--c)")}
36
+ style={{ "--c": it.color } as React.CSSProperties}
37
+ />
38
+ {it.label}
39
+ </li>
40
+ ))}
41
+ </ul>
42
+ )
43
+ }
44
+
45
+ /** Rounded at the data end, square where the bar starts. */
46
+ function barPath(x: number, yTop: number, yBot: number, w: number, end: "top" | "bottom") {
47
+ const h = Math.max(1, yBot - yTop)
48
+ yBot = yTop + h
49
+ const r = Math.min(4, w / 2, h)
50
+ return end === "top"
51
+ ? `M${x},${yBot}V${yTop + r}Q${x},${yTop} ${x + r},${yTop}H${x + w - r}Q${x + w},${yTop} ${x + w},${yTop + r}V${yBot}Z`
52
+ : `M${x},${yTop}V${yBot - r}Q${x},${yBot} ${x + r},${yBot}H${x + w - r}Q${x + w},${yBot} ${x + w},${yBot - r}V${yTop}Z`
53
+ }
54
+
55
+ /* ---------------------------------------------------------------- change value */
56
+
57
+ /** A change shown with arrow and sign, coloured by whether the direction is good; the word is read out. */
58
+ export function ChangeValue({ value, format = (n) => `${n.toFixed(2)}%`, upIs = "good", className }: { value: number; format?: (n: number) => string; upIs?: "good" | "bad" | "neutral"; className?: string }) {
59
+ const dir = value > 0 ? "up" : value < 0 ? "down" : "flat"
60
+ const tone = upIs === "neutral" || dir === "flat" ? "text-ink-2" : (dir === "up") === (upIs === "good") ? "text-ok" : "text-bad"
61
+ return (
62
+ <span className={cn("tabular whitespace-nowrap", tone, className)}>
63
+ <span aria-hidden="true">{dir === "up" ? "↑" : dir === "down" ? "↓" : "→"} {signed(value, format)}</span>
64
+ <span className="sr-only">{dir === "flat" ? "unchanged" : `${dir} ${format(Math.abs(value))}`}</span>
65
+ </span>
66
+ )
67
+ }
68
+
69
+ /* ---------------------------------------------------------------- share bar */
70
+
71
+ export type SharePart = { id: string; label: string; value: number }
72
+ type FoldedPart = SharePart & { other?: number }
73
+
74
+ /** Keep the largest parts, fold the rest into “Other” so a bar never has more than `max` parts. */
75
+ export function foldParts<T extends SharePart>(parts: T[], max = 6, otherLabel = "Other"): (T | FoldedPart)[] {
76
+ if (parts.length <= max) return parts
77
+ const keep = new Set([...parts].sort((a, b) => b.value - a.value).slice(0, max - 1).map((p) => p.id))
78
+ const rest = parts.filter((p) => !keep.has(p.id))
79
+ return [...parts.filter((p) => keep.has(p.id)), { id: "__other", label: otherLabel, value: rest.reduce((a, p) => a + p.value, 0), other: rest.length }]
80
+ }
81
+
82
+ /** Table view for a share bar or treemap. */
83
+ export const shareTable = (parts: SharePart[], format: (n: number) => string = fmt, first = "Part") => {
84
+ const sum = parts.reduce((a, p) => a + p.value, 0)
85
+ return { columns: [first, "Value", "Share"], rows: parts.map((p) => [p.label, format(p.value), pct(p.value, sum)]) }
86
+ }
87
+
88
+ /**
89
+ * One whole split into up to six parts, read at a glance. Thin (8px, figures in the legend above)
90
+ * or tall (28px, share printed in each block where it fits, names in the legend).
91
+ */
92
+ export function ShareBar({
93
+ parts,
94
+ max = 6,
95
+ variant = "thin",
96
+ format = fmt,
97
+ showValue = false,
98
+ "aria-label": label,
99
+ className,
100
+ }: {
101
+ parts: SharePart[]
102
+ /** Parts past this fold into “Other”. */
103
+ max?: number
104
+ variant?: "thin" | "tall"
105
+ format?: (n: number) => string
106
+ /** Also print each part's amount in the legend (thin only). */
107
+ showValue?: boolean
108
+ "aria-label": string
109
+ className?: string
110
+ }) {
111
+ const [wrap, width] = useWidth<HTMLDivElement>()
112
+ const items = foldParts(parts, max).filter((p) => p.value > 0)
113
+ const sum = items.reduce((a, p) => a + p.value, 0) || 1
114
+ const { active, setActive, onKey } = useActive(items.length)
115
+ const color = (p: FoldedPart, i: number) => (p.other ? NEUTRAL : seriesColor(i))
116
+ const tall = variant === "tall"
117
+ let acc = 0
118
+ const segs = items.map((p, i) => {
119
+ const start = acc / sum
120
+ acc += p.value
121
+ return { p, i, start, share: p.value / sum }
122
+ })
123
+ const cur = active === null ? null : segs[active]
124
+ const summary = `${label}. ${items.map((p) => `${p.label} ${pct(p.value, sum)} (${format(p.value)})`).join(", ")}.`
125
+ return (
126
+ <div className={cn("grid gap-2", className)}>
127
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-hidden="true">
128
+ {segs.map(({ p, i }) => (
129
+ <li key={p.id} className={cn("inline-flex items-center gap-control text-sm transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-60")} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)}>
130
+ <span className="size-2.5 shrink-0 rounded-xs bg-(--c)" style={{ "--c": color(p, i) } as React.CSSProperties} />
131
+ <span className="text-ink-2">{p.label}</span>
132
+ {!tall && <span className="tabular font-medium text-ink">{pct(p.value, sum)}</span>}
133
+ {!tall && showValue && <span className="tabular text-ink-3">{format(p.value)}</span>}
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ <div ref={wrap} className="relative">
138
+ <div
139
+ role="group"
140
+ tabIndex={0}
141
+ aria-roledescription="chart"
142
+ aria-label={summary}
143
+ onKeyDown={onKey}
144
+ onBlur={() => setActive(null)}
145
+ onMouseLeave={() => setActive(null)}
146
+ className={cn("flex gap-0.5 rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary", tall ? "h-7" : "h-2")}
147
+ >
148
+ {segs.map(({ p, i, share }) => {
149
+ const w = share * width
150
+ const lbl = pct(p.value, sum, 0)
151
+ const fits = tall && w >= textW(lbl) + 12
152
+ return (
153
+ <span
154
+ key={p.id}
155
+ onMouseEnter={() => setActive(i)}
156
+ className={cn(
157
+ "flex min-w-0.5 basis-0 grow-(--g) items-center bg-(--c) transition-opacity duration-(--duration-fast)",
158
+ tall ? "rounded-xs px-2" : "first:rounded-l-full last:rounded-r-full",
159
+ active !== null && active !== i && "opacity-55",
160
+ )}
161
+ style={{ "--g": p.value, "--c": color(p, i) } as React.CSSProperties}
162
+ >
163
+ {fits && <span aria-hidden="true" className={cn("tabular text-xs font-medium", "other" in p && p.other ? "text-surface" : i === 3 ? "text-neutral-950" : "text-white")}>{lbl}</span>}
164
+ </span>
165
+ )
166
+ })}
167
+ </div>
168
+ {cur && width > 0 && (
169
+ <Tip
170
+ x={(cur.start + cur.share / 2) * width}
171
+ y={tall ? 34 : 14}
172
+ width={width}
173
+ title={cur.p.label}
174
+ rows={[
175
+ { label: "Share", value: pct(cur.p.value, sum), color: color(cur.p, cur.i) },
176
+ { label: "Value", value: format(cur.p.value) },
177
+ ...((cur.p as FoldedPart).other ? [{ label: "Parts folded in", value: String((cur.p as FoldedPart).other) }] : []),
178
+ ]}
179
+ />
180
+ )}
181
+ </div>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ /* ---------------------------------------------------------------- ranked list */
187
+
188
+ export type RankedItem = {
189
+ id: string
190
+ label: string
191
+ value: number
192
+ /** Change since the comparison period, in the unit formatChange expects (percent by default). */
193
+ change?: number
194
+ spark?: number[]
195
+ href?: string
196
+ /** An icon in place of the colour dot. */
197
+ icon?: React.ReactNode
198
+ /** Series slot when the list is the legend of a chart; defaults to the row's position. */
199
+ colorIndex?: number
200
+ }
201
+
202
+ /** Ranked rows with value and change. Beside a stacked chart it is the chart's legend, so dots follow the series colours. */
203
+ export function RankedList({
204
+ items,
205
+ format = fmt,
206
+ formatChange = (n) => `${n.toFixed(1)}%`,
207
+ upIs = "good",
208
+ dots = true,
209
+ onSelect,
210
+ activeId,
211
+ onActiveChange,
212
+ "aria-label": label,
213
+ className,
214
+ }: {
215
+ items: RankedItem[]
216
+ format?: (n: number) => string
217
+ formatChange?: (n: number) => string
218
+ upIs?: "good" | "bad" | "neutral"
219
+ /** Show a series dot on each row (when the list is a chart legend). */
220
+ dots?: boolean
221
+ onSelect?: (item: RankedItem) => void
222
+ /** Highlight a row, such as the series under the chart's crosshair. */
223
+ activeId?: string | null
224
+ onActiveChange?: (id: string | null) => void
225
+ "aria-label": string
226
+ className?: string
227
+ }) {
228
+ return (
229
+ <ol aria-label={label} className={cn("grid gap-px", className)}>
230
+ {items.map((it, i) => {
231
+ const body = (
232
+ <>
233
+ <span className="tabular w-4 shrink-0 text-right text-xs text-ink-3">{i + 1}</span>
234
+ {it.icon ? <span aria-hidden="true" className="grid size-4 shrink-0 place-items-center text-ink-2 [&_svg]:size-4">{it.icon}</span>
235
+ : dots ? <span aria-hidden="true" className="size-2 shrink-0 rounded-full bg-(--c)" style={{ "--c": seriesColor(it.colorIndex ?? i) } as React.CSSProperties} /> : null}
236
+ <span className="min-w-0 flex-1 truncate text-ink">{it.label}</span>
237
+ {it.spark && <Sparkline data={it.spark} aria-label={`${it.label} trend`} className="h-5 w-14 shrink-0 max-sm:hidden" />}
238
+ <span className="tabular shrink-0 font-medium text-ink">{format(it.value)}</span>
239
+ {it.change !== undefined && <ChangeValue value={it.change} format={formatChange} upIs={upIs} className="w-16 shrink-0 text-right text-xs" />}
240
+ </>
241
+ )
242
+ const cls = cn(
243
+ "flex h-8 w-full items-center gap-2 rounded-xs px-2 text-left text-sm transition-colors duration-(--duration-fast) hover:bg-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary",
244
+ activeId === it.id && "bg-hover",
245
+ )
246
+ const hover = { onMouseEnter: () => onActiveChange?.(it.id), onMouseLeave: () => onActiveChange?.(null), onFocus: () => onActiveChange?.(it.id), onBlur: () => onActiveChange?.(null) }
247
+ return (
248
+ <li key={it.id}>
249
+ {it.href ? <a href={it.href} className={cls} {...hover}>{body}</a>
250
+ : onSelect ? <button type="button" className={cls} onClick={() => onSelect(it)} {...hover}>{body}</button>
251
+ : <div className={cls} {...hover}>{body}</div>}
252
+ </li>
253
+ )
254
+ })}
255
+ </ol>
256
+ )
257
+ }
258
+
259
+ /* ---------------------------------------------------------------- change list */
260
+
261
+ export type ChangeItem = { id: string; name: string; symbol?: string; value: number; unit?: string; change: number; spark?: number[]; href?: string }
262
+
263
+ /** Ticker rows: name and code, value with unit, change by arrow, sign and colour, optional sparkline. */
264
+ export function ChangeList({
265
+ title,
266
+ description,
267
+ items,
268
+ format = (n) => n.toLocaleString("en-GB", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
269
+ formatChange = (n) => `${n.toFixed(2)}%`,
270
+ upIs = "good",
271
+ seeAll,
272
+ level = 3,
273
+ className,
274
+ }: {
275
+ title: string
276
+ description?: string
277
+ /** Heading level of the title. Use 2 when the list sits straight under the page's h1, as on a dashboard. */
278
+ level?: 2 | 3 | 4
279
+ items: ChangeItem[]
280
+ format?: (n: number) => string
281
+ formatChange?: (n: number) => string
282
+ upIs?: "good" | "bad" | "neutral"
283
+ seeAll?: { href: string; label?: string }
284
+ className?: string
285
+ }) {
286
+ const id = React.useId()
287
+ return (
288
+ <section aria-labelledby={`${id}-t`} data-slot="change-list" className={cn("grid gap-2 rounded-sm border border-line bg-surface p-4 shadow-card", className)}>
289
+ <header className="grid gap-0.5">
290
+ {React.createElement(`h${level}`, { id: `${id}-t`, className: "text-md font-semibold text-ink" }, title)}
291
+ {description && <p className="text-sm text-ink-2">{description}</p>}
292
+ </header>
293
+ <ul className="-mx-2 grid divide-y divide-line">
294
+ {items.map((it) => {
295
+ const body = (
296
+ <>
297
+ <span className="grid min-w-0 flex-1">
298
+ <span className="truncate text-sm font-medium text-ink">{it.name}</span>
299
+ {it.symbol && <span className="truncate text-xs text-ink-3">{it.symbol}</span>}
300
+ </span>
301
+ {it.spark && <Sparkline data={it.spark} aria-label={`${it.name} today`} className="h-6 w-16 shrink-0 max-sm:hidden" />}
302
+ <span className="grid shrink-0 justify-items-end">
303
+ <span className="tabular text-sm font-medium text-ink">{format(it.value)}{it.unit && <span className="font-normal text-ink-3">{it.unit.startsWith("%") ? "" : " "}{it.unit}</span>}</span>
304
+ <ChangeValue value={it.change} format={formatChange} upIs={upIs} className="text-xs" />
305
+ </span>
306
+ </>
307
+ )
308
+ const cls = "flex min-h-11 items-center gap-3 rounded-xs px-2 py-2"
309
+ return (
310
+ <li key={it.id}>
311
+ {it.href ? <a href={it.href} className={cn(cls, "transition-colors duration-(--duration-fast) hover:bg-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary")}>{body}</a> : <div className={cls}>{body}</div>}
312
+ </li>
313
+ )
314
+ })}
315
+ </ul>
316
+ {seeAll && <a href={seeAll.href} className="justify-self-start text-sm font-medium text-primary-ink hover:underline">{seeAll.label ?? "See all"}</a>}
317
+ </section>
318
+ )
319
+ }
320
+
321
+ /* ---------------------------------------------------------------- price chart */
322
+
323
+ /**
324
+ * One price over a day: line with a wash to the axis floor, last value tagged on the right axis,
325
+ * an optional dashed reference (previous close) and a live dot. When the range is narrow the axis
326
+ * does not start at zero, and the chart says so under the axis.
327
+ */
328
+ export function PriceChart({
329
+ x,
330
+ data,
331
+ height = 240,
332
+ format = fmt,
333
+ reference,
334
+ live = false,
335
+ baseline = "auto",
336
+ valueLabel = "Price",
337
+ "aria-label": label,
338
+ }: {
339
+ /** Time labels for the whole session, e.g. 08:00 to 16:30. Later slots can be null in data. */
340
+ x: string[]
341
+ data: (number | null)[]
342
+ height?: number
343
+ format?: (n: number) => string
344
+ reference?: { value: number; label: string }
345
+ /** Pulse the last point while the market is open. Still under reduced motion. */
346
+ live?: boolean
347
+ /** auto: start at zero unless the range is narrow; zero: always; range: never. */
348
+ baseline?: "auto" | "zero" | "range"
349
+ valueLabel?: string
350
+ "aria-label": string
351
+ }) {
352
+ const [wrap, width] = useWidth<HTMLDivElement>()
353
+ const n = x.length
354
+ const vals = data.filter((v): v is number => v !== null)
355
+ const last = data.reduce<number>((a, v, i) => (v === null ? a : i), -1)
356
+ const { active, setActive, onKey } = useActive(last + 1)
357
+ const firstV = vals[0] ?? 0, lastV = last >= 0 ? (data[last] as number) : 0
358
+ const lo0 = Math.min(...vals, reference?.value ?? Infinity), hi0 = Math.max(...vals, reference?.value ?? -Infinity)
359
+ const fromRange = baseline === "range" || (baseline === "auto" && lo0 > 0 && (hi0 - lo0) / hi0 < 0.5)
360
+ const ticks = fromRange ? rangeTicks(lo0, hi0) : niceTicks(hi0)
361
+ const lo = ticks[0], hi = ticks[ticks.length - 1]
362
+ const tagW = Math.max(...ticks.map((t) => textW(format(t))), textW(format(lastV))) + 12
363
+ const pad = { t: 10, r: tagW + 6, b: 22, l: 2 }
364
+ const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
365
+ const sx = (i: number) => pad.l + (n <= 1 ? w / 2 : (i / (n - 1)) * w)
366
+ const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
367
+ // contiguous runs, so missing data stays a gap
368
+ const runs: [number, number][][] = []
369
+ data.forEach((v, i) => {
370
+ if (v === null) return
371
+ if (i === 0 || data[i - 1] === null) runs.push([])
372
+ runs[runs.length - 1].push([sx(i), sy(v)])
373
+ })
374
+ const line = (pts: [number, number][]) => pts.map(([px, py], i) => `${i ? "L" : "M"}${px.toFixed(1)},${py.toFixed(1)}`).join("")
375
+ const every = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(w / 64))))
376
+ const tagY = sy(lastV)
377
+ const cur = active !== null && data[active] !== null ? (data[active] as number) : null
378
+ const base = reference?.value ?? firstV
379
+ const summary = `${label}. ${x[0]} to ${x[Math.max(0, last)]}. Opened at ${format(firstV)}, last ${format(lastV)}, high ${format(Math.max(...vals))}, low ${format(Math.min(...vals))}.${fromRange ? ` The axis starts at ${format(lo)}, not zero.` : ""}`
380
+ const onMove = (e: React.MouseEvent<SVGRectElement>) => {
381
+ const r = e.currentTarget.getBoundingClientRect()
382
+ const i = Math.round(((e.clientX - r.left) / (r.width || 1)) * (n - 1))
383
+ setActive(Math.max(0, Math.min(last, i)))
384
+ }
385
+ return (
386
+ <div className="grid gap-1">
387
+ <div ref={wrap} className="relative w-full">
388
+ {width > 0 && (
389
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary}
390
+ onKeyDown={onKey}
391
+ onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
392
+ {ticks.map((t) => (
393
+ <g key={t}>
394
+ <line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === lo ? "stroke-line-strong" : "stroke-line"} />
395
+ {Math.abs(sy(t) - tagY) > 12 && <text x={pad.l + w + 8} y={sy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{format(t)}</text>}
396
+ </g>
397
+ ))}
398
+ {x.map((lbl, i) => i % every === 0 && (
399
+ <text key={lbl + i} x={sx(i)} y={height - 6} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs tabular">{lbl}</text>
400
+ ))}
401
+ {reference && (
402
+ <g>
403
+ <line x1={pad.l} x2={pad.l + w} y1={sy(reference.value)} y2={sy(reference.value)} className="stroke-ink-3" strokeDasharray="4 3" />
404
+ <text x={pad.l + 4} y={sy(reference.value) - 5} strokeWidth={3} className={cn("fill-ink-2 text-xs", halo)}>{reference.label} {format(reference.value)}</text>
405
+ </g>
406
+ )}
407
+ {runs.map((pts, k) => (
408
+ <path key={`a${k}`} d={`${line(pts)}L${pts[pts.length - 1][0]},${pad.t + h}L${pts[0][0]},${pad.t + h}Z`} fill="var(--color-c1)" fillOpacity={0.1} />
409
+ ))}
410
+ {runs.map((pts, k) => <path key={`l${k}`} d={line(pts)} fill="none" stroke="var(--color-c1)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />)}
411
+ {last >= 0 && (
412
+ <g>
413
+ {live && <circle cx={sx(last)} cy={tagY} r={4} fill="var(--color-c1)" className="origin-center opacity-40 [transform-box:fill-box] motion-safe:animate-ping" />}
414
+ <circle cx={sx(last)} cy={tagY} r={4} fill="var(--color-c1)" className="stroke-surface" strokeWidth={2} />
415
+ <rect x={pad.l + w + 2} y={tagY - 9} width={tagW} height={18} rx={3} fill="var(--color-c1)" />
416
+ <text x={pad.l + w + 8} y={tagY} dy="0.32em" className="fill-white text-xs font-medium tabular">{format(lastV)}</text>
417
+ </g>
418
+ )}
419
+ {cur !== null && active !== null && (
420
+ <g>
421
+ <line x1={sx(active)} x2={sx(active)} y1={pad.t} y2={pad.t + h} className="stroke-ink-3" />
422
+ <circle cx={sx(active)} cy={sy(cur)} r={4} fill="var(--color-c1)" className="stroke-surface" strokeWidth={2} />
423
+ </g>
424
+ )}
425
+ <rect x={pad.l} y={pad.t} width={w} height={h} fill="transparent" onMouseMove={onMove} />
426
+ </svg>
427
+ )}
428
+ {cur !== null && active !== null && width > 0 && (
429
+ <Tip x={sx(active)} y={4} width={width} title={x[active]} rows={[
430
+ { label: valueLabel, value: format(cur), color: "var(--color-c1)" },
431
+ { label: `vs ${reference ? reference.label.toLowerCase() : "open"}`, value: `${signed(cur - base, format)} (${signed(((cur - base) / (base || 1)) * 100, (v) => `${v.toFixed(2)}%`)})` },
432
+ ]} />
433
+ )}
434
+ </div>
435
+ {fromRange && <p className="text-right text-xs text-ink-3">Axis starts at {format(lo)}, not zero</p>}
436
+ </div>
437
+ )
438
+ }
439
+
440
+ /* ---------------------------------------------------------------- waterfall */
441
+
442
+ export type WaterfallStep = { id: string; label: string; /** Change, or for a total its level (omit to use the running total). */ value?: number; total?: boolean }
443
+
444
+ function waterfallBars(steps: WaterfallStep[]) {
445
+ let run = 0
446
+ return steps.map((s) => {
447
+ if (s.total) { const lvl = s.value ?? run; run = lvl; return { s, from: 0, to: lvl, delta: lvl, kind: "total" as const } }
448
+ const v = s.value ?? 0, from = run
449
+ run += v
450
+ return { s, from, to: run, delta: v, kind: v >= 0 ? ("increase" as const) : ("decrease" as const) }
451
+ })
452
+ }
453
+
454
+ export const waterfallTable = (steps: WaterfallStep[], format: (n: number) => string = fmt) => ({
455
+ columns: ["Step", "Change", "Running total"],
456
+ rows: waterfallBars(steps).map((b) => [b.s.label, b.kind === "total" ? "Total" : signed(b.delta, format), format(b.to)]),
457
+ })
458
+
459
+ /** Opening, increases, decreases, closing on one axis. Increases and decreases in two categorical colours with +/− labels; totals neutral. */
460
+ export function Waterfall({
461
+ steps,
462
+ height = 240,
463
+ format = fmt,
464
+ labels = { increase: "Increase", decrease: "Decrease", total: "Total" },
465
+ "aria-label": label,
466
+ }: {
467
+ steps: WaterfallStep[]
468
+ height?: number
469
+ format?: (n: number) => string
470
+ labels?: { increase: string; decrease: string; total: string }
471
+ "aria-label": string
472
+ }) {
473
+ const [wrap, width] = useWidth<HTMLDivElement>()
474
+ const bars = waterfallBars(steps)
475
+ const n = bars.length
476
+ const { active, setActive, onKey } = useActive(n)
477
+ const lo0 = Math.min(0, ...bars.map((b) => Math.min(b.from, b.to))), hi0 = Math.max(...bars.map((b) => Math.max(b.from, b.to)))
478
+ const ticks = lo0 < 0 ? rangeTicks(lo0, hi0) : niceTicks(hi0)
479
+ const lo = ticks[0], hi = ticks[ticks.length - 1]
480
+ const padL = Math.max(...ticks.map((t) => textW(format(t)))) + 10
481
+ const band = n ? Math.max(0, width - padL - 4) / n : 0
482
+ const pad = { t: 18, r: 4, b: band > 0 && band < 64 && Math.max(...bars.map((b) => b.s.label.length)) * 6.4 + 4 > band ? 36 : 22, l: padL }
483
+ const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
484
+ const bw = Math.min(band * 0.6, 36)
485
+ const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
486
+ const bx = (i: number) => pad.l + band * i + (band - bw) / 2
487
+ const colour = { increase: "var(--color-c1)", decrease: "var(--color-c2)", total: NEUTRAL }
488
+ const stagger = band > 0 && Math.max(...bars.map((b) => b.s.label.length)) * 6.4 + 4 > band && band < 64
489
+ const maxChars = Math.max(2, Math.floor(((stagger ? band * 2 : band) - 6) / 6.4))
490
+ const short = (s: string) => (s.length > maxChars ? `${s.slice(0, maxChars - 1)}…` : s)
491
+ const summary = `${label}. ${bars.map((b) => `${b.s.label} ${b.kind === "total" ? format(b.to) : signed(b.delta, format)}`).join(", ")}.`
492
+ const cur = active === null ? null : bars[active]
493
+ return (
494
+ <div className="grid gap-3">
495
+ <div ref={wrap} className="relative w-full">
496
+ {width > 0 && (
497
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
498
+ {ticks.map((t) => (
499
+ <g key={t}>
500
+ <line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
501
+ <text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{format(t)}</text>
502
+ </g>
503
+ ))}
504
+ {bars.map((b, i) => i < n - 1 && (
505
+ <line key={`c${i}`} x1={bx(i) + bw} x2={bx(i + 1)} y1={sy(b.to)} y2={sy(b.to)} className="stroke-ink-3" strokeOpacity={0.6} />
506
+ ))}
507
+ {bars.map((b, i) => {
508
+ const top = sy(Math.max(b.from, b.to)), bot = sy(Math.min(b.from, b.to))
509
+ const txt = b.kind === "total" ? format(b.to) : signed(b.delta, format)
510
+ const fits = textW(txt) <= band - 2
511
+ const below = b.kind === "decrease" && bot + 14 < pad.t + h
512
+ return (
513
+ <g key={b.s.id} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
514
+ <rect x={pad.l + band * i} y={pad.t} width={band} height={h} fill="transparent" />
515
+ <path d={barPath(bx(i), top, bot, bw, b.kind === "decrease" ? "bottom" : "top")} fill={colour[b.kind]} />
516
+ {fits && <text x={bx(i) + bw / 2} y={below ? bot + 12 : top - 5} textAnchor="middle" className="fill-ink-2 text-xs font-medium tabular">{txt}</text>}
517
+ <text x={pad.l + band * i + band / 2} y={height - 6 - (stagger && i % 2 === 0 ? 14 : 0)} textAnchor="middle" className="fill-ink-3 text-xs">{short(b.s.label)}</text>
518
+ </g>
519
+ )
520
+ })}
521
+ </svg>
522
+ )}
523
+ {cur && active !== null && width > 0 && (
524
+ <Tip x={bx(active) + bw} y={Math.max(0, sy(Math.max(cur.from, cur.to)) - 20)} width={width} title={cur.s.label} rows={
525
+ cur.kind === "total"
526
+ ? [{ label: labels.total, value: format(cur.to), color: NEUTRAL }]
527
+ : [{ label: labels[cur.kind], value: signed(cur.delta, format), color: colour[cur.kind] }, { label: "Running total", value: format(cur.to) }]
528
+ } />
529
+ )}
530
+ </div>
531
+ <KeyLegend items={[{ label: labels.increase, color: colour.increase }, { label: labels.decrease, color: colour.decrease }, { label: labels.total, color: NEUTRAL }]} />
532
+ </div>
533
+ )
534
+ }
535
+
536
+ /* ---------------------------------------------------------------- bullet chart */
537
+
538
+ export type BulletRow = {
539
+ id: string
540
+ label: string
541
+ value: number
542
+ target: number
543
+ /** Upper edges of the qualitative bands, ascending, e.g. [300000, 400000, 500000]. */
544
+ bands: number[]
545
+ }
546
+ const BAND_FILL = ["bg-input", "bg-line-strong", "bg-hover", "bg-surface-2"]
547
+
548
+ function BulletRowView({ r, bandLabels, format }: { r: BulletRow; bandLabels: string[]; format: (n: number) => string }) {
549
+ const [wrap, width] = useWidth<HTMLDivElement>()
550
+ const [on, setOn] = React.useState(false)
551
+ const domain = Math.max(r.value, r.target, ...r.bands)
552
+ const ticks = niceTicks(domain, Math.max(2, Math.min(5, Math.floor(width / 72)))).filter((t) => t <= domain)
553
+ const top = domain || 1
554
+ const p = (v: number) => `${Math.min(100, Math.max(0, (v / top) * 100))}%`
555
+ const band = Math.max(0, r.bands.findIndex((b) => r.value <= b))
556
+ const bandName = bandLabels[band] ?? bandLabels[bandLabels.length - 1]
557
+ const of = pct(r.value, r.target, 0)
558
+ const desc = `${r.label}: ${format(r.value)} against a target of ${format(r.target)}, ${of} of target, in the ${bandName.toLowerCase()} band.`
559
+ return (
560
+ <li tabIndex={0} aria-label={desc} onMouseEnter={() => setOn(true)} onMouseLeave={() => setOn(false)} onFocus={() => setOn(true)} onBlur={() => setOn(false)}
561
+ className="relative grid gap-1 rounded-xs focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary">
562
+ <div className="flex items-baseline justify-between gap-3 text-sm" aria-hidden="true">
563
+ <span className="min-w-0 truncate text-ink-2">{r.label}</span>
564
+ <span className="tabular shrink-0"><span className="font-medium text-ink">{format(r.value)}</span><span className="text-ink-3"> of {format(r.target)}</span></span>
565
+ </div>
566
+ <div ref={wrap} className="relative h-6" aria-hidden="true">
567
+ {r.bands.map((b, i) => {
568
+ const prev = i ? r.bands[i - 1] : 0
569
+ return <span key={i} className={cn("absolute inset-y-0 left-(--l) w-(--w) first:rounded-l-xs", BAND_FILL[i], i === r.bands.length - 1 && "rounded-r-xs")} style={{ "--l": p(prev), "--w": `calc(${p(b)} - ${p(prev)})` } as React.CSSProperties} />
570
+ })}
571
+ <span className="absolute top-2 left-0 h-2 w-(--w) rounded-r-xs bg-c1" style={{ "--w": p(r.value) } as React.CSSProperties} />
572
+ <span className="absolute inset-y-0.5 left-(--l) w-0.5 -translate-x-1/2 rounded-full bg-ink" style={{ "--l": p(r.target) } as React.CSSProperties} />
573
+ </div>
574
+ <div className="relative h-4 text-xs text-ink-3" aria-hidden="true">
575
+ {ticks.map((t, i) => (
576
+ <span key={t} className={cn("tabular absolute top-0 left-(--l) whitespace-nowrap", i === 0 ? "" : t / top > 0.9 ? "-translate-x-full" : "-translate-x-1/2")} style={{ "--l": p(t) } as React.CSSProperties}>{format(t)}</span>
577
+ ))}
578
+ </div>
579
+ {on && width > 0 && (
580
+ <Tip x={(Math.min(r.target, top) / top) * width} y={52} width={width} title={r.label} rows={[
581
+ { label: "Actual", value: format(r.value), color: "var(--color-c1)" },
582
+ { label: "Target", value: format(r.target), color: "var(--color-ink)" },
583
+ { label: "Of target", value: of },
584
+ { label: "Band", value: bandName },
585
+ ]} />
586
+ )}
587
+ </li>
588
+ )
589
+ }
590
+
591
+ /** Actual against target over qualitative bands in neutral steps. The alternative to a gauge. */
592
+ export function BulletChart({ rows, bandLabels = ["Poor", "Satisfactory", "Good"], format = fmt, "aria-label": label }: { rows: BulletRow[]; bandLabels?: string[]; format?: (n: number) => string; "aria-label": string }) {
593
+ return (
594
+ <div className="grid gap-4">
595
+ <ul aria-label={label} className="grid gap-4">
596
+ {rows.map((r) => <BulletRowView key={r.id} r={r} bandLabels={bandLabels} format={format} />)}
597
+ </ul>
598
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
599
+ <li className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className="h-2 w-3 rounded-xs bg-c1" />Actual</li>
600
+ <li className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className="h-3 w-0.5 rounded-full bg-ink" />Target</li>
601
+ {bandLabels.slice(0, Math.max(...rows.map((r) => r.bands.length))).map((b, i) => (
602
+ <li key={b} className="inline-flex items-center gap-control text-sm text-ink-2"><span aria-hidden="true" className={cn("size-2.5 rounded-xs", BAND_FILL[i])} />{b}</li>
603
+ ))}
604
+ </ul>
605
+ </div>
606
+ )
607
+ }
608
+
609
+ /* ---------------------------------------------------------------- treemap */
610
+
611
+ export type TreemapItem = SharePart & { /** Up to eight parent groups, each its own colour with a legend. */ group?: string }
612
+ type Rect = { x: number; y: number; w: number; h: number }
613
+
614
+ function squarify(areas: { i: number; a: number }[], r: Rect) {
615
+ const out: (Rect & { i: number })[] = []
616
+ let rect = { ...r }
617
+ let row: { i: number; a: number }[] = []
618
+ const worst = (rw: { a: number }[], len: number) => {
619
+ const s = rw.reduce((x, it) => x + it.a, 0)
620
+ const mx = Math.max(...rw.map((it) => it.a)), mn = Math.min(...rw.map((it) => it.a))
621
+ return Math.max((len * len * mx) / (s * s), (s * s) / (len * len * mn))
622
+ }
623
+ const place = () => {
624
+ const s = row.reduce((x, it) => x + it.a, 0)
625
+ if (rect.w >= rect.h) {
626
+ const cw = s / rect.h
627
+ let yy = rect.y
628
+ for (const it of row) { const hh = it.a / cw; out.push({ i: it.i, x: rect.x, y: yy, w: cw, h: hh }); yy += hh }
629
+ rect = { x: rect.x + cw, y: rect.y, w: rect.w - cw, h: rect.h }
630
+ } else {
631
+ const rh = s / rect.w
632
+ let xx = rect.x
633
+ for (const it of row) { const ww = it.a / rh; out.push({ i: it.i, x: xx, y: rect.y, w: ww, h: rh }); xx += ww }
634
+ rect = { x: rect.x, y: rect.y + rh, w: rect.w, h: rect.h - rh }
635
+ }
636
+ row = []
637
+ }
638
+ const rest = [...areas]
639
+ while (rest.length) {
640
+ const len = Math.min(rect.w, rect.h)
641
+ if (!row.length || worst([...row, rest[0]], len) <= worst(row, len)) row.push(rest.shift()!)
642
+ else place()
643
+ }
644
+ if (row.length) place()
645
+ return out
646
+ }
647
+
648
+ /** Break a label into lines that fit; an empty result means a single word does not fit. */
649
+ function wrapLabel(label: string, width: number) {
650
+ const out: string[] = []
651
+ for (const word of label.split(" ")) {
652
+ if (textW(word) > width) return []
653
+ const last = out[out.length - 1]
654
+ if (last !== undefined && textW(`${last} ${word}`) <= width) out[out.length - 1] = `${last} ${word}`
655
+ else out.push(word)
656
+ }
657
+ return out
658
+ }
659
+
660
+ /** Squarified treemap: area by value, labels only where they fit, value in the tooltip, at most `max` tiles then Other. */
661
+ export function Treemap({ items, max = 12, height = 260, format = fmt, "aria-label": label }: { items: TreemapItem[]; max?: number; height?: number; format?: (n: number) => string; "aria-label": string }) {
662
+ const [wrap, width] = useWidth<HTMLDivElement>()
663
+ const folded = foldParts(items, max).filter((p) => p.value > 0) as (TreemapItem & { other?: number })[]
664
+ const tiles = [...folded].sort((a, b) => (a.other ? 1 : b.other ? -1 : b.value - a.value))
665
+ const groups = [...new Set(items.map((it) => it.group).filter((g): g is string => !!g))].slice(0, 8)
666
+ const sum = tiles.reduce((a, t) => a + t.value, 0) || 1
667
+ const { active, setActive, onKey } = useActive(tiles.length)
668
+ const rects = width > 0 ? squarify(tiles.map((t, i) => ({ i, a: (t.value / sum) * width * height })), { x: 0, y: 0, w: width, h: height }) : []
669
+ const slot = (t: (typeof tiles)[number]) => (t.other ? -1 : t.group ? groups.indexOf(t.group) : 0)
670
+ const fill = (s: number) => (s < 0 ? NEUTRAL : seriesColor(s))
671
+ const ink = (s: number) => (s < 0 ? "fill-surface" : s === 3 ? "fill-neutral-950" : "fill-white")
672
+ const curR = active === null ? null : rects.find((r) => r.i === active)
673
+ const cur = active === null ? null : tiles[active]
674
+ const summary = `${label}. ${tiles.map((t) => `${t.label} ${format(t.value)} (${pct(t.value, sum)})`).join(", ")}.`
675
+ return (
676
+ <div className="grid gap-3">
677
+ <div ref={wrap} className="relative w-full">
678
+ {width > 0 && (
679
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
680
+ {rects.map((r) => {
681
+ const t = tiles[r.i], s = slot(t)
682
+ const x = r.x + 1, y = r.y + 1, w = Math.max(0, r.w - 2), h = Math.max(0, r.h - 2)
683
+ const share = pct(t.value, sum)
684
+ const lines = wrapLabel(t.label, w - 12)
685
+ const nameFits = lines.length > 0 && lines.length <= 2 && h >= 8 + lines.length * 14
686
+ const shareFits = nameFits && h >= 24 + lines.length * 14 && w >= textW(share) + 12
687
+ return (
688
+ <g key={t.id} onMouseEnter={() => setActive(r.i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== r.i && "opacity-60")}>
689
+ <rect x={x} y={y} width={w} height={h} rx={3} fill={fill(s)} />
690
+ {nameFits && <text x={x + 6} y={y + 16} className={cn("text-xs font-medium", ink(s))}>{lines.map((ln, k) => <tspan key={k} x={x + 6} dy={k ? 14 : 0}>{ln}</tspan>)}</text>}
691
+ {shareFits && <text x={x + 6} y={y + 17 + lines.length * 14} className={cn("text-xs tabular opacity-90", ink(s))}>{share}</text>}
692
+ </g>
693
+ )
694
+ })}
695
+ </svg>
696
+ )}
697
+ {cur && curR && (
698
+ <Tip x={curR.x + curR.w / 2} y={Math.min(curR.y + 8, height - 90)} width={width} title={cur.label} rows={[
699
+ { label: "Value", value: format(cur.value), color: fill(slot(cur)) },
700
+ { label: "Share", value: pct(cur.value, sum) },
701
+ ...(cur.group ? [{ label: "Group", value: cur.group }] : []),
702
+ ...(cur.other ? [{ label: "Items folded in", value: String(cur.other) }] : []),
703
+ ]} />
704
+ )}
705
+ </div>
706
+ <KeyLegend items={groups.map((g, i) => ({ label: g, color: seriesColor(i) }))} />
707
+ </div>
708
+ )
709
+ }
710
+
711
+ /* ---------------------------------------------------------------- sankey */
712
+
713
+ export type SankeyNode = { id: string; label: string }
714
+ export type SankeyLink = { source: string; target: string; value: number }
715
+
716
+ export const sankeyTable = (nodes: SankeyNode[], links: SankeyLink[], format: (n: number) => string = fmt) => {
717
+ const name = (id: string) => nodes.find((n) => n.id === id)?.label ?? id
718
+ return { columns: ["From", "To", "Value"], rows: links.map((l) => [name(l.source), name(l.target), format(l.value)]) }
719
+ }
720
+
721
+ /** Left-to-right flows in two or three columns. Link width by value; hover or arrow keys highlight a path. */
722
+ export function SankeyChart({ nodes, links, height = 280, format = fmt, "aria-label": label }: { nodes: SankeyNode[]; links: SankeyLink[]; height?: number; format?: (n: number) => string; "aria-label": string }) {
723
+ const [wrap, width] = useWidth<HTMLDivElement>()
724
+ const [hoverNode, setHoverNode] = React.useState<string | null>(null)
725
+ // columns: longest path from a source
726
+ const col: Record<string, number> = {}
727
+ nodes.forEach((n) => (col[n.id] = 0))
728
+ for (let k = 0; k < nodes.length; k++) for (const l of links) col[l.target] = Math.max(col[l.target], col[l.source] + 1)
729
+ const C = Math.max(...Object.values(col)) + 1
730
+ const inSum = (id: string) => links.filter((l) => l.target === id).reduce((a, l) => a + l.value, 0)
731
+ const outSum = (id: string) => links.filter((l) => l.source === id).reduce((a, l) => a + l.value, 0)
732
+ const val = (id: string) => Math.max(inSum(id), outSum(id))
733
+ const cols = Array.from({ length: C }, (_, c) => nodes.filter((n) => col[n.id] === c))
734
+ const gap = 16, nodeW = 8, pad = { t: 4, b: 4 }
735
+ const H = height - pad.t - pad.b
736
+ const k = Math.min(...cols.map((cn_) => (H - gap * (cn_.length - 1)) / (cn_.reduce((a, n) => a + val(n.id), 0) || 1)))
737
+ const colX = (c: number) => (C <= 1 ? 0 : (c / (C - 1)) * (width - nodeW))
738
+ const pos: Record<string, { x: number; y: number; h: number }> = {}
739
+ cols.forEach((cn_, c) => {
740
+ const used = cn_.reduce((a, n) => a + val(n.id) * k, 0) + gap * (cn_.length - 1)
741
+ let y = pad.t + (H - used) / 2
742
+ cn_.forEach((n) => { const h = Math.max(2, val(n.id) * k); pos[n.id] = { x: colX(c), y, h }; y += h + gap })
743
+ })
744
+ const colTotal = (c: number) => cols[c].reduce((a, n) => a + val(n.id), 0)
745
+ // link offsets
746
+ const order = links.map((l, i) => ({ l, i })).sort((a, b) => col[a.l.source] - col[b.l.source] || pos[a.l.source].y - pos[b.l.source].y || pos[a.l.target].y - pos[b.l.target].y)
747
+ const outOff: Record<string, number> = {}, inOff: Record<string, number> = {}
748
+ const geo: { i: number; d: string; mid: [number, number] }[] = []
749
+ if (width > 0) {
750
+ const bySrc = [...order].sort((a, b) => pos[a.l.target].y - pos[b.l.target].y)
751
+ const sy0: Record<number, number> = {}, ty0: Record<number, number> = {}
752
+ for (const { l, i } of bySrc) { sy0[i] = pos[l.source].y + (outOff[l.source] ?? 0); outOff[l.source] = (outOff[l.source] ?? 0) + l.value * k }
753
+ const byTgt = [...order].sort((a, b) => pos[a.l.source].y - pos[b.l.source].y)
754
+ for (const { l, i } of byTgt) { ty0[i] = pos[l.target].y + (inOff[l.target] ?? 0); inOff[l.target] = (inOff[l.target] ?? 0) + l.value * k }
755
+ for (const { l, i } of order) {
756
+ const t = l.value * k, x0 = pos[l.source].x + nodeW, x1 = pos[l.target].x, xm = (x0 + x1) / 2
757
+ const a = sy0[i], b = ty0[i]
758
+ geo.push({ i, d: `M${x0},${a}C${xm},${a} ${xm},${b} ${x1},${b}L${x1},${b + t}C${xm},${b + t} ${xm},${a + t} ${x0},${a + t}Z`, mid: [xm, (a + b) / 2 + t / 2] })
759
+ }
760
+ }
761
+ const { active, setActive, onKey } = useActive(geo.length)
762
+ const activeLink = active === null ? null : geo[active]?.i ?? null
763
+ // highlighted path: a link, or every link through a node (upstream and downstream)
764
+ const lit = new Set<number>()
765
+ if (activeLink !== null) lit.add(activeLink)
766
+ if (hoverNode) {
767
+ const walk = (id: string, dir: "up" | "down") => links.forEach((l, i) => {
768
+ if (dir === "down" && l.source === id && !lit.has(i)) { lit.add(i); walk(l.target, "down") }
769
+ if (dir === "up" && l.target === id && !lit.has(i)) { lit.add(i); walk(l.source, "up") }
770
+ })
771
+ walk(hoverNode, "down"); walk(hoverNode, "up")
772
+ }
773
+ const any = lit.size > 0
774
+ const litNodes = new Set([...lit].flatMap((i) => [links[i].source, links[i].target]))
775
+ const name = (id: string) => nodes.find((n) => n.id === id)?.label ?? id
776
+ const roomy = C <= 1 || (width - nodeW) / (C - 1) >= 230
777
+ const cur = activeLink === null ? null : links[activeLink]
778
+ const curGeo = active === null ? null : geo[active]
779
+ const summary = `${label}. ${links.map((l) => `${name(l.source)} to ${name(l.target)}: ${format(l.value)}`).join("; ")}.`
780
+ return (
781
+ <div ref={wrap} className="relative w-full">
782
+ {width > 0 && (
783
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => { setActive(null); setHoverNode(null) }} className={svgCls}>
784
+ {geo.map((g, gi) => (
785
+ <path key={g.i} d={g.d} fill="var(--color-c1)" fillOpacity={!any ? 0.2 : lit.has(g.i) ? 0.45 : 0.06} onMouseEnter={() => { setHoverNode(null); setActive(gi) }} className="transition-[fill-opacity] duration-(--duration-fast)" />
786
+ ))}
787
+ {nodes.map((n) => {
788
+ const p = pos[n.id], c = col[n.id], lastCol = c === C - 1 && C > 1
789
+ const share = pct(val(n.id), colTotal(c), 0)
790
+ return (
791
+ <g key={n.id} onMouseEnter={() => { setActive(null); setHoverNode(n.id) }} className={cn("transition-opacity duration-(--duration-fast)", any && !litNodes.has(n.id) && "opacity-50")}>
792
+ <rect x={p.x} y={p.y} width={nodeW} height={p.h} rx={2} fill="var(--color-c1)" />
793
+ <rect x={p.x - 4} y={p.y - 4} width={nodeW + 8} height={p.h + 8} fill="transparent" />
794
+ <text x={lastCol ? p.x - 6 : p.x + nodeW + 6} y={p.y + p.h / 2} dy="0.32em" textAnchor={lastCol ? "end" : "start"} strokeWidth={3} className={cn("text-xs", halo)}>
795
+ <tspan className="fill-ink font-medium">{n.label}</tspan>
796
+ {roomy && <tspan className="fill-ink-2 tabular"> {format(val(n.id))} · {share}</tspan>}
797
+ </text>
798
+ </g>
799
+ )
800
+ })}
801
+ </svg>
802
+ )}
803
+ {cur && curGeo && (
804
+ <Tip x={curGeo.mid[0]} y={Math.min(curGeo.mid[1], height - 80)} width={width} title={`${name(cur.source)} → ${name(cur.target)}`} rows={[
805
+ { label: "Value", value: format(cur.value), color: "var(--color-c1)" },
806
+ { label: `Of ${name(cur.source)}`, value: pct(cur.value, outSum(cur.source) || 1) },
807
+ ]} />
808
+ )}
809
+ {hoverNode && !cur && pos[hoverNode] && (
810
+ <Tip x={pos[hoverNode].x + nodeW} y={Math.min(pos[hoverNode].y, height - 80)} width={width} title={name(hoverNode)} rows={[
811
+ { label: "Total", value: format(val(hoverNode)), color: "var(--color-c1)" },
812
+ { label: "Of column", value: pct(val(hoverNode), colTotal(col[hoverNode])) },
813
+ ]} />
814
+ )}
815
+ </div>
816
+ )
817
+ }
818
+
819
+ /* ---------------------------------------------------------------- histogram */
820
+
821
+ export type Bin = { from: number; to: number; count: number }
822
+
823
+ /** Count values into equal, round-edged bins. */
824
+ export function binValues(values: number[], { bins = 10, binWidth, start }: { bins?: number; binWidth?: number; start?: number } = {}): Bin[] {
825
+ if (!values.length) return []
826
+ const lo = Math.min(...values), hi = Math.max(...values)
827
+ const step = binWidth ?? niceStep((hi - lo) / bins || 1)
828
+ const s0 = start ?? Math.floor(lo / step) * step
829
+ const n = Math.max(1, Math.floor((hi - s0) / step) + 1)
830
+ const out = Array.from({ length: n }, (_, i) => ({ from: +(s0 + i * step).toFixed(10), to: +(s0 + (i + 1) * step).toFixed(10), count: 0 }))
831
+ for (const v of values) out[Math.min(n - 1, Math.max(0, Math.floor((v - s0) / step)))].count++
832
+ return out
833
+ }
834
+
835
+ export const histogramTable = (bins: Bin[], format: (n: number) => string = fmt, first = "Range") => ({
836
+ columns: [first, "Count"],
837
+ rows: bins.map((b) => [`${format(b.from)} to ${format(b.to)}`, b.count]),
838
+ })
839
+
840
+ /** How values spread: touching columns, one per bin, counted on one axis. */
841
+ export function Histogram({
842
+ bins,
843
+ height = 220,
844
+ format = fmt,
845
+ xTitle,
846
+ countLabel = "records",
847
+ marker,
848
+ "aria-label": label,
849
+ }: {
850
+ bins: Bin[]
851
+ height?: number
852
+ /** Formats bin edges. */
853
+ format?: (n: number) => string
854
+ xTitle: string
855
+ /** Plural noun for what is counted, e.g. “roles”. */
856
+ countLabel?: string
857
+ /** A dashed reference such as the median. */
858
+ marker?: { value: number; label: string }
859
+ "aria-label": string
860
+ }) {
861
+ const [wrap, width] = useWidth<HTMLDivElement>()
862
+ const n = bins.length
863
+ const { active, setActive, onKey } = useActive(n)
864
+ const ticks = niceTicks(Math.max(...bins.map((b) => b.count), 1))
865
+ const top = ticks[ticks.length - 1] || 1
866
+ const lo = bins[0]?.from ?? 0, hi = bins[n - 1]?.to ?? 1
867
+ const pad = { t: 16, r: textW(format(hi)) / 2 + 2, b: 38, l: Math.max(...ticks.map((t) => textW(fmt(t)))) + 10 }
868
+ const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
869
+ const band = n ? w / n : 0
870
+ const sx = (v: number) => pad.l + ((v - lo) / (hi - lo || 1)) * w
871
+ const sy = (v: number) => pad.t + h - (v / top) * h
872
+ const edges = [...bins.map((b) => b.from), hi]
873
+ const labelEvery = Math.max(1, Math.ceil((Math.max(...edges.map((e) => textW(format(e)))) + 12) / (band || 1)))
874
+ const cur = active === null ? null : bins[active]
875
+ const total = bins.reduce((a, b) => a + b.count, 0)
876
+ const peak = bins.reduce((a, b) => (b.count > a.count ? b : a), bins[0] ?? { from: 0, to: 0, count: 0 })
877
+ const summary = `${label}. ${total} ${countLabel} in ${n} bins from ${format(lo)} to ${format(hi)}. Most common: ${format(peak.from)} to ${format(peak.to)} with ${peak.count}.`
878
+ return (
879
+ <div ref={wrap} className="relative w-full">
880
+ {width > 0 && (
881
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
882
+ {ticks.map((t) => (
883
+ <g key={t}>
884
+ <line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
885
+ <text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{fmt(t)}</text>
886
+ </g>
887
+ ))}
888
+ {edges.map((e, i) => i % labelEvery === 0 && (
889
+ <text key={e} x={sx(e)} y={pad.t + h + 14} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs tabular">{format(e)}</text>
890
+ ))}
891
+ <text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
892
+ {bins.map((b, i) => (
893
+ <g key={i} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
894
+ <rect x={pad.l + band * i} y={pad.t} width={band} height={h} fill="transparent" />
895
+ {b.count > 0 && <path d={barPath(pad.l + band * i + 1, sy(b.count), sy(0), Math.max(1, band - 2), "top")} fill="var(--color-c1)" />}
896
+ </g>
897
+ ))}
898
+ {marker && (
899
+ <g>
900
+ <line x1={sx(marker.value)} x2={sx(marker.value)} y1={pad.t - 4} y2={pad.t + h} className="stroke-ink" strokeDasharray="4 3" />
901
+ <text x={sx(marker.value) + 4} y={pad.t - 6} strokeWidth={3} className={cn("fill-ink-2 text-xs", halo)}>{marker.label}</text>
902
+ </g>
903
+ )}
904
+ </svg>
905
+ )}
906
+ {cur && active !== null && (
907
+ <Tip x={pad.l + band * (active + 1)} y={Math.max(0, sy(cur.count) - 30)} width={width} title={`${format(cur.from)} to ${format(cur.to)}`} rows={[
908
+ { label: countLabel[0].toUpperCase() + countLabel.slice(1), value: fmt(cur.count), color: "var(--color-c1)" },
909
+ { label: "Of all", value: pct(cur.count, total || 1) },
910
+ ]} />
911
+ )}
912
+ </div>
913
+ )
914
+ }
915
+
916
+ /* ---------------------------------------------------------------- box plot */
917
+
918
+ export type BoxStats = { min: number; q1: number; median: number; q3: number; max: number; outliers: number[] }
919
+ const quantile = (s: number[], q: number) => {
920
+ const p = (s.length - 1) * q, b = Math.floor(p)
921
+ return s[b + 1] !== undefined ? s[b] + (p - b) * (s[b + 1] - s[b]) : s[b]
922
+ }
923
+ /** Median, quartiles, whiskers at 1.5 × IQR and the points beyond them. */
924
+ export function boxStats(values: number[]): BoxStats {
925
+ const s = [...values].sort((a, b) => a - b)
926
+ const q1 = quantile(s, 0.25), q3 = quantile(s, 0.75), iqr = q3 - q1
927
+ const inside = s.filter((v) => v >= q1 - 1.5 * iqr && v <= q3 + 1.5 * iqr)
928
+ return { min: inside[0] ?? q1, q1, median: quantile(s, 0.5), q3, max: inside[inside.length - 1] ?? q3, outliers: s.filter((v) => v < q1 - 1.5 * iqr || v > q3 + 1.5 * iqr) }
929
+ }
930
+ export type BoxGroup = { id: string; label: string; values?: number[]; stats?: BoxStats }
931
+
932
+ export const boxTable = (groups: BoxGroup[], format: (n: number) => string = fmt, first = "Group") => ({
933
+ columns: [first, "Lower whisker", "Q1", "Median", "Q3", "Upper whisker", "Outliers"],
934
+ rows: groups.map((g) => { const s = g.stats ?? boxStats(g.values ?? []); return [g.label, format(s.min), format(s.q1), format(s.median), format(s.q3), format(s.max), s.outliers.length ? s.outliers.map(format).join(", ") : "None"] }),
935
+ })
936
+
937
+ /** Distributions side by side on one axis: box from Q1 to Q3, median line, whiskers, outlier dots. */
938
+ export function BoxPlot({ groups, format = fmt, xTitle, "aria-label": label }: { groups: BoxGroup[]; format?: (n: number) => string; xTitle: string; "aria-label": string }) {
939
+ const [wrap, width] = useWidth<HTMLDivElement>()
940
+ const stats = groups.map((g) => g.stats ?? boxStats(g.values ?? []))
941
+ const n = groups.length
942
+ const { active, setActive, onKey } = useActive(n)
943
+ const all = stats.flatMap((s) => [s.min, s.max, ...s.outliers])
944
+ const ticks = rangeTicks(Math.min(...all), Math.max(...all), Math.max(2, Math.min(6, Math.floor(width / 90))))
945
+ const lo = ticks[0], hi = ticks[ticks.length - 1]
946
+ const rowH = 36
947
+ const pad = { t: 4, r: 12, b: 38, l: Math.min(140, Math.max(...groups.map((g) => textW(g.label))) + 12) }
948
+ const height = pad.t + n * rowH + pad.b
949
+ const w = Math.max(0, width - pad.l - pad.r)
950
+ const sx = (v: number) => pad.l + ((v - lo) / (hi - lo || 1)) * w
951
+ const cy = (i: number) => pad.t + rowH * i + rowH / 2
952
+ const cur = active === null ? null : stats[active]
953
+ const summary = `${label}. ${groups.map((g, i) => `${g.label}: median ${format(stats[i].median)}, middle half ${format(stats[i].q1)} to ${format(stats[i].q3)}${stats[i].outliers.length ? `, ${stats[i].outliers.length} outliers` : ""}`).join("; ")}.`
954
+ return (
955
+ <div className="grid gap-3">
956
+ <div ref={wrap} className="relative w-full">
957
+ {width > 0 && (
958
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} onMouseLeave={() => setActive(null)} className={svgCls}>
959
+ {ticks.map((t, i) => (
960
+ <g key={t}>
961
+ <line x1={sx(t)} x2={sx(t)} y1={pad.t} y2={pad.t + n * rowH} className="stroke-line" />
962
+ <text x={sx(t)} y={pad.t + n * rowH + 14} textAnchor={i === 0 ? "start" : i === ticks.length - 1 ? "end" : "middle"} className="fill-ink-3 text-xs tabular">{format(t)}</text>
963
+ </g>
964
+ ))}
965
+ <text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
966
+ {groups.map((g, i) => {
967
+ const s = stats[i], y = cy(i)
968
+ return (
969
+ <g key={g.id} onMouseEnter={() => setActive(i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
970
+ <rect x={0} y={y - rowH / 2} width={width} height={rowH} fill="transparent" />
971
+ <text x={pad.l - 10} y={y} dy="0.32em" textAnchor="end" className="fill-ink-2 text-xs">{g.label}</text>
972
+ <line x1={sx(s.min)} x2={sx(s.q1)} y1={y} y2={y} className="stroke-ink-3" />
973
+ <line x1={sx(s.q3)} x2={sx(s.max)} y1={y} y2={y} className="stroke-ink-3" />
974
+ <line x1={sx(s.min)} x2={sx(s.min)} y1={y - 5} y2={y + 5} className="stroke-ink-3" />
975
+ <line x1={sx(s.max)} x2={sx(s.max)} y1={y - 5} y2={y + 5} className="stroke-ink-3" />
976
+ <rect x={sx(s.q1)} y={y - 9} width={Math.max(1, sx(s.q3) - sx(s.q1))} height={18} rx={3} fill="var(--color-c1)" fillOpacity={0.22} stroke="var(--color-c1)" strokeWidth={1.5} />
977
+ <line x1={sx(s.median)} x2={sx(s.median)} y1={y - 9} y2={y + 9} className="stroke-ink" strokeWidth={2} />
978
+ {s.outliers.map((o, k) => <circle key={k} cx={sx(o)} cy={y} r={3.5} fill="var(--color-c1)" className="stroke-surface" strokeWidth={1.5} />)}
979
+ </g>
980
+ )
981
+ })}
982
+ </svg>
983
+ )}
984
+ {cur && active !== null && (
985
+ <Tip x={sx(cur.q3)} y={Math.max(0, cy(active) - 20)} width={width} title={groups[active].label} rows={[
986
+ { label: "Median", value: format(cur.median), color: "var(--color-ink)" },
987
+ { label: "Middle half", value: `${format(cur.q1)} to ${format(cur.q3)}`, color: "var(--color-c1)" },
988
+ { label: "Whiskers", value: `${format(cur.min)} to ${format(cur.max)}` },
989
+ { label: "Outliers", value: cur.outliers.length ? String(cur.outliers.length) : "None" },
990
+ ]} />
991
+ )}
992
+ </div>
993
+ <ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-ink-2" aria-label="Legend">
994
+ <li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-2.5 w-4 rounded-xs border-2 border-c1 bg-c1/20" />Middle half (Q1 to Q3)</li>
995
+ <li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-3 w-0.5 rounded-full bg-ink" />Median</li>
996
+ <li className="inline-flex items-center gap-control"><span aria-hidden="true" className="h-px w-4 bg-ink-3" />Whiskers, 1.5 × IQR</li>
997
+ <li className="inline-flex items-center gap-control"><span aria-hidden="true" className="size-2 rounded-full bg-c1" />Outlier</li>
998
+ </ul>
999
+ </div>
1000
+ )
1001
+ }
1002
+
1003
+ /* ---------------------------------------------------------------- candlestick */
1004
+
1005
+ export type Candle = { t: string; o: number; h: number; l: number; c: number; v?: number }
1006
+
1007
+ export const candleTable = (candles: Candle[], format: (n: number) => string = fmt, formatVolume: (n: number) => string = fmt) => ({
1008
+ columns: ["Date", "Open", "High", "Low", "Close", "Change", ...(candles.some((k) => k.v !== undefined) ? ["Volume"] : [])],
1009
+ rows: candles.map((k) => [k.t, format(k.o), format(k.h), format(k.l), format(k.c), signed(k.c - k.o, format), ...(k.v !== undefined ? [formatVolume(k.v)] : [])]),
1010
+ })
1011
+
1012
+ /**
1013
+ * Open, high, low, close per period. Up and down are two categorical colours and differ in fill too:
1014
+ * up hollow, down filled, so direction never rests on colour. Volume is a second chart below, never a second axis.
1015
+ */
1016
+ export function CandlestickChart({
1017
+ candles,
1018
+ height = 240,
1019
+ volumeHeight = 64,
1020
+ format = fmt,
1021
+ formatVolume = (n) => n.toLocaleString("en-GB", { notation: "compact", maximumFractionDigits: 1 }),
1022
+ colors = "categorical",
1023
+ showLast = false,
1024
+ lastNote,
1025
+ live = false,
1026
+ volumeLabel = "Volume",
1027
+ "aria-label": label,
1028
+ }: {
1029
+ candles: Candle[]
1030
+ height?: number
1031
+ /** "market" draws up in green and down in red, for trading screens where people expect it; up stays hollow and down filled either way. */
1032
+ colors?: "categorical" | "market"
1033
+ /** Tags the last close on the price axis, in the colour of the last candle. */
1034
+ showLast?: boolean
1035
+ /** A second line under the last-price tag, such as the time until the open candle closes ("12:41"). */
1036
+ lastNote?: string
1037
+ /** The last candle and the price tag ease to each new value, for a feed that updates every second. Off under reduced motion. */
1038
+ live?: boolean
1039
+ /** Names the volume chart with its unit, such as "Volume (BTC)". */
1040
+ volumeLabel?: string
1041
+ /** 0 hides the volume chart. */
1042
+ volumeHeight?: number
1043
+ format?: (n: number) => string
1044
+ formatVolume?: (n: number) => string
1045
+ "aria-label": string
1046
+ }) {
1047
+ const [wrap, width] = useWidth<HTMLDivElement>()
1048
+ const n = candles.length
1049
+ const { active, setActive, onKey } = useActive(n)
1050
+ const ticks = rangeTicks(Math.min(...candles.map((k) => k.l)), Math.max(...candles.map((k) => k.h)))
1051
+ const lo = ticks[0], hi = ticks[ticks.length - 1]
1052
+ const showVol = volumeHeight > 0 && candles.some((k) => k.v !== undefined)
1053
+ const vTicks = niceTicks(Math.max(...candles.map((k) => k.v ?? 0), 1), 2)
1054
+ const vTop = vTicks[vTicks.length - 1] || 1
1055
+ const lastC = candles[n - 1]?.c ?? 0
1056
+ const axisW = Math.max(...ticks.map((t) => textW(format(t))), ...vTicks.map((t) => textW(formatVolume(t))), showLast ? textW(format(lastC)) + 6 : 0, lastNote ? textW(lastNote) + 6 : 0) + 10
1057
+ const ease = live ? "motion-safe:transition-all motion-safe:duration-(--duration-base) motion-safe:ease-(--ease-standard)" : undefined
1058
+ const pad = { t: 8, r: axisW, b: 22, l: 2 }
1059
+ const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
1060
+ const band = n ? w / n : 0
1061
+ const bw = Math.max(3, Math.min(band * 0.6, 12))
1062
+ const cx = (i: number) => pad.l + band * i + band / 2
1063
+ const sy = (v: number) => pad.t + h - ((v - lo) / (hi - lo || 1)) * h
1064
+ const vh = volumeHeight - 8
1065
+ const vy = (v: number) => 4 + vh - (v / vTop) * vh
1066
+ const every = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(w / 64))))
1067
+ const [up, down] = colors === "market" ? ["var(--color-ok)", "var(--color-bad)"] : ["var(--color-c1)", "var(--color-c2)"]
1068
+ const cur = active === null ? null : candles[active]
1069
+ const first = candles[0], lastK = candles[n - 1]
1070
+ const summary = `${label}. ${n} periods from ${first?.t} to ${lastK?.t}. Opened at ${format(first?.o ?? 0)}, closed at ${format(lastK?.c ?? 0)}. High ${format(Math.max(...candles.map((k) => k.h)))}, low ${format(Math.min(...candles.map((k) => k.l)))}. The price axis starts at ${format(lo)}, not zero.`
1071
+ const hits = (hgt: number, y0: number) => candles.map((_, i) => <rect key={`h${i}`} x={pad.l + band * i} y={y0} width={band} height={hgt} fill="transparent" onMouseEnter={() => setActive(i)} />)
1072
+ return (
1073
+ <div className="grid gap-3">
1074
+ <div ref={wrap} className="relative grid w-full gap-1" onMouseLeave={() => setActive(null)}>
1075
+ {width > 0 && (
1076
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={summary} onKeyDown={onKey} onBlur={() => setActive(null)} className={svgCls}>
1077
+ {ticks.map((t) => (
1078
+ <g key={t}>
1079
+ <line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className="stroke-line" />
1080
+ {!(showLast && sy(t) - sy(lastC) > -12 && sy(t) - sy(lastC) < (lastNote ? 28 : 12)) && <text x={pad.l + w + 6} y={sy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{format(t)}</text>}
1081
+ </g>
1082
+ ))}
1083
+ {showLast && lastK && (
1084
+ // Drawn at 0 and moved with a transform, so it can glide to each new price.
1085
+ <g className={ease} style={{ transform: `translateY(${sy(lastC)}px)` }}>
1086
+ <line x1={pad.l} x2={pad.l + w} y1={0} y2={0} stroke={lastK.c >= lastK.o ? up : down} strokeDasharray="2 3" />
1087
+ <rect x={pad.l + w + 2} y={-9} width={axisW - 4} height={lastNote ? 32 : 18} rx={3} fill={lastK.c >= lastK.o ? up : down} />
1088
+ <text x={pad.l + w + 6} y={0} dy="0.32em" className="fill-bg text-xs font-medium tabular">{format(lastC)}</text>
1089
+ {lastNote && <text x={pad.l + w + 6} y={14} dy="0.32em" className="fill-bg text-xs tabular opacity-85">{lastNote}</text>}
1090
+ </g>
1091
+ )}
1092
+ {candles.map((k, i) => i % every === 0 && <text key={`x${i}`} x={cx(i)} y={height - 6} textAnchor={i === 0 ? "start" : "middle"} className="fill-ink-3 text-xs">{k.t}</text>)}
1093
+ {active !== null && <line x1={cx(active)} x2={cx(active)} y1={pad.t} y2={pad.t + h} className="stroke-line-strong" />}
1094
+ {candles.map((k, i) => {
1095
+ const isUp = k.c >= k.o, col = isUp ? up : down
1096
+ const yT = sy(Math.max(k.o, k.c)), yB = sy(Math.min(k.o, k.c))
1097
+ // The open candle eases to each new price when live; closed candles never move.
1098
+ const e = i === n - 1 ? ease : undefined
1099
+ return (
1100
+ <g key={k.t + i} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-55")}>
1101
+ <rect x={cx(i) - 0.5} y={sy(k.h)} width={1} height={Math.max(1, sy(k.l) - sy(k.h))} fill={col} className={e} />
1102
+ {isUp
1103
+ ? <rect x={cx(i) - bw / 2 + 0.75} y={yT + 0.75} width={bw - 1.5} height={Math.max(1, yB - yT - 1.5)} rx={1} className={cn("fill-surface", e)} stroke={col} strokeWidth={1.5} />
1104
+ : <rect x={cx(i) - bw / 2} y={yT} width={bw} height={Math.max(1, yB - yT)} rx={1} fill={col} className={e} />}
1105
+ </g>
1106
+ )
1107
+ })}
1108
+ {hits(h, pad.t)}
1109
+ </svg>
1110
+ )}
1111
+ {width > 0 && showVol && (
1112
+ <svg width={width} height={volumeHeight} role="img" aria-label={`Volume per period, ${formatVolume(Math.min(...candles.map((k) => k.v ?? 0)))} to ${formatVolume(Math.max(...candles.map((k) => k.v ?? 0)))}. Values are in the table view.`} className="block overflow-visible">
1113
+ {vTicks.map((t) => (
1114
+ <g key={t}>
1115
+ <line x1={pad.l} x2={pad.l + w} y1={vy(t)} y2={vy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
1116
+ <text x={pad.l + w + 6} y={vy(t)} dy="0.32em" className="fill-ink-3 text-xs tabular">{formatVolume(t)}</text>
1117
+ </g>
1118
+ ))}
1119
+ <text x={pad.l + 2} y={12} strokeWidth={3} className={cn("fill-ink-3 text-xs", halo)}>{volumeLabel}</text>
1120
+ {candles.map((k, i) => k.v ? (i === n - 1 && live
1121
+ ? <rect key={k.t + i} x={cx(i) - bw / 2} y={vy(k.v)} width={bw} height={Math.max(0, vy(0) - vy(k.v))} fill={NEUTRAL} fillOpacity={active === i ? 0.9 : 0.45} className={ease} />
1122
+ : <path key={k.t + i} d={barPath(cx(i) - bw / 2, vy(k.v), vy(0), bw, "top")} fill={NEUTRAL} fillOpacity={active === i ? 0.9 : 0.45} />) : null)}
1123
+ {hits(vh, 4)}
1124
+ </svg>
1125
+ )}
1126
+ {cur && active !== null && (
1127
+ <Tip x={cx(active)} y={4} width={width} title={cur.t} rows={[
1128
+ { label: cur.c >= cur.o ? "Up" : "Down", value: `${signed(cur.c - cur.o, format)} (${signed(((cur.c - cur.o) / (cur.o || 1)) * 100, (v) => `${v.toFixed(2)}%`)})`, color: cur.c >= cur.o ? up : down },
1129
+ { label: "Open", value: format(cur.o) },
1130
+ { label: "High", value: format(cur.h) },
1131
+ { label: "Low", value: format(cur.l) },
1132
+ { label: "Close", value: format(cur.c) },
1133
+ ...(cur.v !== undefined ? [{ label: "Volume", value: formatVolume(cur.v) }] : []),
1134
+ ]} />
1135
+ )}
1136
+ </div>
1137
+ <div className="flex flex-wrap items-center justify-between gap-2">
1138
+ <KeyLegend items={[{ label: "Up (close above open)", color: up, shape: "hollow" }, { label: "Down (close below open)", color: down, shape: "box" }]} />
1139
+ <p className="text-xs text-ink-3">Price axis starts at {format(lo)}, not zero</p>
1140
+ </div>
1141
+ </div>
1142
+ )
1143
+ }
1144
+
1145
+ /* ---------------------------------------------------------------- donut */
1146
+
1147
+ export function DonutChart({ segments, total, totalLabel = "Total", format = fmt, "aria-label": label }: { segments: { id: string; label: string; value: number }[]; total?: number; totalLabel?: string; format?: (n: number) => string; "aria-label": string }) {
1148
+ const sum = segments.reduce((a, s) => a + s.value, 0) || 1
1149
+ const [active, setActive] = React.useState<number | null>(null)
1150
+ const R = 64, r = 44, C = 80
1151
+ let a0 = -Math.PI / 2
1152
+ const gap = 2 / R // 2px gap at the outer edge
1153
+ const arcs = segments.map((s, i) => {
1154
+ const a1 = a0 + (s.value / sum) * Math.PI * 2
1155
+ const [s0, s1] = [a0 + gap / 2, Math.max(a0 + gap / 2, a1 - gap / 2)]
1156
+ a0 = a1
1157
+ const p = (rad: number, ang: number) => `${C + rad * Math.cos(ang)},${C + rad * Math.sin(ang)}`
1158
+ const large = s1 - s0 > Math.PI ? 1 : 0
1159
+ return { s, i, d: `M${p(R, s0)}A${R},${R} 0 ${large} 1 ${p(R, s1)}L${p(r, s1)}A${r},${r} 0 ${large} 0 ${p(r, s0)}Z` }
1160
+ })
1161
+ return (
1162
+ <div className="flex flex-wrap items-center gap-6">
1163
+ <svg viewBox="0 0 160 160" className="size-40 shrink-0" role="img" aria-label={`${label}. ${segments.map((s) => `${s.label} ${format(s.value)} (${Math.round((s.value / sum) * 100)}%)`).join(", ")}.`}>
1164
+ {arcs.map(({ s, i, d }) => <path key={s.id} d={d} fill={seriesColor(i)} opacity={active === null || active === i ? 1 : 0.45} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} />)}
1165
+ <text x={C} y={C - 4} textAnchor="middle" className="fill-ink text-lg font-semibold tabular">{format(total ?? sum)}</text>
1166
+ <text x={C} y={C + 14} textAnchor="middle" className="fill-ink-3 text-xs">{totalLabel}</text>
1167
+ </svg>
1168
+ <ul className="grid min-w-44 gap-2" aria-label="Legend">
1169
+ {segments.map((s, i) => (
1170
+ <li key={s.id} className={cn("flex items-center gap-2 text-sm", active !== null && active !== i && "opacity-60")} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)}>
1171
+ <span aria-hidden="true" className="size-2.5 shrink-0 rounded-xs bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
1172
+ <span className="text-ink-2">{s.label}</span>
1173
+ <span className="tabular ml-auto pl-4 font-medium text-ink">{format(s.value)}</span>
1174
+ <span className="tabular w-10 text-right text-ink-3">{Math.round((s.value / sum) * 100)}%</span>
1175
+ </li>
1176
+ ))}
1177
+ </ul>
1178
+ </div>
1179
+ )
1180
+ }
1181
+
1182
+ /* ---------------------------------------------------------------- heatmap, funnel and scatter */
1183
+
1184
+ export function Heatmap({ rows, columns, values, format = fmt, "aria-label": label }: { rows: string[]; columns: string[]; values: number[][]; format?: (n: number) => string; "aria-label": string }) {
1185
+ const max = Math.max(...values.flat(), 1)
1186
+ const step = (v: number) => (v <= 0 ? 0 : Math.min(5, Math.ceil((v / max) * 5)))
1187
+ return (
1188
+ <div className="overflow-x-auto">
1189
+ <table className="border-separate border-spacing-0.5 text-sm" aria-label={label}>
1190
+ <thead><tr><td />{columns.map((c) => <th key={c} scope="col" className="px-1 pb-1 text-xs font-medium text-ink-3">{c}</th>)}</tr></thead>
1191
+ <tbody>
1192
+ {rows.map((r, ri) => (
1193
+ <tr key={r}>
1194
+ <th scope="row" className="pr-2 text-left text-xs font-medium whitespace-nowrap text-ink-2">{r}</th>
1195
+ {columns.map((c, ci) => {
1196
+ const v = values[ri][ci], s = step(v)
1197
+ return (
1198
+ <td key={c} title={`${r}, ${c}: ${format(v)}`} className={cn("h-7 w-9 rounded-xs text-center text-xs tabular", s === 0 ? "bg-hover text-ink-3" : ["", "bg-q1 text-white", "bg-q2 text-white", "bg-q3 text-white", "bg-q4 text-white", "bg-q5 text-white"][s])}>
1199
+ <span className="sr-only">{format(v)}</span>
1200
+ </td>
1201
+ )
1202
+ })}
1203
+ </tr>
1204
+ ))}
1205
+ </tbody>
1206
+ </table>
1207
+ <div className="mt-2 flex items-center gap-1 text-xs text-ink-3" aria-hidden="true">
1208
+ Fewer {["bg-hover", "bg-q1", "bg-q2", "bg-q3", "bg-q4", "bg-q5"].map((c) => <span key={c} className={cn("size-3 rounded-xs", c)} />)} More
1209
+ </div>
1210
+ </div>
1211
+ )
1212
+ }
1213
+
1214
+ export function Funnel({ stages, format = fmt, "aria-label": label }: { stages: { label: string; value: number }[]; format?: (n: number) => string; "aria-label": string }) {
1215
+ const first = stages[0]?.value || 1
1216
+ return (
1217
+ <ol className="grid gap-2" aria-label={label}>
1218
+ {stages.map((s, i) => {
1219
+ const prev = i ? stages[i - 1].value : null
1220
+ return (
1221
+ <li key={s.label} className="grid grid-cols-[96px_1fr_96px] items-center gap-3 text-sm">
1222
+ <span className="text-ink-2">{s.label}</span>
1223
+ <span className="h-6 overflow-hidden rounded-xs bg-hover"><span className="block h-full w-(--w) rounded-xs bg-c1" style={{ "--w": `${(s.value / first) * 100}%` } as React.CSSProperties} /></span>
1224
+ <span className="tabular text-right"><span className="font-medium text-ink">{format(s.value)}</span>{prev !== null && <span className="text-ink-3"> · {Math.round((s.value / prev) * 100)}%</span>}</span>
1225
+ </li>
1226
+ )
1227
+ })}
1228
+ </ol>
1229
+ )
1230
+ }
1231
+
1232
+ /* ---------------------------------------------------------------- scatter */
1233
+
1234
+ export type ScatterPoint = { id: string; label: string; x: number; y: number; group: string; href?: string }
1235
+
1236
+ const SHAPES = ["circle", "square", "triangle"] as const
1237
+ function Mark({ shape, x, y, color, active }: { shape: (typeof SHAPES)[number]; x: number; y: number; color: string; active: boolean }) {
1238
+ const r = active ? 5 : 3.5
1239
+ const common = { fill: color, className: "stroke-surface", strokeWidth: 1 }
1240
+ if (shape === "square") return <rect x={x - r} y={y - r} width={r * 2} height={r * 2} rx={1} {...common} />
1241
+ if (shape === "triangle") return <path d={`M${x},${y - r * 1.2}L${x + r * 1.1},${y + r * 0.8}L${x - r * 1.1},${y + r * 0.8}Z`} {...common} />
1242
+ return <circle cx={x} cy={y} r={r} {...common} />
1243
+ }
1244
+
1245
+ /** Two measures per record. Up to three groups, each with its own colour (c1–c3) and marker shape. */
1246
+ export function ScatterChart({
1247
+ points,
1248
+ xTitle,
1249
+ yTitle,
1250
+ height = 260,
1251
+ formatX = fmt,
1252
+ formatY = fmt,
1253
+ "aria-label": label,
1254
+ }: {
1255
+ points: ScatterPoint[]
1256
+ xTitle: string
1257
+ yTitle: string
1258
+ height?: number
1259
+ formatX?: (n: number) => string
1260
+ formatY?: (n: number) => string
1261
+ "aria-label": string
1262
+ }) {
1263
+ const [wrap, width] = useWidth<HTMLDivElement>()
1264
+ const [active, setActive] = React.useState<number | null>(null)
1265
+ const [pinned, setPinned] = React.useState(false)
1266
+ const groups = [...new Set(points.map((p) => p.group))].slice(0, 3)
1267
+ const sorted = [...points].filter((p) => groups.includes(p.group)).sort((a, b) => a.x - b.x)
1268
+ const pad = { t: 10, r: 12, b: 40, l: 52 }
1269
+ const w = Math.max(0, width - pad.l - pad.r), h = height - pad.t - pad.b
1270
+ const xt = niceTicks(Math.max(...sorted.map((p) => p.x), 1)), yt = niceTicks(Math.max(...sorted.map((p) => p.y), 1))
1271
+ const sx = (v: number) => pad.l + (v / xt[xt.length - 1]) * w
1272
+ const sy = (v: number) => pad.t + h - (v / yt[yt.length - 1]) * h
1273
+ const cur = active === null ? null : sorted[active]
1274
+ const onKey = (e: React.KeyboardEvent) => {
1275
+ if (e.key === "ArrowRight") { e.preventDefault(); setPinned(false); setActive((a) => Math.min(sorted.length - 1, a === null ? 0 : a + 1)) }
1276
+ else if (e.key === "ArrowLeft") { e.preventDefault(); setPinned(false); setActive((a) => Math.max(0, a === null ? 0 : a - 1)) }
1277
+ else if (e.key === "Enter" && active !== null) { e.preventDefault(); setPinned(true) }
1278
+ else if (e.key === "Escape") { setPinned(false); setActive(null) }
1279
+ }
1280
+ return (
1281
+ <div className="grid gap-3">
1282
+ <div ref={wrap} className="relative w-full">
1283
+ {width > 0 && (
1284
+ <svg width={width} height={height} role="group" tabIndex={0} aria-roledescription="chart" aria-label={`${label}. ${sorted.length} records. ${xTitle} against ${yTitle}.`} onKeyDown={onKey} onMouseLeave={() => !pinned && setActive(null)} className="block overflow-visible rounded-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
1285
+ {yt.map((t) => (
1286
+ <g key={`y${t}`}>
1287
+ <line x1={pad.l} x2={pad.l + w} y1={sy(t)} y2={sy(t)} className={t === 0 ? "stroke-line-strong" : "stroke-line"} />
1288
+ <text x={pad.l - 6} y={sy(t)} dy="0.32em" textAnchor="end" className="fill-ink-3 text-xs tabular">{formatY(t)}</text>
1289
+ </g>
1290
+ ))}
1291
+ {xt.map((t) => <text key={`x${t}`} x={sx(t)} y={pad.t + h + 14} textAnchor="middle" className="fill-ink-3 text-xs tabular">{formatX(t)}</text>)}
1292
+ <text x={pad.l + w / 2} y={height - 4} textAnchor="middle" className="fill-ink-2 text-xs">{xTitle}</text>
1293
+ <text transform={`translate(12 ${pad.t + h / 2}) rotate(-90)`} textAnchor="middle" className="fill-ink-2 text-xs">{yTitle}</text>
1294
+ {sorted.map((p, i) => {
1295
+ const g = groups.indexOf(p.group)
1296
+ return (
1297
+ <g key={p.id} onMouseEnter={() => !pinned && setActive(i)} onClick={() => { setActive(i); setPinned(true) }} className="cursor-pointer">
1298
+ <circle cx={sx(p.x)} cy={sy(p.y)} r={10} fill="transparent" />
1299
+ <Mark shape={SHAPES[g]} x={sx(p.x)} y={sy(p.y)} color={seriesColor(g)} active={active === i} />
1300
+ </g>
1301
+ )
1302
+ })}
1303
+ </svg>
1304
+ )}
1305
+ {cur && width > 0 && (
1306
+ <div
1307
+ role={pinned ? "dialog" : "status"}
1308
+ aria-label={pinned ? cur.label : undefined}
1309
+ className={cn("absolute z-10 grid min-w-44 gap-1 rounded-md border border-line bg-surface px-2 py-2 text-sm shadow-pop left-(--x) top-(--y)", !pinned && "pointer-events-none")}
1310
+ style={{ "--x": `${Math.min(sx(cur.x) + 10, width - 190)}px`, "--y": `${Math.max(0, sy(cur.y) - 70)}px` } as React.CSSProperties}
1311
+ >
1312
+ <span className="font-medium text-ink">{cur.label}</span>
1313
+ <span className="flex justify-between gap-3 text-ink-2">{xTitle}<span className="tabular font-medium text-ink">{formatX(cur.x)}</span></span>
1314
+ <span className="flex justify-between gap-3 text-ink-2">{yTitle}<span className="tabular font-medium text-ink">{formatY(cur.y)}</span></span>
1315
+ {pinned && cur.href && <a href={cur.href} className="font-medium text-primary-ink hover:underline" autoFocus>Open {cur.label}</a>}
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ {groups.length > 1 && (
1320
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label="Legend">
1321
+ {groups.map((g, i) => (
1322
+ <li key={g} className="inline-flex items-center gap-control text-sm text-ink-2">
1323
+ <svg width="10" height="10" aria-hidden="true"><Mark shape={SHAPES[i]} x={5} y={5} color={seriesColor(i)} active={false} /></svg>
1324
+ {g}
1325
+ </li>
1326
+ ))}
1327
+ </ul>
1328
+ )}
1329
+ </div>
1330
+ )
1331
+ }