@humanforest/nuxt-layer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +208 -0
  3. package/app.config.ts +10 -0
  4. package/assets/icons/forest/bike-asterisk.svg +21 -0
  5. package/assets/icons/forest/bike-down.svg +19 -0
  6. package/assets/icons/forest/bike-off.svg +20 -0
  7. package/assets/icons/forest/bike-up.svg +19 -0
  8. package/assets/icons/forest/bike-x.svg +19 -0
  9. package/assets/icons/forest/bike.svg +18 -0
  10. package/assets/icons/forest/mark-fill.svg +6 -0
  11. package/assets/icons/forest/mark.svg +6 -0
  12. package/assets/icons/forest/parking.svg +3 -0
  13. package/assets/icons/forest/star-fill.svg +4 -0
  14. package/assets/icons/forest/star.svg +4 -0
  15. package/assets/icons/forest/traffic-light-caution.svg +8 -0
  16. package/assets/icons/forest/traffic-light-go.svg +8 -0
  17. package/assets/icons/forest/traffic-light-lit.svg +8 -0
  18. package/assets/icons/forest/traffic-light-stop.svg +8 -0
  19. package/assets/icons/forest/traffic-light.svg +8 -0
  20. package/nuxt.config.ts +81 -0
  21. package/package.json +35 -0
  22. package/packages/charts/src/FBoroughShape.vue +52 -0
  23. package/packages/charts/src/FCalendarGrid.vue +332 -0
  24. package/packages/charts/src/FCellLegend.vue +54 -0
  25. package/packages/charts/src/FChartFrame.vue +201 -0
  26. package/packages/charts/src/FChartFrameSingle.vue +91 -0
  27. package/packages/charts/src/FChartLegend.vue +26 -0
  28. package/packages/charts/src/FDistributionBar.vue +224 -0
  29. package/packages/charts/src/FDottedMap.vue +335 -0
  30. package/packages/charts/src/FLondonMap.vue +399 -0
  31. package/packages/charts/src/FSparkline.vue +198 -0
  32. package/packages/charts/src/FStatusTrack.vue +151 -0
  33. package/packages/charts/src/cellSize.ts +46 -0
  34. package/packages/charts/src/distribution.ts +99 -0
  35. package/packages/charts/src/engine.ts +15 -0
  36. package/packages/charts/src/forestTooltip.ts +110 -0
  37. package/packages/charts/src/fromCategories.ts +43 -0
  38. package/packages/charts/src/index.ts +87 -0
  39. package/packages/charts/src/londonAreas.ts +75 -0
  40. package/packages/charts/src/londonGrid.ts +67 -0
  41. package/packages/charts/src/motionDuration.ts +49 -0
  42. package/packages/charts/src/presets.ts +143 -0
  43. package/packages/charts/src/resolveVar.ts +21 -0
  44. package/packages/charts/src/snapTooltip.ts +154 -0
  45. package/packages/charts/src/tooltipStandIn.ts +58 -0
  46. package/packages/charts/src/useCellScale.ts +155 -0
  47. package/packages/charts/src/useChartPalette.ts +61 -0
  48. package/packages/charts/src/useChartRepaintKey.ts +22 -0
  49. package/packages/charts/src/useThemeVersion.ts +17 -0
  50. package/packages/frames/src/FBrowserFrame.vue +53 -0
  51. package/packages/frames/src/FDeviceFrame.vue +242 -0
  52. package/packages/frames/src/FFitFrame.vue +83 -0
  53. package/packages/frames/src/art/iphone-11.svg +124 -0
  54. package/packages/frames/src/art/iphone-12.svg +601 -0
  55. package/packages/frames/src/art/iphone-13-pro.svg +820 -0
  56. package/packages/frames/src/art/iphone-13.svg +849 -0
  57. package/packages/frames/src/art/iphone-14.svg +681 -0
  58. package/packages/frames/src/art/iphone-15-pro.svg +633 -0
  59. package/packages/frames/src/art/iphone-15.svg +713 -0
  60. package/packages/frames/src/art/iphone-16-plus.svg +685 -0
  61. package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
  62. package/packages/frames/src/art/iphone-16-pro.svg +631 -0
  63. package/packages/frames/src/art/iphone-16.svg +713 -0
  64. package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
  65. package/packages/frames/src/art/iphone-17.svg +305 -0
  66. package/packages/frames/src/art/iphone-air.svg +389 -0
  67. package/packages/frames/src/art/iphone-se-3.svg +473 -0
  68. package/packages/frames/src/art/iphone-x.svg +652 -0
  69. package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
  70. package/packages/frames/src/art/pixel-10-pro.svg +32 -0
  71. package/packages/frames/src/art/pixel-10.svg +32 -0
  72. package/packages/frames/src/art/pixel-8-pro.svg +208 -0
  73. package/packages/frames/src/art/pixel-8.svg +208 -0
  74. package/packages/frames/src/art/pixel-8a.svg +8 -0
  75. package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
  76. package/packages/frames/src/art/pixel-9-pro.svg +34 -0
  77. package/packages/frames/src/art/pixel-9a.svg +25 -0
  78. package/packages/frames/src/art.ts +29 -0
  79. package/packages/frames/src/devices.ts +337 -0
  80. package/packages/frames/src/index.ts +15 -0
  81. package/packages/tokens/geo/london-boroughs.json +1 -0
  82. package/packages/tokens/geo/london-dots-coarse.json +1 -0
  83. package/packages/tokens/geo/london-dots-fine.json +1 -0
  84. package/packages/tokens/geo/london-dots-medium.json +1 -0
  85. package/packages/tokens/geo/london-dots-ultra.json +1 -0
  86. package/packages/tokens/geo/london-wards.json +1 -0
  87. package/packages/tokens/glyphs/glyphs.json +1 -0
  88. package/packages/tokens/logos/f-rot.svg +4 -0
  89. package/packages/tokens/logos/f.svg +4 -0
  90. package/packages/tokens/logos/favicon-c2w.svg +9 -0
  91. package/packages/tokens/logos/favicon-console.svg +9 -0
  92. package/packages/tokens/logos/favicon-ds.svg +9 -0
  93. package/packages/tokens/logos/favicon-fleet.svg +9 -0
  94. package/packages/tokens/logos/favicon-flex.svg +9 -0
  95. package/packages/tokens/logos/favicon-plus.svg +9 -0
  96. package/packages/tokens/logos/favicon-radar.svg +9 -0
  97. package/packages/tokens/logos/favicon-tower.svg +9 -0
  98. package/packages/tokens/logos/lockup-horizontal.svg +17 -0
  99. package/packages/tokens/logos/lockup-vertical.svg +17 -0
  100. package/packages/tokens/logos/mark-mono.svg +4 -0
  101. package/packages/tokens/logos/mark.svg +7 -0
  102. package/packages/tokens/logos/wordmark.svg +9 -0
  103. package/packages/tokens/src/colourEngine.ts +604 -0
  104. package/packages/tokens/src/glyphs.ts +127 -0
  105. package/packages/tokens/src/logo.ts +50 -0
  106. package/packages/tokens/src/subbrands.ts +44 -0
  107. package/packages/tokens/src/sublogo.ts +80 -0
  108. package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
  109. package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
  110. package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
  111. package/packages/ui/icons/forest/map/manifest.json +373 -0
  112. package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
  113. package/packages/ui/icons/forest/map/place-manifest.json +74 -0
  114. package/packages/ui/src/address/address.ts +125 -0
  115. package/packages/ui/src/address/autofill.ts +78 -0
  116. package/packages/ui/src/address/geocode.ts +55 -0
  117. package/packages/ui/src/address/index.ts +31 -0
  118. package/packages/ui/src/address/validate.ts +91 -0
  119. package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
  120. package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
  121. package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
  122. package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
  123. package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
  124. package/packages/ui/src/canvas/canvasSurface.ts +39 -0
  125. package/packages/ui/src/canvas/index.ts +17 -0
  126. package/packages/ui/src/contexts/ForestContext.vue +53 -0
  127. package/packages/ui/src/contexts/contexts.ts +153 -0
  128. package/packages/ui/src/contexts/index.ts +23 -0
  129. package/packages/ui/src/contexts/marketing-theme.ts +214 -0
  130. package/packages/ui/src/contexts/mobile-theme.ts +402 -0
  131. package/packages/ui/src/filter/FFilterBar.vue +72 -0
  132. package/packages/ui/src/filter/FFilterChip.vue +362 -0
  133. package/packages/ui/src/filter/FFilterClauses.vue +104 -0
  134. package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
  135. package/packages/ui/src/filter/filter.theme.ts +27 -0
  136. package/packages/ui/src/filter/filterClause.ts +265 -0
  137. package/packages/ui/src/filter/filterPredicate.ts +134 -0
  138. package/packages/ui/src/filter/filterSchema.ts +82 -0
  139. package/packages/ui/src/filter/index.ts +36 -0
  140. package/packages/ui/src/forest-preset.ts +68 -0
  141. package/packages/ui/src/icons.ts +79 -0
  142. package/packages/ui/src/index.ts +17 -0
  143. package/packages/ui/src/kpi/FKpi.vue +253 -0
  144. package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
  145. package/packages/ui/src/kpi/index.ts +20 -0
  146. package/packages/ui/src/kpi/kpi.theme.ts +168 -0
  147. package/packages/ui/src/kpi/kpiDelta.ts +82 -0
  148. package/packages/ui/src/kpi/kpiFormat.ts +72 -0
  149. package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
  150. package/packages/ui/src/logo/FLogo.vue +52 -0
  151. package/packages/ui/src/logo/FSubLogo.vue +214 -0
  152. package/packages/ui/src/logo/index.ts +8 -0
  153. package/packages/ui/src/map/FMap.vue +355 -0
  154. package/packages/ui/src/map/FMapCompass.vue +154 -0
  155. package/packages/ui/src/map/FMapCoordinates.vue +134 -0
  156. package/packages/ui/src/map/FMapDrawTools.vue +116 -0
  157. package/packages/ui/src/map/FMapFullscreen.vue +92 -0
  158. package/packages/ui/src/map/FMapGeolocate.vue +208 -0
  159. package/packages/ui/src/map/FMapLayers.vue +75 -0
  160. package/packages/ui/src/map/FMapLegend.vue +45 -0
  161. package/packages/ui/src/map/FMapPopup.vue +129 -0
  162. package/packages/ui/src/map/FMapRamp.vue +69 -0
  163. package/packages/ui/src/map/FMapRegion.vue +77 -0
  164. package/packages/ui/src/map/FMapScale.vue +107 -0
  165. package/packages/ui/src/map/FMapSearch.vue +362 -0
  166. package/packages/ui/src/map/FMapTilt.vue +87 -0
  167. package/packages/ui/src/map/FMapTools.vue +61 -0
  168. package/packages/ui/src/map/FMapZoom.vue +81 -0
  169. package/packages/ui/src/map/FMarker.vue +218 -0
  170. package/packages/ui/src/map/FTrackScrubber.vue +94 -0
  171. package/packages/ui/src/map/FVehicleLegend.vue +72 -0
  172. package/packages/ui/src/map/FVehicleMark.vue +166 -0
  173. package/packages/ui/src/map/bin.ts +196 -0
  174. package/packages/ui/src/map/coordinates.ts +83 -0
  175. package/packages/ui/src/map/density.ts +401 -0
  176. package/packages/ui/src/map/draw.ts +356 -0
  177. package/packages/ui/src/map/ease.ts +67 -0
  178. package/packages/ui/src/map/fullscreen.ts +59 -0
  179. package/packages/ui/src/map/heat.ts +160 -0
  180. package/packages/ui/src/map/index.ts +351 -0
  181. package/packages/ui/src/map/liveMarkers.ts +60 -0
  182. package/packages/ui/src/map/map.css +116 -0
  183. package/packages/ui/src/map/mapColor.ts +31 -0
  184. package/packages/ui/src/map/mapContext.ts +12 -0
  185. package/packages/ui/src/map/mapLayers.ts +55 -0
  186. package/packages/ui/src/map/mapLegend.ts +70 -0
  187. package/packages/ui/src/map/mapSearch.theme.ts +79 -0
  188. package/packages/ui/src/map/mapSearch.ts +279 -0
  189. package/packages/ui/src/map/mapUrl.ts +117 -0
  190. package/packages/ui/src/map/markerAsset.ts +3 -0
  191. package/packages/ui/src/map/padding.ts +39 -0
  192. package/packages/ui/src/map/place.ts +192 -0
  193. package/packages/ui/src/map/ramp.ts +95 -0
  194. package/packages/ui/src/map/regionSelect.ts +163 -0
  195. package/packages/ui/src/map/route.ts +167 -0
  196. package/packages/ui/src/map/scale.ts +329 -0
  197. package/packages/ui/src/map/scaleBar.ts +67 -0
  198. package/packages/ui/src/map/selectionModel.ts +50 -0
  199. package/packages/ui/src/map/sources/coordinates.ts +58 -0
  200. package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
  201. package/packages/ui/src/map/style.ts +14 -0
  202. package/packages/ui/src/map/track.ts +279 -0
  203. package/packages/ui/src/map/useFeatureSelect.ts +230 -0
  204. package/packages/ui/src/map/useLiveMarkers.ts +84 -0
  205. package/packages/ui/src/map/useMap.ts +150 -0
  206. package/packages/ui/src/map/useMapDraw.ts +776 -0
  207. package/packages/ui/src/map/useMapOverlay.ts +421 -0
  208. package/packages/ui/src/map/useMapTableSelection.ts +101 -0
  209. package/packages/ui/src/map/useMapUrlState.ts +155 -0
  210. package/packages/ui/src/map/useMarkerSource.ts +163 -0
  211. package/packages/ui/src/map/useRegionSelect.ts +309 -0
  212. package/packages/ui/src/map/useTokenMode.ts +61 -0
  213. package/packages/ui/src/map/useTrackPlayback.ts +131 -0
  214. package/packages/ui/src/map/useVehicleFilter.ts +51 -0
  215. package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
  216. package/packages/ui/src/map/vehicle.ts +807 -0
  217. package/packages/ui/src/map/vehicleFilter.ts +284 -0
  218. package/packages/ui/src/map/vehicleLegend.ts +155 -0
  219. package/packages/ui/src/map/visibleFeatures.ts +192 -0
  220. package/packages/ui/src/map/zone.ts +291 -0
  221. package/packages/ui/src/shell/FAppNavbar.vue +68 -0
  222. package/packages/ui/src/shell/FAppShell.vue +163 -0
  223. package/packages/ui/src/shell/FAppSidebar.vue +215 -0
  224. package/packages/ui/src/shell/FPageToolbar.vue +126 -0
  225. package/packages/ui/src/shell/FScrollShadow.vue +43 -0
  226. package/packages/ui/src/shell/FSidePanel.vue +258 -0
  227. package/packages/ui/src/shell/FToolbarActions.vue +32 -0
  228. package/packages/ui/src/shell/index.ts +33 -0
  229. package/packages/ui/src/shell/nav.ts +37 -0
  230. package/packages/ui/src/shell/pageToolbar.ts +69 -0
  231. package/packages/ui/src/shell/shell.ts +74 -0
  232. package/packages/ui/src/shell/sidePanel.ts +115 -0
  233. package/packages/ui/src/themes/accordion.ts +36 -0
  234. package/packages/ui/src/themes/alert.ts +16 -0
  235. package/packages/ui/src/themes/auth-form.ts +19 -0
  236. package/packages/ui/src/themes/avatar-group.ts +9 -0
  237. package/packages/ui/src/themes/avatar.ts +18 -0
  238. package/packages/ui/src/themes/badge.ts +51 -0
  239. package/packages/ui/src/themes/banner.ts +21 -0
  240. package/packages/ui/src/themes/breadcrumb.ts +10 -0
  241. package/packages/ui/src/themes/button.mechanics.ts +191 -0
  242. package/packages/ui/src/themes/button.ts +136 -0
  243. package/packages/ui/src/themes/calendar.ts +29 -0
  244. package/packages/ui/src/themes/card.ts +67 -0
  245. package/packages/ui/src/themes/carousel.ts +41 -0
  246. package/packages/ui/src/themes/checkbox-group.ts +20 -0
  247. package/packages/ui/src/themes/checkbox.ts +47 -0
  248. package/packages/ui/src/themes/chip.ts +50 -0
  249. package/packages/ui/src/themes/collapsible.ts +26 -0
  250. package/packages/ui/src/themes/color-picker.ts +13 -0
  251. package/packages/ui/src/themes/command-palette.ts +37 -0
  252. package/packages/ui/src/themes/container.ts +14 -0
  253. package/packages/ui/src/themes/context-menu.ts +38 -0
  254. package/packages/ui/src/themes/dashboard-group.ts +11 -0
  255. package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
  256. package/packages/ui/src/themes/dashboard-panel.ts +18 -0
  257. package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
  258. package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
  259. package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
  260. package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
  261. package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
  262. package/packages/ui/src/themes/drawer.ts +45 -0
  263. package/packages/ui/src/themes/dropdown-menu.ts +38 -0
  264. package/packages/ui/src/themes/empty.ts +43 -0
  265. package/packages/ui/src/themes/field-group.ts +52 -0
  266. package/packages/ui/src/themes/file-upload.ts +38 -0
  267. package/packages/ui/src/themes/footer-columns.ts +13 -0
  268. package/packages/ui/src/themes/footer.ts +9 -0
  269. package/packages/ui/src/themes/form-field.ts +25 -0
  270. package/packages/ui/src/themes/form.ts +22 -0
  271. package/packages/ui/src/themes/header.ts +12 -0
  272. package/packages/ui/src/themes/icon.ts +61 -0
  273. package/packages/ui/src/themes/index.ts +99 -0
  274. package/packages/ui/src/themes/input-date.ts +73 -0
  275. package/packages/ui/src/themes/input-menu.ts +66 -0
  276. package/packages/ui/src/themes/input-number.ts +58 -0
  277. package/packages/ui/src/themes/input-rating.ts +25 -0
  278. package/packages/ui/src/themes/input-tags.ts +63 -0
  279. package/packages/ui/src/themes/input-time.ts +70 -0
  280. package/packages/ui/src/themes/input.ts +148 -0
  281. package/packages/ui/src/themes/kbd.ts +27 -0
  282. package/packages/ui/src/themes/link.ts +27 -0
  283. package/packages/ui/src/themes/listbox.ts +48 -0
  284. package/packages/ui/src/themes/main.ts +6 -0
  285. package/packages/ui/src/themes/marquee.ts +45 -0
  286. package/packages/ui/src/themes/modal.ts +46 -0
  287. package/packages/ui/src/themes/navigation-menu.ts +80 -0
  288. package/packages/ui/src/themes/page-anchors.ts +7 -0
  289. package/packages/ui/src/themes/page-aside.ts +7 -0
  290. package/packages/ui/src/themes/page-card.ts +22 -0
  291. package/packages/ui/src/themes/page-cta.ts +18 -0
  292. package/packages/ui/src/themes/page-feature.ts +13 -0
  293. package/packages/ui/src/themes/page-header.ts +16 -0
  294. package/packages/ui/src/themes/page-hero.ts +15 -0
  295. package/packages/ui/src/themes/page-links.ts +12 -0
  296. package/packages/ui/src/themes/page-section.ts +14 -0
  297. package/packages/ui/src/themes/page.ts +7 -0
  298. package/packages/ui/src/themes/pagination.ts +17 -0
  299. package/packages/ui/src/themes/pin-input.ts +73 -0
  300. package/packages/ui/src/themes/popover.ts +30 -0
  301. package/packages/ui/src/themes/progress.ts +29 -0
  302. package/packages/ui/src/themes/prose.ts +45 -0
  303. package/packages/ui/src/themes/radio-group.ts +39 -0
  304. package/packages/ui/src/themes/scroll-area.ts +23 -0
  305. package/packages/ui/src/themes/select-menu.ts +71 -0
  306. package/packages/ui/src/themes/select.ts +72 -0
  307. package/packages/ui/src/themes/separator.ts +28 -0
  308. package/packages/ui/src/themes/sidebar.ts +127 -0
  309. package/packages/ui/src/themes/skeleton.ts +15 -0
  310. package/packages/ui/src/themes/slideover.ts +87 -0
  311. package/packages/ui/src/themes/slider.ts +48 -0
  312. package/packages/ui/src/themes/stepper.ts +41 -0
  313. package/packages/ui/src/themes/switch.ts +63 -0
  314. package/packages/ui/src/themes/table.ts +34 -0
  315. package/packages/ui/src/themes/tabs.ts +74 -0
  316. package/packages/ui/src/themes/textarea.ts +77 -0
  317. package/packages/ui/src/themes/timeline.ts +25 -0
  318. package/packages/ui/src/themes/toast.ts +29 -0
  319. package/packages/ui/src/themes/toaster.ts +66 -0
  320. package/packages/ui/src/themes/tooltip.ts +31 -0
  321. package/packages/ui/src/themes/tree.ts +41 -0
  322. package/packages/ui/src/themes/user.ts +28 -0
  323. package/styles.cdn.gen.css +2047 -0
  324. package/theme.gen.json +1 -0
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts" generic="Data">
2
+ // The single-container frame, for the non-XY charts (Donut, Sankey, Graph, Treemap). Same
3
+ // doctrine as FChartFrame: wrap only ceremony — container, legend, tooltip wiring, repaint —
4
+ // never mark props. `data` is whatever the mark's container expects (number[] for a donut,
5
+ // { nodes, links } for a sankey) and passes through untyped by the frame, typed by the mark.
6
+ import { computed, ref } from 'vue';
7
+ import { VisSingleContainer, VisTooltip, VisBulletLegend } from '@unovis/vue';
8
+ import type { BulletLegendItemInterface, TooltipConfigInterface } from '@unovis/ts';
9
+ // Relative file imports (not the package barrel): the barrel exports this component, and a
10
+ // self-import through it would create a module cycle.
11
+ import { fromCategories } from './fromCategories';
12
+ import { motionDuration } from './motionDuration';
13
+ import { tooltipDefaults, tooltipContainer } from './presets';
14
+ import { useThemeVersion } from './useThemeVersion';
15
+ import { useTooltipSnap, nextTipId } from './snapTooltip';
16
+
17
+ defineOptions({ inheritAttrs: false });
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ data: Data;
22
+ /** Optional: series colours + legend. Omit for charts that label directly (Sankey, Graph). */
23
+ categories?: Record<string, BulletLegendItemInterface>;
24
+ height?: number;
25
+ /** Rendered only when given — single-container tooltips need `triggers` to show anything. */
26
+ tooltip?: Partial<TooltipConfigInterface>;
27
+ /** Let the panel track the pointer instead of snapping to the hovered mark. Off by default:
28
+ * the panel anchors to the centre of the shape under the cursor and holds there, so it moves
29
+ * when the reading changes rather than with every pixel. Turn it on for a free-floating panel.
30
+ */
31
+ followCursor?: boolean;
32
+ legend?: boolean;
33
+ /** Where the legend sits. `bottom` (default) renders centred under the chart, caption-style;
34
+ * `top` renders start-aligned above it, editorial-style. */
35
+ legendPosition?: 'top' | 'bottom';
36
+ /** Animation ms for the container's components. Default: motionDuration('slow'). */
37
+ duration?: number;
38
+ /** Extra signals that must remount the chart (theme flips and palette identity are automatic). */
39
+ repaintOn?: unknown[];
40
+ }>(),
41
+ { height: 260, legend: true, legendPosition: 'bottom', followCursor: false, repaintOn: () => [] },
42
+ );
43
+
44
+ defineSlots<{
45
+ mark(p: { data: Data; colors: string[] }): unknown;
46
+ }>();
47
+
48
+ const acc = computed(() => fromCategories(props.categories ?? {}));
49
+
50
+ // As in FChartFrame: the panel is snapped to the hovered mark by useTooltipSnap, so what Unovis is
51
+ // configured to do is the fallback for when the snap stands down. Pointer tracking is that
52
+ // fallback — never the library's element pinning, which anchors to a bounding box that for these
53
+ // marks (arcs, groups, links) is nothing like the shape under the cursor.
54
+ const tipId = nextTipId();
55
+ const tooltipConfig = computed<Partial<TooltipConfigInterface>>(() => ({
56
+ ...tooltipDefaults,
57
+ container: tooltipContainer(),
58
+ followCursor: true,
59
+ ...(props.tooltip || {}),
60
+ attributes: { ...(props.tooltip || {}).attributes, 'data-forest-tip': tipId },
61
+ }));
62
+
63
+ const rootEl = ref<HTMLElement>();
64
+ useTooltipSnap(rootEl, tipId, 'mark', () => !props.followCursor);
65
+ const resolvedDuration = computed(() => props.duration ?? motionDuration());
66
+
67
+ const themeVersion = useThemeVersion();
68
+ const repaintKey = computed(() =>
69
+ [themeVersion.value, acc.value.colors.join('|'), ...props.repaintOn].map(String).join('-'),
70
+ );
71
+ </script>
72
+
73
+ <template>
74
+ <div ref="rootEl" style="display: flex; flex-direction: column; gap: 0.75rem">
75
+ <VisSingleContainer
76
+ :key="repaintKey"
77
+ :data="data"
78
+ :height="height"
79
+ :duration="resolvedDuration"
80
+ v-bind="$attrs"
81
+ >
82
+ <slot name="mark" :data="data" :colors="acc.colors" />
83
+ <VisTooltip v-if="tooltip" v-bind="tooltipConfig" />
84
+ </VisSingleContainer>
85
+ <VisBulletLegend
86
+ v-if="legend && acc.legendItems.length"
87
+ :items="acc.legendItems"
88
+ :style="legendPosition === 'top' ? { order: -1, alignSelf: 'flex-start' } : { alignSelf: 'center' }"
89
+ />
90
+ </div>
91
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ // The house-positioned legend, for charts assembled without a frame. FChartFrame and
3
+ // FChartFrameSingle place their own legend; a bare VisBulletLegend has no opinion about where it
4
+ // sits, so direct charts would otherwise each hand-roll the same wrapper. Same items prop and the
5
+ // same two positions the frames take, so a chart reads identically whichever way it is built.
6
+ import { VisBulletLegend } from '@unovis/vue';
7
+ import type { BulletLegendItemInterface } from '@unovis/ts';
8
+
9
+ withDefaults(
10
+ defineProps<{
11
+ items: BulletLegendItemInterface[];
12
+ /** `bottom` (default) centres it under the chart; `top` sits it start-aligned above. */
13
+ position?: 'top' | 'bottom';
14
+ }>(),
15
+ { position: 'bottom' },
16
+ );
17
+ </script>
18
+
19
+ <template>
20
+ <div
21
+ style="display: flex"
22
+ :style="{ justifyContent: position === 'top' ? 'flex-start' : 'center' }"
23
+ >
24
+ <VisBulletLegend :items="items" />
25
+ </div>
26
+ </template>
@@ -0,0 +1,224 @@
1
+ <script setup lang="ts">
2
+ // One bar split into its parts: what a figure is made of, at the size of a figure. Sits in an FKpi
3
+ // `visual` slot beside the number it decomposes, where a donut would need a legend, a centre label
4
+ // and more height than a tile has.
5
+ //
6
+ // Plain DOM on a grid rather than SVG. Grid takes the gaps out of the track widths itself, so the
7
+ // segments still sum to the bar — the same layout in flex needs percentages that the gaps then
8
+ // overflow.
9
+ //
10
+ // Deliberately not a stacked bar chart: one bar, one moment. A composition that moves over time is
11
+ // a chart, and FChartFrame is the component for that.
12
+ //
13
+ // Two looks. Split (default) gives each part its own pill, which says "these are separate things,
14
+ // here is their relative size". `joined` closes the gaps and rounds only the bar's outside corners,
15
+ // which says "this is one quantity, here is what it is made of". The corner shaping is what does
16
+ // the work: pills at gap 0 pinch at every seam.
17
+ //
18
+ // Joined is NOT the component for a plain progress or capacity bar — one value against a maximum,
19
+ // no parts, is UProgress, which owns the semantics (role, value text, step labels) and the intent
20
+ // colours. This is for the case UProgress cannot draw: the filled length itself split by what made
21
+ // it up.
22
+ //
23
+ // In an FKpi `visual` slot, pass `visual-fit="inset"` on the tile. The slot's default fit bleeds to
24
+ // the card's side and bottom edges, which is right for a sparkline — it holds no text and its
25
+ // baseline is the card's. This holds a legend, so it wants the padding back and top alignment.
26
+ import { computed, ref } from 'vue';
27
+ import { categoricalVar, type CategoricalPalette } from './useChartPalette';
28
+ import {
29
+ distributionShares,
30
+ formatShare,
31
+ segmentRadius,
32
+ type DistributionSegment,
33
+ } from './distribution';
34
+
35
+ const props = withDefaults(
36
+ defineProps<{
37
+ /** The parts. Order is the drawing order, left to right, and fixes each part's palette slot. */
38
+ data: DistributionSegment[];
39
+ /**
40
+ * The whole the parts are measured against. Defaults to their sum, which makes the bar a pure
41
+ * composition. Pass a larger number to leave a remainder — "78% of capacity, and here is what
42
+ * used it" is one bar, not two.
43
+ */
44
+ total?: number;
45
+ /** Bar thickness. */
46
+ height?: number;
47
+ /** Space between segments. Ignored when `joined` — a joined bar has no space to give. */
48
+ gap?: number;
49
+ /**
50
+ * Draws the parts as ONE continuous bar: no gaps, and only the outside corners rounded, so the
51
+ * seams are square. For a quantity read as one thing that happens to be made of parts, where
52
+ * separate pills would read as separate objects. One value and no parts is UProgress, not this.
53
+ */
54
+ joined?: boolean;
55
+ palette?: CategoricalPalette;
56
+ legend?: boolean;
57
+ /** Adds each part's percentage to its legend entry. The bar states shares by length either way. */
58
+ showShare?: boolean;
59
+ locale?: string;
60
+ /**
61
+ * Hover and focus text on the segments. `true` uses "<label> — <value> (<share>)"; a function
62
+ * writes its own. Given either, the segments become real buttons so a keyboard reaches them —
63
+ * which is also why it is opt-in: three tiles of three parts is nine extra tab stops for a
64
+ * figure the legend already states.
65
+ */
66
+ tooltip?: boolean | ((segment: DistributionSegment, share: number) => string);
67
+ /** Overrides the generated sentence for assistive tech. */
68
+ ariaLabel?: string;
69
+ }>(),
70
+ {
71
+ height: 8,
72
+ gap: 2,
73
+ joined: false,
74
+ palette: 'cvdSafe',
75
+ legend: true,
76
+ showShare: false,
77
+ locale: 'en-GB',
78
+ total: undefined,
79
+ /* Vue casts an ABSENT Boolean-typed prop to false, which is the intended default here — but it
80
+ is spelled out so the union with the formatter function keeps a declared default either way. */
81
+ tooltip: false,
82
+ ariaLabel: undefined,
83
+ },
84
+ );
85
+
86
+ const shares = computed(() => distributionShares(props.data, props.total));
87
+
88
+ const colorOf = (part: { segment: DistributionSegment; index: number }) =>
89
+ part.segment.color ?? categoricalVar(props.palette, part.index);
90
+
91
+ // A zero-width track would still take its share of the gap, leaving a stray notch where nothing is.
92
+ const drawn = computed(() => shares.value.parts.filter((p) => p.share > 0));
93
+
94
+ // Grid tracks in fr, so the gaps come out of the tracks rather than pushing the last one out.
95
+ const columns = computed(() => {
96
+ const tracks = drawn.value.map((p) => `${p.share}fr`);
97
+ if (shares.value.remainder > 0) tracks.push(`${shares.value.remainder}fr`);
98
+ return tracks.join(' ');
99
+ });
100
+
101
+ // How many tracks actually render — the drawn segments plus the remainder when there is one. The
102
+ // corner shaping needs this, and it is NOT `data.length`: zero-share parts are filtered out, so the
103
+ // last DRAWN segment is what carries the bar's right-hand corners.
104
+ const trackCount = computed(() => drawn.value.length + (shares.value.remainder > 0 ? 1 : 0));
105
+ const radiusAt = (index: number) =>
106
+ segmentRadius(index, trackCount.value, props.height / 2, props.joined);
107
+
108
+ // The bar's lengths are the only place the shares are stated unless `showShare` is on, so the
109
+ // sentence carries them regardless.
110
+ const label = computed(() => {
111
+ if (props.ariaLabel) return props.ariaLabel;
112
+ const parts = shares.value.parts.map(
113
+ (p) => `${p.segment.label} ${formatShare(p.share, props.locale)}`,
114
+ );
115
+ if (shares.value.remainder > 0)
116
+ parts.push(`unaccounted ${formatShare(shares.value.remainder, props.locale)}`);
117
+ return parts.join(', ');
118
+ });
119
+
120
+ const interactive = computed(() => props.tooltip !== false);
121
+
122
+ const num = (n: number) => n.toLocaleString(props.locale);
123
+
124
+ const tipFor = (segment: DistributionSegment, share: number) =>
125
+ typeof props.tooltip === 'function'
126
+ ? props.tooltip(segment, share)
127
+ : `${segment.label} — ${num(segment.value)} (${formatShare(share, props.locale)})`;
128
+
129
+ // The remainder is not one of `data`, so it has no segment to hand a formatter. It gets the same
130
+ // sentence shape from the numbers the shares already carry.
131
+ const remainderTip = computed(
132
+ () =>
133
+ `Unaccounted — ${num(Math.round(shares.value.remainder * shares.value.total))} (${formatShare(shares.value.remainder, props.locale)})`,
134
+ );
135
+
136
+ // One UTooltip for the whole bar, re-anchored to the segment under the pointer, rather than one
137
+ // per segment: `reference` moves the popper's anchor while the trigger stays the bar itself, so a
138
+ // bar of any width costs a single tooltip root.
139
+ //
140
+ // `tipOpen` owns visibility; the text and the anchor are left alone on close. Clearing them
141
+ // together with the flag emptied the bubble mid-exit-animation and dropped the anchor back to the
142
+ // trigger, so leaving a segment flashed a blank pill at the container's centre for ~80ms.
143
+ //
144
+ // Anchored to the segment, not the pointer, so a keyboard focus places it too.
145
+ const tip = ref<{ text: string; el: HTMLElement | null }>({ text: '', el: null });
146
+ const tipOpen = ref(false);
147
+ const showTip = (event: Event, text: string) => {
148
+ tip.value = { text, el: event.currentTarget as HTMLElement };
149
+ tipOpen.value = true;
150
+ };
151
+ const hideTip = () => {
152
+ tipOpen.value = false;
153
+ };
154
+ </script>
155
+
156
+ <template>
157
+ <div class="flex w-full flex-col gap-2">
158
+ <!-- `img` makes its contents presentational, which is right while the bar is a graphic and
159
+ wrong the moment the segments are buttons — the buttons would be hidden from the very
160
+ readers the role is for. Interactive, it becomes a labelled group whose children stay
161
+ reachable, and each segment states itself. -->
162
+ <UTooltip :text="tip.text" :reference="tip.el" :open="tipOpen">
163
+ <div
164
+ class="grid w-full"
165
+ :role="interactive ? 'group' : 'img'"
166
+ :aria-label="label"
167
+ :style="{
168
+ gridTemplateColumns: columns,
169
+ gap: `${joined ? 0 : gap}px`,
170
+ height: `${height}px`,
171
+ }"
172
+ >
173
+ <component
174
+ :is="interactive ? 'button' : 'span'"
175
+ v-for="(p, i) in drawn"
176
+ :key="p.index"
177
+ :type="interactive ? 'button' : undefined"
178
+ :class="
179
+ interactive
180
+ ? 'cursor-pointer transition-[filter] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]'
181
+ : ''
182
+ "
183
+ :style="{ background: colorOf(p), borderRadius: radiusAt(i) }"
184
+ :aria-label="interactive ? tipFor(p.segment, p.share) : undefined"
185
+ @mouseenter="interactive && showTip($event, tipFor(p.segment, p.share))"
186
+ @mouseleave="hideTip"
187
+ @focus="interactive && showTip($event, tipFor(p.segment, p.share))"
188
+ @blur="hideTip"
189
+ />
190
+ <!-- The remainder is the track colour, not a palette slot: it is the absence of a category,
191
+ and giving it a colour would make it read as one more. -->
192
+ <component
193
+ :is="interactive ? 'button' : 'span'"
194
+ v-if="shares.remainder > 0"
195
+ :type="interactive ? 'button' : undefined"
196
+ class="bg-[var(--ui-bg-accented)]"
197
+ :class="
198
+ interactive
199
+ ? 'cursor-pointer transition-[filter] duration-150 hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ui-primary)]'
200
+ : ''
201
+ "
202
+ :style="{ borderRadius: radiusAt(trackCount - 1) }"
203
+ :aria-label="interactive ? remainderTip : undefined"
204
+ @mouseenter="interactive && showTip($event, remainderTip)"
205
+ @mouseleave="hideTip"
206
+ @focus="interactive && showTip($event, remainderTip)"
207
+ @blur="hideTip"
208
+ />
209
+ </div>
210
+ </UTooltip>
211
+
212
+ <!-- Centred: the entries are a caption for the bar rather than a column of their own, and a
213
+ wrapped second row reads as ragged against a left edge the bar does not share. -->
214
+ <div v-if="legend" class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
215
+ <span v-for="p in shares.parts" :key="p.index" class="flex items-center gap-1.5">
216
+ <span class="size-2 rounded-full" :style="{ background: colorOf(p) }" aria-hidden="true" />
217
+ <span class="type-caption text-muted">{{ p.segment.label }}</span>
218
+ <span v-if="showShare" class="type-caption text-dimmed">
219
+ {{ formatShare(p.share, locale) }}
220
+ </span>
221
+ </span>
222
+ </div>
223
+ </div>
224
+ </template>
@@ -0,0 +1,335 @@
1
+ <script setup lang="ts" generic="P extends Record<string, unknown>">
2
+ // London as a grid of dots, coloured by how many points land in each. Third of the cell components:
3
+ // FStatusTrack puts cells in a sequence, FCalendarGrid puts them on a date grid, this puts them on a
4
+ // geography. Colour resolution is useCellScale's; this component owns the binning and the layout.
5
+ //
6
+ // It is not a map. There is no pan, no zoom and no projection at read time — a dot is roughly a
7
+ // square kilometre and locates nothing. Real geography is Mapbox's job. This answers "where in
8
+ // London" at a glance, with no tiles and no network.
9
+ import { computed, ref, shallowRef, watchEffect } from 'vue';
10
+ import { useCellScale, type SequentialRamp } from './useCellScale';
11
+ import { CELL_SIZES, orElse, type CellSize } from './cellSize';
12
+ import {
13
+ LONDON_GRID,
14
+ RIVER_COLOR,
15
+ loadLondonGrid,
16
+ dotAt,
17
+ dotKey,
18
+ type DotDensity,
19
+ type LondonGrid,
20
+ } from './londonGrid';
21
+ import FCellLegend from './FCellLegend.vue';
22
+
23
+ interface DotInfo {
24
+ col: number;
25
+ row: number;
26
+ value: number;
27
+ }
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ points: P[];
32
+ lat: (p: P) => number;
33
+ lng: (p: P) => number;
34
+ /** Summed per dot as `value(p) ?? 1`, so the default reading is a count of points. */
35
+ value?: (p: P) => number | null | undefined;
36
+ ramp?: SequentialRamp;
37
+ /** Intensity domain, in the caller's units. Two maps compared side by side must both set it. */
38
+ domain?: [number, number];
39
+ steps?: number;
40
+ /** How a dot's total is spaced along the ramp. See `spaced` below for why this exists. */
41
+ scale?: 'linear' | 'sqrt' | 'log';
42
+ /** How many dots the map is sampled into. Separate from `size`, which is how big it is drawn:
43
+ * a coarse grid at lg is big dots, a fine grid at sm is a texture. Anything but the default is
44
+ * fetched on use. */
45
+ density?: DotDensity;
46
+ /** Size step. Sets the dot dimensions, and whether legend and tooltips default on:
47
+ * xs and sm are too small to carry chrome, so it defaults off there. Any explicit prop wins. */
48
+ size?: CellSize;
49
+ /** Forces the size step's chrome decision either way. `legend` still wins over it. */
50
+ chrome?: boolean;
51
+ dotSize?: number;
52
+ dotGap?: number;
53
+ /** Corner radius. Defaults to half the dot, which is a circle; 0 gives squares. */
54
+ radius?: number;
55
+ legend?: boolean;
56
+ /** Paint the Thames. Off, its cells are ordinary land — they take data and are coloured like
57
+ * any other dot, rather than leaving a gap where the channel was. */
58
+ river?: boolean;
59
+ /** Hover text. Suppressed at xs and sm, where the target is too small to hit reliably. */
60
+ tooltip?: (info: DotInfo) => string;
61
+ ariaLabel?: string;
62
+ }>(),
63
+ {
64
+ density: 'fine',
65
+ size: 'md',
66
+ steps: 5,
67
+ scale: 'linear',
68
+ /* Vue casts an ABSENT Boolean-typed prop to false, so without these the size step could never
69
+ switch chrome on: `orElse` would read that false as a deliberate "off" at every size. */
70
+ legend: undefined,
71
+ chrome: undefined,
72
+ river: true,
73
+ },
74
+ );
75
+
76
+ // Three tiers, narrowest first: an individual prop beats `chrome`, which beats the size step.
77
+ const spec = computed(() => CELL_SIZES[props.size]);
78
+
79
+ // Size sets the rendered width; the dot size follows from it and the density.
80
+ //
81
+ // Fixing the dot in pixels made the two settings the same setting — a denser grid simply drew a
82
+ // bigger map, so density could not be varied without resizing the chart. Deriving the dot from the
83
+ // width means a coarse grid at lg is big dots and a fine grid at lg is small ones, both filling the
84
+ // same frame. The widths match the London map step for step so the two sit at the same scale.
85
+ const MAP_WIDTH: Record<CellSize, number> = { xs: 192, sm: 383, md: 575, lg: 767 };
86
+ /** How much of each cell the dot fills; the remainder is the gap. */
87
+ const DOT_FILL = 0.78;
88
+ // Left fractional. Rounding the dot and the gap to whole pixels made the rendered width miss its
89
+ // target by up to ten per cent, and differently per density — which is the coupling this was meant
90
+ // to remove. These are path coordinates, so a fraction costs nothing.
91
+ const pitchFor = computed(() => MAP_WIDTH[props.size] / grid.value.width);
92
+ const dotSize = computed(() => orElse(props.dotSize, pitchFor.value * DOT_FILL));
93
+ const dotGap = computed(() => orElse(props.dotGap, pitchFor.value * (1 - DOT_FILL)));
94
+ const radius = computed(() => orElse(props.radius, dotSize.value / 2));
95
+ const showChrome = computed(() => orElse(props.chrome, spec.value.chrome));
96
+ const showLegend = computed(() => orElse(props.legend, showChrome.value));
97
+ const interactive = computed(() => !!props.tooltip && showChrome.value);
98
+
99
+ // The bundled resolution stands in while another loads, so changing density never blanks the map —
100
+ // the silhouette is the same either way, only how finely it is sampled changes.
101
+ const loaded = shallowRef<LondonGrid>(LONDON_GRID);
102
+ watchEffect(() => {
103
+ const want = props.density;
104
+ void loadLondonGrid(want).then((g) => {
105
+ if (props.density === want) loaded.value = g;
106
+ });
107
+ });
108
+ const grid = computed(() => loaded.value);
109
+
110
+ // With the river off, its cells become ordinary land rather than disappearing.
111
+ //
112
+ // The build step files them separately, so hiding the painting alone left a white channel through
113
+ // the middle of the map — "do not draw the Thames" read as "cut a hole where it was". They also have
114
+ // to rejoin the binning, or they would render as a guaranteed-zero stripe: points landing on them
115
+ // are dropped while they count as river.
116
+ const landDots = computed(() =>
117
+ props.river ? grid.value.dots : [...grid.value.dots, ...grid.value.river],
118
+ );
119
+
120
+ const land = computed(() => new Set(landDots.value.map(([c, r]) => dotKey(c, r))));
121
+
122
+ // Points are summed into their dot. A point outside the boundary, or on the river, has nowhere to
123
+ // land — those are counted rather than quietly absorbed, so a caller feeding the wrong city or the
124
+ // wrong coordinate order can see it. The river is drawn but is not a bucket: a ride does not start
125
+ // in the Thames, and letting one land there would put a reading on a dot that cannot have one.
126
+ const binned = computed(() => {
127
+ const totals = new Map<string, number>();
128
+ let dropped = 0;
129
+ for (const p of props.points) {
130
+ const at = dotAt(grid.value, props.lat(p), props.lng(p));
131
+ const key = at && dotKey(at[0], at[1]);
132
+ if (!key || !land.value.has(key)) {
133
+ dropped++;
134
+ continue;
135
+ }
136
+ totals.set(key, (totals.get(key) ?? 0) + (props.value?.(p) ?? 1));
137
+ }
138
+ return { totals, dropped };
139
+ });
140
+
141
+ // Every land dot gets a cell, whether or not a point reached it.
142
+ //
143
+ // This is where the family's "empty is a third outcome" rule inverts, and inverting it is what keeps
144
+ // the reading honest here. On a calendar a day with no datum might not have been measured; on a
145
+ // density map the coverage is complete by construction, so a dot with no points means zero events
146
+ // happened there. Colouring that as "no data" would assert something the data does not. Unhit dots
147
+ // therefore take the ramp's low stop, and the legend carries no empty swatch.
148
+ const cells = computed<DotInfo[]>(() =>
149
+ landDots.value.map(([col, row]) => ({
150
+ col,
151
+ row,
152
+ value: binned.value.totals.get(dotKey(col, row)) ?? 0,
153
+ })),
154
+ );
155
+
156
+ // How a total is spaced along the ramp.
157
+ //
158
+ // Density is heavy-tailed, and linearly spacing a heavy tail produces a blank map: with a typical
159
+ // week of rides the median dot holds 1 and the busiest holds over a hundred, so five equal buckets
160
+ // put roughly ninety per cent of the city in the palest one and the picture says nothing. Compress
161
+ // the tail and the structure appears. Nothing is compressed by default — a transform the caller did
162
+ // not ask for would quietly change what the ramp claims — but a density map usually wants one.
163
+ const spaced = (v: number) =>
164
+ props.scale === 'sqrt' ? Math.sqrt(v) : props.scale === 'log' ? Math.log1p(v) : v;
165
+
166
+ // The domain prop is in the caller's units, so it is spaced too — otherwise setting one would
167
+ // silently mean something different from leaving it off.
168
+ const spacedDomain = computed<[number, number] | undefined>(() =>
169
+ props.domain ? [spaced(props.domain[0]), spaced(props.domain[1])] : undefined,
170
+ );
171
+
172
+ const scale = useCellScale<DotInfo>({
173
+ mode: 'intensity',
174
+ ramp: computed(() => props.ramp ?? ('forest' as SequentialRamp)),
175
+ domain: spacedDomain,
176
+ steps: computed(() => props.steps),
177
+ data: cells,
178
+ value: (d) => spaced(d.value),
179
+ });
180
+
181
+ const pitch = computed(() => dotSize.value + dotGap.value);
182
+ const width = computed(() => grid.value.width * pitch.value - dotGap.value);
183
+ const height = computed(() => grid.value.height * pitch.value - dotGap.value);
184
+
185
+ // Dots are drawn as one path per colour, not one element per dot.
186
+ //
187
+ // At this density a rect each means four and a half thousand nodes per map, and a page showing
188
+ // several sizes of the same map put thirty thousand in one document — enough that the browser spends
189
+ // its time in layout rather than paint. Grouping by fill takes that to about seven nodes, because a
190
+ // path can hold any number of disjoint subpaths and every dot sharing a bucket also shares a fill.
191
+ const subpath = (col: number, row: number): string => {
192
+ const x = col * pitch.value;
193
+ const y = row * pitch.value;
194
+ const s = dotSize.value;
195
+ const r = Math.min(radius.value, s / 2);
196
+ if (r <= 0) return `M${x},${y}h${s}v${s}h${-s}z`;
197
+ // A radius of half the dot is a circle, which is the default and the common case.
198
+ if (r >= s / 2) return `M${x},${y + r}a${r},${r} 0 1,0 ${s},0a${r},${r} 0 1,0 ${-s},0`;
199
+ const m = s - 2 * r;
200
+ return `M${x + r},${y}h${m}a${r},${r} 0 0,1 ${r},${r}v${m}a${r},${r} 0 0,1 ${-r},${r}h${-m}a${r},${r} 0 0,1 ${-r},${-r}v${-m}a${r},${r} 0 0,1 ${r},${-r}z`;
201
+ };
202
+
203
+ /** One entry per distinct fill, in ramp order so the layer list is stable across renders. */
204
+ const layers = computed(() => {
205
+ const byFill = new Map<string, string[]>();
206
+ for (const cell of cells.value) {
207
+ const fill = scale.colorOf(cell);
208
+ const parts = byFill.get(fill);
209
+ if (parts) parts.push(subpath(cell.col, cell.row));
210
+ else byFill.set(fill, [subpath(cell.col, cell.row)]);
211
+ }
212
+ return [...byFill].map(([fill, parts]) => ({ fill, d: parts.join('') }));
213
+ });
214
+
215
+ const riverPath = computed(() =>
216
+ props.river ? grid.value.river.map(([c, r]) => subpath(c, r)).join('') : '',
217
+ );
218
+
219
+ /** Cells by grid position, so a pointer lands on its dot by arithmetic rather than by hit-testing. */
220
+ const byPosition = computed(() => {
221
+ const m = new Map<string, DotInfo>();
222
+ for (const c of cells.value) m.set(dotKey(c.col, c.row), c);
223
+ return m;
224
+ });
225
+
226
+ const mapLabel = computed(() => {
227
+ if (props.ariaLabel) return props.ariaLabel;
228
+ // The busiest dot in the caller's units. scale.domain is in spaced units and would report a
229
+ // logarithm as though it were a ride count.
230
+ const hi = props.domain?.[1] ?? cells.value.reduce((m, c) => (c.value > m ? c.value : m), 0);
231
+ const placed = props.points.length - binned.value.dropped;
232
+ return `London, as a grid of dots shaded by density. The busiest holds ${hi.toLocaleString('en-GB')}, across ${placed.toLocaleString('en-GB')} of ${props.points.length.toLocaleString('en-GB')} points.`;
233
+ });
234
+
235
+ // Anchored to the dot rather than the pointer. Dots are not focusable and deliberately so: promoting
236
+ // four thousand of them to buttons the way FCalendarGrid promotes 365 cells would put four thousand
237
+ // tab stops in the page. The summary on the wrapper carries the reading for anyone not using a
238
+ // pointer.
239
+ // The dot has no element of its own to anchor to — it is painted into a merged path — so the
240
+ // tooltip is given a VIRTUAL reference: floating-ui only ever asks an anchor for its bounding box,
241
+ // and here that box is the arithmetic the hit-testing already does.
242
+ const tip = ref<{ text: string; box: { x: number; y: number; w: number; h: number } | null }>({
243
+ text: '',
244
+ box: null,
245
+ });
246
+ const tipOpen = ref(false);
247
+ const tipReference = computed(() =>
248
+ tip.value.box
249
+ ? { getBoundingClientRect: () => new DOMRect(tip.value.box!.x, tip.value.box!.y, tip.value.box!.w, tip.value.box!.h) }
250
+ : null,
251
+ );
252
+
253
+ // One listener on an overlay, rather than one per dot. The grid is regular, so the dot under the
254
+ // pointer is arithmetic — which is both cheaper than four thousand hit targets and more accurate,
255
+ // because the gaps between dots resolve to their dot instead of falling through to nothing.
256
+ const trackPointer = (event: MouseEvent) => {
257
+ const box = (event.currentTarget as SVGRectElement).getBoundingClientRect();
258
+ // The svg is scaled to fit its container, so client pixels are converted through the viewBox
259
+ // rather than assumed to be user units.
260
+ const scaleX = width.value / box.width;
261
+ const col = Math.floor(((event.clientX - box.left) * scaleX) / pitch.value);
262
+ const row = Math.floor(((event.clientY - box.top) * (height.value / box.height)) / pitch.value);
263
+ const cell = byPosition.value.get(dotKey(col, row));
264
+ if (!cell) {
265
+ tipOpen.value = false;
266
+ return;
267
+ }
268
+ // Anchored to the dot, not the pointer: inside a 5px target a bubble that follows the cursor
269
+ // only jitters.
270
+ const scaleY = height.value / box.height;
271
+ tip.value = {
272
+ text: props.tooltip!(cell),
273
+ box: {
274
+ x: box.left + (col * pitch.value) / scaleX,
275
+ y: box.top + (row * pitch.value) / scaleY,
276
+ w: pitch.value / scaleX,
277
+ h: pitch.value / scaleY,
278
+ },
279
+ };
280
+ tipOpen.value = true;
281
+ };
282
+ const hideTip = () => {
283
+ tipOpen.value = false;
284
+ };
285
+
286
+ defineExpose({
287
+ /** Points that fell outside the landmass — off the grid entirely, or on the river. */
288
+ outOfBounds: computed(() => binned.value.dropped),
289
+ });
290
+ </script>
291
+
292
+ <template>
293
+ <div class="flex w-full flex-col gap-3">
294
+ <div class="overflow-x-auto">
295
+ <!-- max-width and height are inline rather than utilities: Tailwind does not scan this
296
+ package, so a class used only here is never generated and the rule silently does
297
+ nothing. width and height stay attributes for the intrinsic size, and height:auto is
298
+ what keeps the aspect once max-width takes over in a container narrower than the map. -->
299
+ <UTooltip :text="tip.text" :reference="tipReference" :open="tipOpen">
300
+ <svg
301
+ :width="width"
302
+ :height="height"
303
+ :viewBox="`0 0 ${width} ${height}`"
304
+ role="img"
305
+ :aria-label="mapLabel"
306
+ class="mx-auto block"
307
+ style="max-width: 100%; height: auto"
308
+ >
309
+ <!-- The river first, so a land dot wins any overlap. It carries no data and takes no hover:
310
+ the Thames is a label on the map, not a bucket with a reading. -->
311
+ <path v-if="riverPath" :d="riverPath" :fill="RIVER_COLOR" />
312
+
313
+ <path v-for="layer in layers" :key="layer.fill" :d="layer.d" :fill="layer.fill" />
314
+
315
+ <!-- One transparent target over the whole grid. The dot under the pointer is computed, so
316
+ the map keeps its tooltip without giving every dot its own node. -->
317
+ <rect
318
+ v-if="interactive"
319
+ :width="width"
320
+ :height="height"
321
+ fill="transparent"
322
+ @mousemove="trackPointer"
323
+ @mouseleave="hideTip"
324
+ />
325
+ </svg>
326
+ </UTooltip>
327
+ </div>
328
+
329
+ <!-- Centred with the map above it. The legend component itself is shared with the track and the
330
+ calendar, which are left-aligned, so the alignment belongs here rather than in it. -->
331
+ <div v-if="showLegend" class="flex justify-center">
332
+ <FCellLegend :legend="scale.legend.value" />
333
+ </div>
334
+ </div>
335
+ </template>