@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,328 @@
1
+ /**
2
+ * TwoUX radial charts: two narrow uses of polar coordinates, adapted from MUI X radial bars and radial lines.
3
+ *
4
+ * RadialBars: one to four concentric rings, each the share of a fixed limit (seats, storage, credits, an SLA budget).
5
+ * Small, with value and limit always written as text; the ring only repeats them. Never actual against target
6
+ * (that is BulletChart), never categories compared around a circle (that is a bar chart), never a dial with a needle.
7
+ *
8
+ * RadialLines: a line chart in polar coordinates for cyclical periods only (hour of day, day of week, month),
9
+ * where the last period meets the first. One to three series, value axis from zero, missing data as a gap.
10
+ *
11
+ * Same rules as charts.tsx: own SVG, series colours in fixed order (c1…c8), text never in a series colour,
12
+ * one tab stop per chart with arrow keys between marks, Esc clears, a Tip on hover and focus, and a table helper.
13
+ */
14
+ import * as React from "react"
15
+ import { cn } from "../lib/cn"
16
+ import { Badge } from "./badge"
17
+ import { ChartLegend, niceTicks, seriesColor } from "./charts"
18
+ import { fmt, pct, svgCls, halo, useWidth, useActive, Tip, type TipRow } from "../lib/chart-kit"
19
+
20
+ /* ---------------------------------------------------------------- shared */
21
+
22
+ const TAU = Math.PI * 2
23
+ /** Angle for a share of a full turn, starting at 12 o'clock and running clockwise. */
24
+ const turn = (share: number) => -Math.PI / 2 + share * TAU
25
+ const polar = (cx: number, cy: number, r: number, a: number): [number, number] => [cx + r * Math.cos(a), cy + r * Math.sin(a)]
26
+
27
+ /* ---------------------------------------------------------------- radial bars */
28
+
29
+ export type RadialBarItem = {
30
+ id: string
31
+ /** What is measured, e.g. "Seats". */
32
+ label: string
33
+ value: number
34
+ /** The fixed limit the value is a share of: capacity, quota or budget. */
35
+ limit: number
36
+ /** Unit written after the numbers, e.g. "seats", "GB". */
37
+ unit?: string
38
+ }
39
+
40
+ const MAX_RINGS = 4
41
+
42
+ const ringShare = (it: RadialBarItem) => (it.limit > 0 ? it.value / it.limit : 0)
43
+ const ringText = (it: RadialBarItem, format: (n: number) => string) => `${format(it.value)} of ${format(it.limit)}${it.unit ? ` ${it.unit}` : ""}`
44
+ const ringStatus = (it: RadialBarItem) => (it.value > it.limit ? "Over limit" : it.value === it.limit && it.limit > 0 ? "At limit" : null)
45
+
46
+ export const radialBarsTable = (items: RadialBarItem[], format: (n: number) => string = fmt) => ({
47
+ columns: ["Measure", "Used", "Limit", "Share of limit"],
48
+ rows: items.slice(0, MAX_RINGS).map((it) => [it.label, `${format(it.value)}${it.unit ? ` ${it.unit}` : ""}`, `${format(it.limit)}${it.unit ? ` ${it.unit}` : ""}`, pct(it.value, it.limit, 0)]),
49
+ })
50
+
51
+ /**
52
+ * Concentric rings, outermost first, each the share of its own limit from 12 o'clock clockwise.
53
+ * The list beside the rings carries every value in words; a ring at or over its limit adds a status badge.
54
+ */
55
+ export function RadialBars({
56
+ items,
57
+ format = fmt,
58
+ size = 128,
59
+ "aria-label": label,
60
+ className,
61
+ }: {
62
+ /** One to four measures; extra items are not drawn. */
63
+ items: RadialBarItem[]
64
+ format?: (n: number) => string
65
+ /** Diameter of the rings in px (96 to 160). */
66
+ size?: number
67
+ "aria-label": string
68
+ className?: string
69
+ }) {
70
+ const rings = items.slice(0, MAX_RINGS)
71
+ const { active, setActive, onKey } = useActive(rings.length)
72
+ const S = Math.min(160, Math.max(96, size))
73
+ const c = S / 2
74
+ const n = rings.length
75
+ const gap = 3
76
+ const stroke = Math.max(6, Math.min(12, (c - 18 - gap * (n - 1)) / Math.max(n, 2)))
77
+ const radius = (i: number) => c - stroke / 2 - i * (stroke + gap)
78
+ const geo = rings.map((it, i) => {
79
+ const share = ringShare(it), shown = Math.min(1, Math.max(0, share)), r = radius(i)
80
+ const [x0, y0] = polar(c, c, r, turn(0)), [x1, y1] = polar(c, c, r, turn(shown))
81
+ const d = shown <= 0 || shown >= 1 ? null : `M${x0},${y0}A${r},${r} 0 ${shown > 0.5 ? 1 : 0} 1 ${x1},${y1}`
82
+ return { it, i, r, share, shown, d, end: [x1, y1] as [number, number] }
83
+ })
84
+ const summary = `${label}. ${rings.map((it) => `${it.label}: ${ringText(it, format)}, ${pct(it.value, it.limit, 0)} of the limit${ringStatus(it) ? `, ${ringStatus(it)!.toLowerCase()}` : ""}`).join("; ")}.`
85
+ const cur = active === null ? null : geo[active]
86
+ return (
87
+ <div data-slot="radial-bars" className={cn("flex flex-wrap items-center gap-x-4 gap-y-3", className)}>
88
+ <div className="relative shrink-0">
89
+ <svg
90
+ width={S}
91
+ height={S}
92
+ viewBox={`0 0 ${S} ${S}`}
93
+ role="img"
94
+ tabIndex={0}
95
+ aria-label={summary}
96
+ onKeyDown={onKey}
97
+ onBlur={() => setActive(null)}
98
+ onMouseLeave={() => setActive(null)}
99
+ className={svgCls}
100
+ >
101
+ {geo.map((g) => (
102
+ <g key={g.it.id} onMouseEnter={() => setActive(g.i)} className={cn("transition-opacity duration-(--duration-fast)", active !== null && active !== g.i && "opacity-40")}>
103
+ <circle cx={c} cy={c} r={g.r} fill="none" stroke="var(--color-hover)" strokeWidth={stroke} />
104
+ {g.shown >= 1 ? (
105
+ <circle cx={c} cy={c} r={g.r} fill="none" stroke={seriesColor(g.i)} strokeWidth={stroke} />
106
+ ) : g.d ? (
107
+ <path d={g.d} fill="none" stroke={seriesColor(g.i)} strokeWidth={stroke} />
108
+ ) : null}
109
+ {/* zero mark at 12 o'clock so an empty ring still shows where it starts */}
110
+ <line x1={c} x2={c} y1={c - g.r - stroke / 2} y2={c - g.r + stroke / 2} stroke="var(--color-line-strong)" strokeWidth={1} />
111
+ </g>
112
+ ))}
113
+ {n === 1 && (
114
+ <text x={c} y={c} dy="0.35em" textAnchor="middle" className="fill-ink text-lg font-semibold tabular">{pct(rings[0].value, rings[0].limit, 0)}</text>
115
+ )}
116
+ </svg>
117
+ {cur && (
118
+ <Tip x={-12} y={S + 6} width={S} title={cur.it.label} rows={[
119
+ { label: "Used", value: ringText(cur.it, format), color: seriesColor(cur.i) },
120
+ { label: "Of limit", value: pct(cur.it.value, cur.it.limit, 0) },
121
+ ...(ringStatus(cur.it) ? [{ label: "Status", value: ringStatus(cur.it)! }] : []),
122
+ ]} />
123
+ )}
124
+ </div>
125
+ <ul className="grid min-w-0 flex-1 basis-32 gap-2" aria-label="Values">
126
+ {rings.map((it, i) => {
127
+ const status = ringStatus(it)
128
+ return (
129
+ <li key={it.id} onMouseEnter={() => setActive(i)} onMouseLeave={() => setActive(null)} className={cn("grid grid-cols-[auto_1fr] items-center gap-x-2 text-sm transition-opacity duration-(--duration-fast)", active !== null && active !== i && "opacity-60")}>
130
+ <span aria-hidden="true" className="size-2.5 rounded-xs bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
131
+ <span className="flex min-w-0 items-center gap-2">
132
+ <span className="truncate text-ink-2">{it.label}</span>
133
+ {status && <Badge variant={status === "Over limit" ? "destructive" : "warning"}>{status}</Badge>}
134
+ </span>
135
+ <span className="tabular col-start-2 text-ink"><span className="font-medium">{ringText(it, format)}</span><span className="text-ink-3"> · {pct(it.value, it.limit, 0)}</span></span>
136
+ </li>
137
+ )
138
+ })}
139
+ </ul>
140
+ </div>
141
+ )
142
+ }
143
+
144
+ /* ---------------------------------------------------------------- radial lines */
145
+
146
+ export type RadialLineSeries = {
147
+ id: string
148
+ label: string
149
+ /** One value per period, in period order; null for a gap (never zero). */
150
+ data: (number | null)[]
151
+ }
152
+
153
+ export const radialLinesTable = (periods: string[], series: RadialLineSeries[], format: (n: number) => string = fmt, periodLabel = "Period") => ({
154
+ columns: [periodLabel, ...series.slice(0, 3).map((s) => s.label)],
155
+ rows: periods.map((p, i) => [p, ...series.slice(0, 3).map((s) => (s.data[i] == null ? "No data" : format(s.data[i] as number)))]),
156
+ })
157
+
158
+ /** Rings at round values from zero: three or four labelled rings. */
159
+ function ringTicks(max: number) {
160
+ let t = niceTicks(max, 4)
161
+ if (t.length - 1 > 4) t = niceTicks(max, 3)
162
+ return t
163
+ }
164
+
165
+ /**
166
+ * A line chart wrapped round a circle, for cyclical periods only. The first period sits at 12 o'clock and
167
+ * periods run clockwise, so the last one meets the first. Distance from the centre is the value, from zero.
168
+ */
169
+ export function RadialLines({
170
+ periods,
171
+ series,
172
+ format = fmt,
173
+ periodLabel = "Period",
174
+ maxSize = 320,
175
+ "aria-label": label,
176
+ className,
177
+ }: {
178
+ /** Labels for each period of the cycle, e.g. "00:00"…"23:00" or "Mon"…"Sun". */
179
+ periods: string[]
180
+ /** One to three series; extra series are not drawn. */
181
+ series: RadialLineSeries[]
182
+ format?: (n: number) => string
183
+ /** Name of a period, for the tooltip and table, e.g. "Hour". */
184
+ periodLabel?: string
185
+ /** Largest diameter in px; the chart shrinks to its container. */
186
+ maxSize?: number
187
+ "aria-label": string
188
+ className?: string
189
+ }) {
190
+ const [wrap, width] = useWidth<HTMLDivElement>()
191
+ const all = series.slice(0, 3)
192
+ const [hidden, setHidden] = React.useState<string[]>([])
193
+ const shown = all.map((s, i) => ({ s, i })).filter(({ s }) => !hidden.includes(s.id))
194
+ const n = periods.length
195
+ const { active, setActive } = useActive(n)
196
+ const S = Math.max(160, Math.min(maxSize, width))
197
+ const c = S / 2
198
+ const labelPad = 22
199
+ const R = c - labelPad
200
+ const max = Math.max(0, ...all.flatMap((s) => s.data.filter((v): v is number => v != null)))
201
+ const ticks = ringTicks(max)
202
+ const top = ticks[ticks.length - 1] || 1
203
+ const ang = (i: number) => turn(i / Math.max(1, n))
204
+ const pt = (i: number, v: number) => polar(c, c, (v / top) * R, ang(i))
205
+ // label every period when there is room, otherwise every 2nd, 3rd or 6th
206
+ const every = [1, 2, 3, 4, 6].find((k) => (TAU * (R + 12)) / (n / k) >= 34) ?? Math.ceil(n / 8)
207
+
208
+ const paths = (data: (number | null)[]) => {
209
+ // runs of consecutive values; the run that ends at the last period joins the one starting at the first
210
+ const segs: string[] = []
211
+ const dots: [number, number][] = []
212
+ let run: number[] = []
213
+ const flush = () => {
214
+ if (run.length === 1) dots.push(pt(run[0], data[run[0]] as number))
215
+ else if (run.length > 1) segs.push(run.map((j, k) => `${k ? "L" : "M"}${pt(j, data[j] as number).join(",")}`).join(""))
216
+ run = []
217
+ }
218
+ for (let j = 0; j < n; j++) { if (data[j] == null) flush(); else run.push(j) }
219
+ flush()
220
+ if (n > 2 && data[n - 1] != null && data[0] != null) segs.push(`M${pt(n - 1, data[n - 1] as number).join(",")}L${pt(0, data[0] as number).join(",")}`)
221
+ return { d: segs.join(""), dots }
222
+ }
223
+
224
+ const onKey = (e: React.KeyboardEvent) => {
225
+ if (!n) return
226
+ const step = (d: number) => { e.preventDefault(); setActive((a) => (a === null ? (d > 0 ? 0 : n - 1) : (a + d + n) % n)) }
227
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") step(1)
228
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") step(-1)
229
+ else if (e.key === "Home") { e.preventDefault(); setActive(0) }
230
+ else if (e.key === "End") { e.preventDefault(); setActive(n - 1) }
231
+ else if (e.key === "Escape") setActive(null)
232
+ }
233
+ const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
234
+ const box = e.currentTarget.getBoundingClientRect()
235
+ const dx = e.clientX - box.left - c, dy = e.clientY - box.top - c
236
+ if (Math.hypot(dx, dy) > R + 16) { setActive(null); return }
237
+ const a = (Math.atan2(dy, dx) + Math.PI / 2 + TAU) % TAU
238
+ setActive(Math.round((a / TAU) * n) % n)
239
+ }
240
+
241
+ const describe = (s: RadialLineSeries) => {
242
+ const vals = s.data.map((v, i) => ({ v, i })).filter((x): x is { v: number; i: number } => x.v != null)
243
+ if (!vals.length) return `${s.label}: no data`
244
+ const hi = vals.reduce((a, b) => (b.v > a.v ? b : a)), lo = vals.reduce((a, b) => (b.v < a.v ? b : a))
245
+ const gaps = n - vals.length
246
+ return `${s.label}: highest at ${periods[hi.i]} with ${format(hi.v)}, lowest at ${periods[lo.i]} with ${format(lo.v)}${gaps ? `, ${gaps} ${gaps === 1 ? "period" : "periods"} without data` : ""}`
247
+ }
248
+ const summary = `${label}. Radial line chart over ${n} ${periodLabel.toLowerCase()}s, starting at ${periods[0]} at the top and running clockwise. ${all.map(describe).join("; ")}.`
249
+
250
+ const tipRows: TipRow[] = active === null ? [] : shown.map(({ s, i }) => ({ label: s.label, value: s.data[active] == null ? "No data" : format(s.data[active] as number), color: seriesColor(i) }))
251
+ const tipAt = active === null ? null : polar(c, c, R * 0.6, ang(active))
252
+
253
+ return (
254
+ <div data-slot="radial-lines" className={cn("grid gap-3", className)}>
255
+ <div ref={wrap} className="w-full">
256
+ {width > 0 && (
257
+ <div className="relative mx-auto w-fit">
258
+ <svg
259
+ width={S}
260
+ height={S}
261
+ viewBox={`0 0 ${S} ${S}`}
262
+ role="img"
263
+ tabIndex={0}
264
+ aria-label={summary}
265
+ onKeyDown={onKey}
266
+ onBlur={() => setActive(null)}
267
+ onMouseMove={onMove}
268
+ onMouseLeave={() => setActive(null)}
269
+ className={svgCls}
270
+ >
271
+ {/* value rings from zero */}
272
+ {ticks.slice(1).map((t, k) => (
273
+ <circle key={t} cx={c} cy={c} r={(t / top) * R} fill="none" className={k === ticks.length - 2 ? "stroke-line-strong" : "stroke-line"} strokeWidth={1} />
274
+ ))}
275
+ {/* one spoke per period */}
276
+ {periods.map((p, i) => {
277
+ const [x, y] = polar(c, c, R, ang(i))
278
+ return <line key={p + i} x1={c} y1={c} x2={x} y2={y} className={active === i ? "stroke-ink-3" : "stroke-line"} strokeWidth={1} />
279
+ })}
280
+ {/* period labels round the outside */}
281
+ {periods.map((p, i) => {
282
+ if (i % every !== 0 && active !== i) return null
283
+ const a = ang(i), cos = Math.cos(a), sin = Math.sin(a)
284
+ const [x, y] = polar(c, c, R + 8, a)
285
+ return (
286
+ <text key={`l${p}${i}`} x={x} y={y} dy={sin < -0.3 ? "-0.1em" : sin > 0.3 ? "0.8em" : "0.32em"} textAnchor={cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"} className={cn("text-xs tabular", active === i ? "fill-ink font-medium" : "fill-ink-3")}>
287
+ {p}
288
+ </text>
289
+ )
290
+ })}
291
+ {/* lines */}
292
+ {shown.map(({ s, i }) => {
293
+ const { d, dots } = paths(s.data)
294
+ return (
295
+ <g key={s.id}>
296
+ {d && <path d={d} fill="none" stroke={seriesColor(i)} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
297
+ {dots.map(([x, y]) => <circle key={`${x},${y}`} cx={x} cy={y} r={2.5} fill={seriesColor(i)} />)}
298
+ </g>
299
+ )
300
+ })}
301
+ {/* ring values, along the 12 o'clock spoke */}
302
+ {ticks.slice(1).map((t) => (
303
+ <text key={`v${t}`} x={c + 4} y={c - (t / top) * R} dy="0.9em" strokeWidth={3} className={cn("fill-ink-3 text-xs tabular", halo)}>{format(t)}</text>
304
+ ))}
305
+ {/* active period: a dot per series */}
306
+ {active !== null && shown.map(({ s, i }) => {
307
+ const v = s.data[active]
308
+ if (v == null) return null
309
+ const [x, y] = pt(active, v)
310
+ return <circle key={`a${s.id}`} cx={x} cy={y} r={4} fill={seriesColor(i)} className="stroke-surface" strokeWidth={2} />
311
+ })}
312
+ </svg>
313
+ {active !== null && tipAt && (
314
+ <Tip x={tipAt[0]} y={Math.min(tipAt[1], S - 40 - 24 * tipRows.length)} width={S} title={`${periodLabel} ${periods[active]}`} rows={tipRows} />
315
+ )}
316
+ </div>
317
+ )}
318
+ </div>
319
+ {all.length >= 2 && (
320
+ <ChartLegend
321
+ items={all.map((s, i) => ({ id: s.id, label: s.label, color: seriesColor(i), shape: "line" as const }))}
322
+ hidden={hidden}
323
+ onToggle={(id) => setHidden((h) => (h.includes(id) ? h.filter((x) => x !== id) : h.length + 1 >= all.length ? h : [...h, id]))}
324
+ />
325
+ )}
326
+ </div>
327
+ )
328
+ }