@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,336 @@
1
+ /**
2
+ * TwoUX map chart: regions shaded by one value (a choropleth), or circles sized by a count at points.
3
+ * Built on d3-geo and topojson-client; the app passes its own TopoJSON, so no map data ships in the package.
4
+ * Same rules as charts-more: own SVG, one tab stop, arrow keys between marks (here: regions or markers with
5
+ * data, highest value first), Enter pins the details, Esc clears, and mapTable() for the Table view.
6
+ * Colour is the five-step sequential scale (q1…q5) the Heatmap uses. Regions with no value are hatched and
7
+ * labelled "No data" in the legend, never drawn as zero. No zoom or pan: the map fits its width.
8
+ */
9
+ import * as React from "react"
10
+ import { geoPath, geoEqualEarth, geoMercator, geoConicEqualArea, type GeoProjection, type ExtendedFeature, type GeoGeometryObjects } from "d3-geo"
11
+ import { feature } from "topojson-client"
12
+ import type { Topology, GeometryObject } from "topojson-specification"
13
+ import { cn } from "../lib/cn"
14
+ import { Button } from "./button"
15
+ import { fmt, pct, svgCls, useWidth, niceStep } from "../lib/chart-kit"
16
+
17
+ /* ---------------------------------------------------------------- types */
18
+
19
+ /** Any TopoJSON topology, such as world-atlas/countries-110m.json imported as JSON. */
20
+ export type MapTopology = { type: string; objects: Record<string, unknown>; arcs: unknown; transform?: unknown; bbox?: unknown }
21
+ /** One value per region, joined to the shape by feature id (world-atlas uses ISO 3166-1 numeric codes such as "826"). */
22
+ export type MapValue = { id: string; value: number; label?: string }
23
+ /** A point sized by its value: circle area is proportional to the value. */
24
+ export type MapMarker = { id: string; label: string; lon: number; lat: number; value: number }
25
+ export type MapProjection = "equal-earth" | "mercator" | "albers-uk" | (() => GeoProjection)
26
+ /** What onSelect receives: the region or marker the person chose. */
27
+ export type MapRegion = { id: string; label: string; value: number }
28
+
29
+ type Encoding = { values: MapValue[]; markers?: never } | { markers: MapMarker[]; values?: never }
30
+
31
+ export type MapChartProps = Encoding & {
32
+ topology: MapTopology
33
+ /** Name of the object in the topology, e.g. "countries". */
34
+ object: string
35
+ /** Feature ids to leave off the map and out of the fit, e.g. ["010"] for Antarctica. */
36
+ exclude?: string[]
37
+ projection?: MapProjection
38
+ /** Fixed height in px. By default the height follows the shape of the map at the current width. */
39
+ height?: number
40
+ format?: (n: number) => string
41
+ /** Four class breaks for the five colour steps. Default: equal, rounded steps from 0 to the highest value. */
42
+ thresholds?: [number, number, number, number]
43
+ /** Show share of total in the details and table. Turn off for rates and averages, where a share means nothing. */
44
+ share?: boolean
45
+ /** Noun for the value in the details, e.g. "Applicants" or "ARR". */
46
+ valueLabel?: string
47
+ /** Drill-down: a pinned region's details offer one button that calls this. */
48
+ onSelect?: (region: MapRegion) => void
49
+ /** Button text in the pinned details. Name the action and where it lands: "Filter page". */
50
+ selectLabel?: string
51
+ /** Field name shown in the pinned details before the drill-down: "Country is France". */
52
+ filterField?: string
53
+ "aria-label": string
54
+ className?: string
55
+ }
56
+
57
+ type Feat = ExtendedFeature<GeoGeometryObjects | null, { name?: string } | null> & { id?: string | number }
58
+ type Shape = { id: string; name: string; d: string; at: [number, number] }
59
+
60
+ /* ---------------------------------------------------------------- helpers */
61
+
62
+ const STEPS = ["fill-q1", "fill-q2", "fill-q3", "fill-q4", "fill-q5"] as const
63
+ const SWATCH = ["bg-q1", "bg-q2", "bg-q3", "bg-q4", "bg-q5"] as const
64
+
65
+ function makeProjection(p: MapProjection): GeoProjection {
66
+ if (typeof p === "function") return p()
67
+ if (p === "mercator") return geoMercator()
68
+ // Equal-area conic centred on Great Britain and Ireland.
69
+ if (p === "albers-uk") return geoConicEqualArea().parallels([50, 60]).rotate([4.4, 0]).center([0, 55.4])
70
+ return geoEqualEarth()
71
+ }
72
+
73
+ /** Equal, rounded steps from 0: four breaks for five classes. */
74
+ export function mapThresholds(max: number): [number, number, number, number] {
75
+ const s = niceStep(Math.max(max, 1) / 5)
76
+ return [s, s * 2, s * 3, s * 4]
77
+ }
78
+
79
+ const classOf = (v: number, t: readonly number[]) => t.filter((b) => v >= b).length
80
+
81
+ /** Table view: region, value and share, highest first. */
82
+ export const mapTable = (values: MapValue[], { format = fmt, share = true, first = "Region", valueLabel = "Value" }: { format?: (n: number) => string; share?: boolean; first?: string; valueLabel?: string } = {}) => {
83
+ const sorted = [...values].sort((a, b) => b.value - a.value)
84
+ const total = values.reduce((a, v) => a + v.value, 0)
85
+ return {
86
+ columns: share ? [first, valueLabel, "Share"] : [first, valueLabel],
87
+ rows: sorted.map((v) => (share ? [v.label ?? v.id, format(v.value), pct(v.value, total)] : [v.label ?? v.id, format(v.value)])),
88
+ }
89
+ }
90
+
91
+ /* ---------------------------------------------------------------- map chart */
92
+
93
+ /** Where something is matters: regions shaded by a rate, or circles sized by a count. One measure per map. */
94
+ export function MapChart({
95
+ topology,
96
+ object,
97
+ values,
98
+ markers,
99
+ exclude = [],
100
+ projection = "equal-earth",
101
+ height: fixedHeight,
102
+ format = fmt,
103
+ thresholds,
104
+ share = true,
105
+ valueLabel = "Value",
106
+ onSelect,
107
+ selectLabel = "Filter page",
108
+ filterField = "Region",
109
+ "aria-label": label,
110
+ className,
111
+ }: MapChartProps) {
112
+ const [wrap, width] = useWidth<HTMLDivElement>()
113
+ const svgRef = React.useRef<SVGSVGElement>(null)
114
+ const hatch = `${React.useId().replace(/:/g, "")}-nodata`
115
+ const [active, setActive] = React.useState<number | null>(null)
116
+ const [pinned, setPinned] = React.useState(false)
117
+ const [hoverEmpty, setHoverEmpty] = React.useState<string | null>(null)
118
+
119
+ const features = React.useMemo(() => {
120
+ const topo = topology as unknown as Topology
121
+ const obj = topo.objects[object] as GeometryObject | undefined
122
+ if (!obj) return [] as Feat[]
123
+ const fc = feature(topo, obj) as unknown as { features: Feat[] }
124
+ return fc.features.filter((f) => !exclude.includes(String(f.id ?? f.properties?.name)))
125
+ }, [topology, object, exclude.join("|")])
126
+
127
+ const { shapes, height, project } = React.useMemo(() => {
128
+ if (!width || !features.length) return { shapes: [] as Shape[], height: fixedHeight ?? 0, project: null as GeoProjection | null }
129
+ const proj = makeProjection(projection)
130
+ const coll = { type: "FeatureCollection" as const, features }
131
+ let h = fixedHeight ?? 0
132
+ if (fixedHeight) proj.fitSize([width, fixedHeight], coll)
133
+ else {
134
+ proj.fitWidth(width, coll)
135
+ h = Math.ceil(geoPath(proj).bounds(coll)[1][1])
136
+ }
137
+ const path = geoPath(proj)
138
+ const out: Shape[] = features.map((f) => {
139
+ // Centre the details on the largest part, so France sits in Europe and not off French Guiana.
140
+ let at = path.centroid(f)
141
+ if (f.geometry?.type === "MultiPolygon") {
142
+ const parts = (f.geometry.coordinates as number[][][][]).map((c) => ({ type: "Polygon" as const, coordinates: c }))
143
+ const big = parts.reduce((a, p) => (path.area(p) > path.area(a) ? p : a), parts[0])
144
+ at = path.centroid(big)
145
+ }
146
+ return { id: String(f.id ?? f.properties?.name), name: f.properties?.name ?? String(f.id), d: path(f) ?? "", at }
147
+ })
148
+ return { shapes: out, height: h, project: proj }
149
+ }, [width, features, projection, fixedHeight])
150
+
151
+ const byId = React.useMemo(() => new Map(shapes.map((s) => [s.id, s])), [shapes])
152
+ const isMarkers = !!markers
153
+ // Keyboard order: highest value first, among items that are on the map.
154
+ const items: MapRegion[] = React.useMemo(() => {
155
+ const raw = isMarkers ? markers!.map((m) => ({ id: m.id, label: m.label, value: m.value })) : values!.map((v) => ({ id: v.id, label: v.label ?? byId.get(v.id)?.name ?? v.id, value: v.value }))
156
+ return raw.sort((a, b) => b.value - a.value)
157
+ }, [values, markers, byId, isMarkers])
158
+ const total = items.reduce((a, v) => a + v.value, 0)
159
+ const max = Math.max(0, ...items.map((i) => i.value))
160
+ const t = thresholds ?? mapThresholds(max)
161
+ const valueOf = React.useMemo(() => new Map(items.map((i) => [i.id, i.value])), [items])
162
+ const onMap = (i: MapRegion) => (isMarkers ? true : byId.has(i.id))
163
+ const navigable = items.filter(onMap)
164
+ const offMap = items.filter((i) => !onMap(i))
165
+ const noData = isMarkers ? 0 : shapes.filter((s) => !valueOf.has(s.id)).length
166
+
167
+ const markerGeo = React.useMemo(() => {
168
+ if (!isMarkers || !project) return new Map<string, { x: number; y: number; r: number }>()
169
+ const rMax = Math.max(6, Math.min(22, width / 28))
170
+ return new Map(markers!.map((m) => {
171
+ const [x, y] = project([m.lon, m.lat]) ?? [0, 0]
172
+ return [m.id, { x, y, r: Math.max(2, Math.sqrt(m.value / (max || 1)) * rMax) }]
173
+ }))
174
+ }, [isMarkers, markers, project, width, max])
175
+
176
+ const cur = active === null ? null : navigable[active] ?? null
177
+ const posOf = (id: string): [number, number] | null => {
178
+ if (isMarkers) { const g = markerGeo.get(id); return g ? [g.x, g.y] : null }
179
+ return byId.get(id)?.at ?? null
180
+ }
181
+
182
+ const clear = () => { setPinned(false); setActive(null) }
183
+ const onKey = (e: React.KeyboardEvent) => {
184
+ const n = navigable.length
185
+ if (!n) return
186
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); setPinned(false); setActive((a) => (a === null ? 0 : Math.min(n - 1, a + 1))) }
187
+ else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); setPinned(false); setActive((a) => (a === null ? 0 : Math.max(0, a - 1))) }
188
+ else if (e.key === "Home") { e.preventDefault(); setPinned(false); setActive(0) }
189
+ else if (e.key === "End") { e.preventDefault(); setPinned(false); setActive(n - 1) }
190
+ else if (e.key === "Enter" && active !== null) { e.preventDefault(); setPinned(true) }
191
+ else if (e.key === "Escape") clear()
192
+ }
193
+ const pick = (id: string) => {
194
+ const i = navigable.findIndex((r) => r.id === id)
195
+ if (i >= 0) { setActive(i); setPinned(true) }
196
+ }
197
+ const hover = (id: string) => {
198
+ if (pinned) return
199
+ const i = navigable.findIndex((r) => r.id === id)
200
+ setHoverEmpty(i < 0 ? id : null)
201
+ setActive(i < 0 ? null : i)
202
+ }
203
+
204
+ const top3 = navigable.slice(0, 3).map((r) => `${r.label} ${format(r.value)}`).join(", ")
205
+ const summary = `${label}. ${isMarkers ? `${navigable.length} places sized by value` : `${navigable.length} regions shaded by value`}. Highest: ${top3 || "none"}.${noData ? ` ${noData} regions have no data.` : ""} Arrow keys move from highest to lowest; Enter shows details.`
206
+
207
+ const ranges = [
208
+ `Under ${format(t[0])}`,
209
+ `${format(t[0])} to ${format(t[1])}`,
210
+ `${format(t[1])} to ${format(t[2])}`,
211
+ `${format(t[2])} to ${format(t[3])}`,
212
+ `${format(t[3])} and over`,
213
+ ]
214
+
215
+ // Details box: kept inside the map's width and height, never covering the region it describes.
216
+ const box = (p: [number, number]) => {
217
+ const w = 208
218
+ const left = p[0] + 14 + w > width ? Math.max(0, p[0] - 14 - w) : p[0] + 14
219
+ return { "--x": `${left}px`, "--y": `${Math.max(0, Math.min(p[1] - 24, height - 96))}px` } as React.CSSProperties
220
+ }
221
+ const curPos = cur ? posOf(cur.id) : null
222
+ // On a narrow map the details would cover it, so they sit under the map instead.
223
+ const narrow = width < 480
224
+ const boxCls = (float: boolean) => cn("z-10 grid gap-1 rounded-md border border-line bg-surface px-2 py-2 text-sm shadow-pop", narrow ? "mt-2 w-full" : "absolute w-52 left-(--x) top-(--y)", !float && "pointer-events-none")
225
+ const emptyShape = hoverEmpty ? byId.get(hoverEmpty) : null
226
+
227
+ return (
228
+ <div className={cn("grid gap-3", className)}>
229
+ <div ref={wrap} className="relative w-full">
230
+ {width > 0 && height > 0 && (
231
+ <svg
232
+ ref={svgRef}
233
+ width={width}
234
+ height={height}
235
+ role="group"
236
+ tabIndex={0}
237
+ aria-roledescription="map"
238
+ aria-label={summary}
239
+ onKeyDown={onKey}
240
+ onMouseLeave={() => { setHoverEmpty(null); if (!pinned) setActive(null) }}
241
+ className={svgCls}
242
+ >
243
+ <defs>
244
+ <pattern id={hatch} width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
245
+ <rect width="5" height="5" className="fill-surface-2" />
246
+ <line x1="0" y1="0" x2="0" y2="5" strokeWidth="1.25" strokeOpacity={0.45} className="stroke-ink-3" />
247
+ </pattern>
248
+ </defs>
249
+ {shapes.map((s) => {
250
+ const v = valueOf.get(s.id)
251
+ const has = !isMarkers && v !== undefined
252
+ return (
253
+ <path
254
+ key={s.id}
255
+ d={s.d}
256
+ fill={isMarkers ? "var(--color-hover)" : has ? undefined : `url(#${hatch})`}
257
+ strokeWidth={0.5}
258
+ className={cn("stroke-surface", has && STEPS[classOf(v!, t)], !isMarkers && "cursor-pointer")}
259
+ onMouseEnter={isMarkers ? undefined : () => hover(s.id)}
260
+ onClick={isMarkers ? undefined : () => pick(s.id)}
261
+ />
262
+ )
263
+ })}
264
+ {!isMarkers && cur && byId.get(cur.id) && <path d={byId.get(cur.id)!.d} fill="none" strokeWidth={1.5} className="pointer-events-none stroke-ink" />}
265
+ {isMarkers && [...markers!].sort((a, b) => b.value - a.value).map((m) => {
266
+ const g = markerGeo.get(m.id)!
267
+ const on = cur?.id === m.id
268
+ return (
269
+ <circle
270
+ key={m.id}
271
+ cx={g.x}
272
+ cy={g.y}
273
+ r={g.r}
274
+ fill="var(--color-c1)"
275
+ fillOpacity={on ? 0.85 : 0.5}
276
+ strokeWidth={on ? 1.5 : 1}
277
+ className={cn("cursor-pointer", on ? "stroke-ink" : "stroke-surface")}
278
+ onMouseEnter={() => hover(m.id)}
279
+ onClick={() => pick(m.id)}
280
+ />
281
+ )
282
+ })}
283
+ </svg>
284
+ )}
285
+ {cur && curPos && (
286
+ <div
287
+ role={pinned ? "dialog" : "status"}
288
+ aria-label={pinned ? cur.label : undefined}
289
+ onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); clear(); svgRef.current?.focus() } }}
290
+ className={boxCls(pinned)}
291
+ style={narrow ? undefined : box(curPos)}
292
+ >
293
+ <span className="font-medium text-ink">{cur.label}</span>
294
+ <span className="flex justify-between gap-3 text-ink-2">{valueLabel}<span className="tabular font-medium text-ink">{format(cur.value)}</span></span>
295
+ {share && <span className="flex justify-between gap-3 text-ink-2">Share of total<span className="tabular font-medium text-ink">{pct(cur.value, total)}</span></span>}
296
+ <span className="flex justify-between gap-3 text-ink-2">Rank<span className="tabular font-medium text-ink">{items.indexOf(cur) + 1} of {items.length}</span></span>
297
+ {pinned && onSelect && (
298
+ <div className="mt-1 grid gap-2 border-t border-line pt-2">
299
+ <span className="text-xs text-ink-3">{filterField} is {cur.label}</span>
300
+ <Button size="xs" variant="outline" autoFocus onClick={() => { onSelect(cur); clear(); svgRef.current?.focus() }}>{selectLabel}</Button>
301
+ </div>
302
+ )}
303
+ </div>
304
+ )}
305
+ {!cur && emptyShape && (
306
+ <div role="status" className={boxCls(false)} style={narrow ? undefined : box(emptyShape.at)}>
307
+ <span className="font-medium text-ink">{emptyShape.name}</span>
308
+ <span className="text-ink-2">No data</span>
309
+ </div>
310
+ )}
311
+ </div>
312
+ {!isMarkers && (
313
+ <ul className="flex flex-wrap gap-x-4 gap-y-1" aria-label={`Legend: ${valueLabel}`}>
314
+ {ranges.map((r, i) => (
315
+ <li key={r} className="inline-flex items-center gap-control text-sm text-ink-2">
316
+ <span aria-hidden="true" className={cn("size-2.5 shrink-0 rounded-xs", SWATCH[i])} />
317
+ <span className="tabular">{r}</span>
318
+ </li>
319
+ ))}
320
+ {noData > 0 && (
321
+ <li className="inline-flex items-center gap-control text-sm text-ink-2">
322
+ <svg aria-hidden="true" width="10" height="10" className="shrink-0 overflow-hidden rounded-xs"><rect width="10" height="10" fill={`url(#${hatch})`} /></svg>
323
+ No data
324
+ </li>
325
+ )}
326
+ </ul>
327
+ )}
328
+ {isMarkers && max > 0 && (
329
+ <p className="text-sm text-ink-2">Circle area shows {valueLabel}. Largest: {navigable[0]?.label} {format(max)}.</p>
330
+ )}
331
+ {offMap.length > 0 && (
332
+ <p className="text-sm text-ink-3">Not on this map: {offMap.map((r) => r.label).join(", ")}. See the table view.</p>
333
+ )}
334
+ </div>
335
+ )
336
+ }