@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,196 @@
1
+ // The H3 seam — joining values to cells, and choosing which cell size a zoom deserves.
2
+ //
3
+ // ★ FOREST DOES NOT INVENT A GRID. The fleet server already indexes with H3 (`latLngToCell`, and a
4
+ // stored grid it ships as polygons), so a bespoke Forest hex grid would be a second, incompatible
5
+ // hexagonal picture of the same city. This module speaks H3's vocabulary and takes GEOMETRY IN.
6
+ //
7
+ // ★ AND IT TAKES NO DEPENDENCY. `h3-js` is ~60KB gzipped of emscripten output, and the path that
8
+ // exists does not need it: the cells arrive as a FeatureCollection carrying `h3_id`. What is needed
9
+ // here is the arithmetic AROUND the cells — how big one is, which resolution a zoom wants, how a value
10
+ // map joins onto them, and what domain a heavy-tailed feed deserves. All of that is a table and four
11
+ // functions. If a surface ever has raw points and no server to bin them, it imports h3-js itself and
12
+ // hands the result here; that is a call site's decision, not the design system's.
13
+ //
14
+ // Everything here is pure: no map, no DOM, no network. It runs in the node suite.
15
+
16
+ /**
17
+ * AVERAGE EDGE LENGTH IN METRES, PER H3 RESOLUTION.
18
+ *
19
+ * ★ THIS TABLE IS WHY A LEGEND CAN STATE ITS UNIT. A hex map with no stated cell size invites the
20
+ * reader to compare two cells that are not the same size as each other across a zoom, or to read a
21
+ * count as a density. "cell ≈ 460 m across" costs one line and closes both.
22
+ *
23
+ * H3 cells are not all identical — the icosahedral projection varies them by a few percent, and the
24
+ * twelve pentagons are their own case — so these are the documented AVERAGES. They are the right
25
+ * number for a legend and the wrong number for a calculation that has to balance; anything that must
26
+ * be exact should ask h3-js for the cell it actually holds.
27
+ */
28
+ export const H3_EDGE_M: Record<number, number> = {
29
+ 5: 8544,
30
+ 6: 3229,
31
+ 7: 1220,
32
+ 8: 461,
33
+ 9: 174,
34
+ 10: 65.8,
35
+ 11: 24.9,
36
+ };
37
+
38
+ /** The resolutions this ladder will choose between. Below 6 a cell is bigger than London. */
39
+ export const H3_RES_RANGE: readonly number[] = [6, 7, 8, 9, 10, 11];
40
+
41
+ /**
42
+ * How wide a cell should be ON SCREEN, in CSS px.
43
+ *
44
+ * Under the low end a cell stops being a thing a reader can point at — and stops being a thing a
45
+ * pointer can hit, which matters more, because a bin that cannot be hovered cannot state its count.
46
+ * Over the high end the grid stops being a texture and starts being a small number of large tiles,
47
+ * which reads as a coarse choropleth of nothing in particular.
48
+ *
49
+ * The band is wide on purpose: H3 resolutions step by roughly a factor of 2.6 in edge length, so no
50
+ * ladder can keep a cell inside a tight band across every zoom.
51
+ */
52
+ export const CELL_PX_BAND = { min: 40, max: 110, ideal: 65 } as const;
53
+
54
+ /** Web-mercator ground resolution — metres per CSS pixel at a zoom and latitude. */
55
+ export const metresPerPixel = (zoom: number, lat: number): number =>
56
+ (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** zoom;
57
+
58
+ /** How wide a cell of this resolution draws, in CSS px, at a zoom and latitude. */
59
+ export const cellWidthPx = (res: number, zoom: number, lat: number): number =>
60
+ (2 * H3_EDGE_M[res]) / metresPerPixel(zoom, lat);
61
+
62
+ /**
63
+ * The resolution to bin at for a zoom — the rung whose drawn cell lands nearest the ideal width.
64
+ *
65
+ * ★ THE LADDER IS WHERE THE ZOOM LIE LIVES, SO IT IS OWNED HERE RATHER THAN LEFT TO A CALL SITE.
66
+ * Binning at a fixed resolution and letting the reader zoom means the same data draws a different
67
+ * claim at every scale: at z10 a res-9 grid is a stipple with a count of one in most cells, and at z16
68
+ * a res-7 grid is four tiles. Neither is wrong about the data and both are wrong about the reader.
69
+ *
70
+ * ⚠ Changing resolution RE-BINS, which means the numbers change. A surface that follows this ladder
71
+ * must re-fetch or re-aggregate as it steps, and its legend must restate the cell size — that is the
72
+ * cost of not lying, and it is why `h3ResForZoom` is a decision a surface makes deliberately rather
73
+ * than something that happens to it.
74
+ */
75
+ export function h3ResForZoom(zoom: number, lat = 51.5): number {
76
+ let best = H3_RES_RANGE[0];
77
+ let bestGap = Infinity;
78
+ for (const res of H3_RES_RANGE) {
79
+ // Compared in log space: a cell twice too big and one half too small are the same error, which
80
+ // a linear difference would score very differently.
81
+ const gap = Math.abs(Math.log(cellWidthPx(res, zoom, lat) / CELL_PX_BAND.ideal));
82
+ if (gap < bestGap) {
83
+ bestGap = gap;
84
+ best = res;
85
+ }
86
+ }
87
+ return best;
88
+ }
89
+
90
+ /** Does a resolution actually draw inside the band at this zoom, or is it the least-bad rung? */
91
+ export const cellFitsBand = (res: number, zoom: number, lat = 51.5): boolean => {
92
+ const px = cellWidthPx(res, zoom, lat);
93
+ return px >= CELL_PX_BAND.min && px <= CELL_PX_BAND.max;
94
+ };
95
+
96
+ // ── joining values onto cells ────────────────────────────────────────────────────────────────────
97
+
98
+ interface CellFeature {
99
+ type: 'Feature';
100
+ properties: Record<string, unknown> | null;
101
+ geometry: unknown;
102
+ }
103
+ export interface CellCollection {
104
+ type: 'FeatureCollection';
105
+ features: CellFeature[];
106
+ }
107
+
108
+ export interface JoinOptions {
109
+ /** property holding the cell id on the incoming features. Default `h3_id` */
110
+ idProp?: string;
111
+ /** property the value is written to. Default `value` */
112
+ valueProp?: string;
113
+ /** drop cells with no reading instead of passing them through unvalued. Default false */
114
+ dropUnread?: boolean;
115
+ }
116
+
117
+ /**
118
+ * Join a `{ cellId: value }` map onto a collection of cell polygons.
119
+ *
120
+ * ★ A CELL WITH NO READING KEEPS NO VALUE — it does not get a zero. "Nobody counted here" and "we
121
+ * counted and it was none" are different claims, and a join that quietly writes 0 turns the first into
122
+ * the second everywhere the feed is sparse. The unvalued cell passes through without the value
123
+ * property so a layer filter can skip it (`['has', 'value']`), or is dropped outright with
124
+ * `dropUnread` where the surface would rather not ship the geometry at all.
125
+ */
126
+ export function joinCellValues(
127
+ cells: CellCollection,
128
+ values: Record<string, number>,
129
+ opts: JoinOptions = {},
130
+ ): CellCollection {
131
+ const { idProp = 'h3_id', valueProp = 'value', dropUnread = false } = opts;
132
+ const features: CellFeature[] = [];
133
+
134
+ for (const f of cells.features) {
135
+ const id = f.properties?.[idProp];
136
+ const v = typeof id === 'string' ? values[id] : undefined;
137
+ const read = typeof v === 'number' && Number.isFinite(v);
138
+ if (!read && dropUnread) continue;
139
+ features.push({
140
+ ...f,
141
+ properties: read
142
+ ? { ...(f.properties ?? {}), [valueProp]: v }
143
+ : { ...(f.properties ?? {}) },
144
+ });
145
+ }
146
+
147
+ return { type: 'FeatureCollection', features };
148
+ }
149
+
150
+ // ── domains ─────────────────────────────────────────────────────────────────────────────────────
151
+
152
+ export interface Domain {
153
+ domain: [number, number];
154
+ /** how many readings sit above the top of the domain and are painted at the top stop */
155
+ clamped: number;
156
+ /** the percentile the top was taken at, for the legend to state */
157
+ percentile: number;
158
+ }
159
+
160
+ /**
161
+ * `[0, p]` where p is a percentile of the readings — 95th by default.
162
+ *
163
+ * ★ A LINEAR DOMAIN OVER REAL OPS DATA IS A BLANK MAP. Counts per cell are heavy-tailed: one depot,
164
+ * one station, one Friday night, and the maximum is an order of magnitude above the median, which
165
+ * pushes every ordinary cell into the palest stop and paints the city as empty with one bright dot.
166
+ * Clamping at a percentile spends the ramp where the readings are.
167
+ *
168
+ * ★ AND THE CLAMP IS REPORTED, NOT HIDDEN. `clamped` is the count of cells painted at the top stop
169
+ * that are actually above it, so the legend can say "12 cells above 40" rather than implying the
170
+ * ramp's top is the maximum. A silent clamp is a lie with a percentile in front of it.
171
+ */
172
+ export function robustDomain(values: readonly number[], percentile = 0.95): Domain {
173
+ const finite = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
174
+ if (!finite.length) return { domain: [0, 0], clamped: 0, percentile };
175
+ const idx = Math.min(finite.length - 1, Math.max(0, Math.ceil(percentile * finite.length) - 1));
176
+ const top = finite[idx];
177
+ return {
178
+ domain: [0, top],
179
+ clamped: finite.filter((v) => v > top).length,
180
+ percentile,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * A domain for a SIGNED reading — `[-m, +m]`, symmetric by construction.
186
+ *
187
+ * ★ SYMMETRY IS NOT A CONVENIENCE, IT IS THE CLAIM. A diverging ramp says "this far from zero, in this
188
+ * direction". An asymmetric domain makes the same colour mean +20 on one side and −8 on the other, so
189
+ * the picture reads as a bias that is an artefact of the scale. Whichever arm is longer sets both.
190
+ */
191
+ export function symmetricDomain(values: readonly number[], percentile = 0.95): Domain {
192
+ const mags = values.filter((v) => Number.isFinite(v)).map(Math.abs);
193
+ const { domain, clamped } = robustDomain(mags, percentile);
194
+ const m = domain[1];
195
+ return { domain: [-m, m], clamped, percentile };
196
+ }
@@ -0,0 +1,83 @@
1
+ // Reading a coordinate pair out of something a person pasted.
2
+ //
3
+ // ★ THE ORDER IS GENUINELY AMBIGUOUS AND CANNOT ALWAYS BE RESOLVED. GeoJSON, Mapbox and this codebase
4
+ // all say [lng, lat]; Google Maps, every support ticket copied out of it, and most humans say
5
+ // "lat, lng". For London the two are far apart (51.5 and -0.05) and the pair reads unambiguously, but
6
+ // `1.5, 2.5` is a real location under either reading and no amount of cleverness settles it.
7
+ //
8
+ // So: use the range where it decides, and fall back to the convention people TYPE rather than the one
9
+ // the format uses. Anything above 90 cannot be a latitude, which resolves most real pastes on its own.
10
+
11
+ /** [lng, lat], the order the rest of the map subsystem speaks. */
12
+ export type LngLat = [number, number];
13
+
14
+ export interface ParseCoordinatesOptions {
15
+ /**
16
+ * What an ambiguous pair means — both readings valid, neither number above 90.
17
+ * @default 'lat-first' — what a person pastes out of Google Maps
18
+ */
19
+ assume?: 'lat-first' | 'lng-first';
20
+ }
21
+
22
+ const PAIR = /^\s*(-?\d+(?:\.\d+)?)\s*[,\s]\s*(-?\d+(?:\.\d+)?)\s*$/;
23
+
24
+ /**
25
+ * A coordinate pair, or null when the text is not one.
26
+ *
27
+ * Null rather than a guess: this feeds a search field where every other source is also being asked,
28
+ * and a loose parser that read "4412" or "E9 6NU" as a position would put a nonsense location above
29
+ * the vehicle someone was actually looking for.
30
+ */
31
+ export function parseCoordinates(text: string, options: ParseCoordinatesOptions = {}): LngLat | null {
32
+ const match = PAIR.exec(text);
33
+ if (!match) return null;
34
+
35
+ const a = Number(match[1]);
36
+ const b = Number(match[2]);
37
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
38
+
39
+ // Above 90 can only be a longitude, which settles the order without any convention.
40
+ const aIsLng = Math.abs(a) > 90;
41
+ const bIsLng = Math.abs(b) > 90;
42
+ if (aIsLng && bIsLng) return null; // two longitudes is not a position
43
+ const latFirst = bIsLng ? true : aIsLng ? false : (options.assume ?? 'lat-first') === 'lat-first';
44
+
45
+ const lat = latFirst ? a : b;
46
+ const lng = latFirst ? b : a;
47
+ if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
48
+ return [lng, lat];
49
+ }
50
+
51
+ /** How a coordinate pair is written back to a reader — [lng, lat] in, "lat, lng" out. */
52
+ export function formatCoordinates([lng, lat]: LngLat, places = 5): string {
53
+ return `${lat.toFixed(places)}, ${lng.toFixed(places)}`;
54
+ }
55
+
56
+ /** Web Mercator at the equator, for a 512px tile — the constant mapbox's zoom scale is built on. */
57
+ const METRES_PER_PIXEL_Z0 = 156543.03392;
58
+ /** One degree of latitude, in metres. Longitude narrows with latitude; latitude does not. */
59
+ const METRES_PER_DEGREE = 111320;
60
+
61
+ /**
62
+ * How many decimals a readout may honestly print at this camera.
63
+ *
64
+ * ★ PRECISION IS A CLAIM. Five decimals is about a metre. Printed at zoom 3, where a pixel spans
65
+ * kilometres, four of those digits are noise a reader cannot tell from signal — and a coordinate
66
+ * copied out of that readout is an address nobody visited. The reading is only ever as fine as the
67
+ * pixel it was taken from.
68
+ *
69
+ * `cos(lat)` is in it because a pixel covers less ground as latitude rises: the same zoom is a finer
70
+ * reading in Tromsø than in London. Same reason the scale bar measures the canvas rather than
71
+ * deriving a distance from the zoom level.
72
+ *
73
+ * Clamped at both ends — 2 so a world view still names a place rather than a hemisphere, 7 because
74
+ * past that the projection has nothing left to say.
75
+ */
76
+ export function coordinatePlaces(zoom: number, lat: number): number {
77
+ const z = Number.isFinite(zoom) ? zoom : 0;
78
+ // 89.9 rather than 90: cos(90°) is 0, and a pixel covering no ground asks for infinite decimals.
79
+ const safeLat = Number.isFinite(lat) ? Math.min(Math.abs(lat), 89.9) : 0;
80
+ const metresPerPixel = (METRES_PER_PIXEL_Z0 * Math.cos((safeLat * Math.PI) / 180)) / 2 ** z;
81
+ const places = Math.ceil(-Math.log10(metresPerPixel / METRES_PER_DEGREE));
82
+ return Math.min(7, Math.max(2, Number.isFinite(places) ? places : 2));
83
+ }
@@ -0,0 +1,401 @@
1
+ // HOW MUCH, painted on the map — the ground paint for a quantity and for a signed gap.
2
+ //
3
+ // Zones say what the rules are and marks say where each vehicle is. Between them there was nothing
4
+ // that answered "where is demand", "where did the faults cluster", "which cells are short of bikes".
5
+ // This is that layer, and like `zone.ts` it is PAINT rather than a component: the style spec already
6
+ // says everything a fill needs to say.
7
+ //
8
+ // ★ THIS MODULE IS BINS AND BALANCE ONLY. The kernel field lives in `heat.ts` and shares nothing with
9
+ // this one but a basemap. A bin is a COUNTED CELL: it has a size, an id, a hoverable edge, a
10
+ // resolution on a ladder and a domain in per-cell totals. A field has none of those. They were one
11
+ // function with one `kind` flag and one `domain`, and the word meant two different things depending
12
+ // on the branch — which is how a heat layer handed a bins domain scaled every event to a twentieth
13
+ // of its weight and painted a blank map without failing.
14
+ //
15
+ // The rule between them survives the split: HEAT FOR THE GLANCE, BINS FOR THE DECISION. A dispatch
16
+ // instruction — send twelve bikes to this cell — may never be read off a kernel field, which is why
17
+ // bins step and heat interpolates. The smooth one is the one you cannot read a number from.
18
+ //
19
+ // ★ ABSENT IS ABSENT; ZERO IS A READING. A cell nobody counted is not painted (see `joinCellValues`,
20
+ // which refuses to write a zero it was not given, and the `['has', …]` filter below). A cell counted
21
+ // as none IS painted, at the palest stop, because "we looked and there was nothing" is information.
22
+ // This is also why opacity does not ramp with the value: that would erase the distinction and
23
+ // double-encode one number in two channels, making the low cells the hardest to see and to hover.
24
+ //
25
+ // ★ THE TWO RAMPS FLIP IN TWO DIFFERENT PLACES, AND EACH IS THE ONLY WAY IN.
26
+ // The SEQUENTIAL ramp is sampled from the engine by `ramp.ts`, which reverses the stop order after
27
+ // dark — every bucket count lands on a real engine stop, which is what stopped a seven-bucket map
28
+ // asking for an alias that did not exist and painting grey.
29
+ // The DIVERGING ramp is sampled the same way, except for its CENTRE — "no meaningful gap" is not a
30
+ // position on any ramp, so that one stop stays a token (`--forest-map-balance-centre`). Its arms
31
+ // reverse WITHIN themselves rather than end to end, so blue keeps meaning short after dark.
32
+ // Either way nothing in this file knows which mode it is in, and no caller is asked to remember —
33
+ // the house rule elsewhere ("consumers invert the stop order in dark mode") is a trap on GL, where a
34
+ // surface that forgets ships a map meaning the opposite thing after dark with nothing failing.
35
+ import { MAP_SLOT, token, type OverlayLayer } from './useMapOverlay';
36
+ import { H3_EDGE_M } from './bin';
37
+ import { divergingStops, rampStops, type DivergingRamp, type SequentialRamp } from './ramp';
38
+
39
+ /**
40
+ * ★ THE ALPHA CEILING, AND IT IS MEASURED (`bun scripts/mapDensity.ts`).
41
+ *
42
+ * Two independent reasons land on the same number. The basemap has to survive underneath — a density
43
+ * layer that hides the streets stops being a map and becomes a chart with a coastline. And the
44
+ * distance from a composited stop to the nearest hue the map has already spent (the vivid categorical
45
+ * set the vehicle marks draw from) collapses from ΔE00 15 at 0.5 to 8 at 0.65, which is where a
46
+ * reader starts mistaking ground paint for a mark.
47
+ */
48
+ export const DENSITY_ALPHA = 0.5;
49
+
50
+ /**
51
+ * ★ FIVE STOPS, AND FIVE IS A CEILING RATHER THAN A PREFERENCE.
52
+ *
53
+ * Composited at DENSITY_ALPHA over Standard's worst ground, five stops of the brand ramp still clear
54
+ * ΔE00 5.6 between neighbours; the sixth does not. viridis and magma sustain nine — and that is the
55
+ * reason to refuse them here, not to prefer them: the house already documents that a reader recovers
56
+ * RANK from a shade and not a value, so nine steps is a promise nobody can use, bought with the brand.
57
+ */
58
+ export const DENSITY_STEPS = 5;
59
+
60
+ /** Nine, because a diverging ramp needs a centre stop and four rungs of magnitude on each arm. */
61
+ export const BALANCE_STEPS = 9;
62
+
63
+ /**
64
+ * The bin ramp's stops for a bucket count — the brand family, sampled and dark-flipped by `ramp.ts`.
65
+ *
66
+ * ★ SAMPLED, NOT ALIASED. There used to be a `--forest-map-density-*` block in forest.css naming five
67
+ * positions, and a seven-bucket map asked it for a sixth that did not exist — an undefined var in a GL
68
+ * paint property falls back to the inherited colour rather than failing, so London's densest cells
69
+ * painted grey under a green legend. Sampling cannot name a colour that is not there.
70
+ */
71
+ export const densityStops = (steps: number, ramp: SequentialRamp = 'forest') => rampStops(ramp, steps);
72
+
73
+ /** The balance ramp's stops — arms sampled from the engine, centre from the one token not on a ramp. */
74
+ export const balanceStops = (ramp: DivergingRamp = 'blueOrange') => divergingStops(ramp);
75
+
76
+ // ── expressions ─────────────────────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * A `step` over the domain — n buckets, ascending, one token per bucket.
80
+ *
81
+ * ★ STEP, NEVER INTERPOLATE, and the flat-domain guard is not a nicety. Mapbox requires a `step`'s
82
+ * stops to ascend strictly; a domain of [0, 0] (an empty feed, a single reading, everything clamped to
83
+ * one value) produces duplicates, and mapbox REJECTS the layer without raising — the layer is simply
84
+ * absent and the map looks subtly wrong forever. A degenerate domain paints one flat colour instead.
85
+ */
86
+ export function stepColour(prop: string, domain: [number, number], steps: number, tokenAt: (i: number) => string): unknown {
87
+ const [lo, hi] = domain;
88
+ if (!(hi > lo)) return token(tokenAt(steps - 1));
89
+ const expr: unknown[] = ['step', ['get', prop], token(tokenAt(0))];
90
+ for (let i = 1; i < steps; i++) {
91
+ expr.push(lo + ((hi - lo) * i) / steps, token(tokenAt(i)));
92
+ }
93
+ return expr;
94
+ }
95
+
96
+ /** The bucket edges a legend has to label — the same arithmetic `stepColour` paints with. */
97
+ export function stepEdges(domain: [number, number], steps: number): number[] {
98
+ const [lo, hi] = domain;
99
+ return Array.from({ length: steps + 1 }, (_, i) => lo + ((hi - lo) * i) / steps);
100
+ }
101
+
102
+ // ── layers ──────────────────────────────────────────────────────────────────────────────────────
103
+
104
+ export interface DensityOptions {
105
+ /** source id holding the counted cells */
106
+ source: string;
107
+ /** the value range the ramp spans. STATE IT — see the note on viewport-relative domains below */
108
+ domain: [number, number];
109
+ /** feature property holding the reading. Default `value` */
110
+ prop?: string;
111
+ /** id prefix, so two density overlays can coexist. Default `density` */
112
+ prefix?: string;
113
+ /**
114
+ * Which sequential family the buckets paint in. Default `forest`.
115
+ *
116
+ * ⚠ TWO BIN MAPS IN ONE CONSOLE SHOULD SHARE A RAMP. Unlike a field — an impression, where telling
117
+ * two layers apart is worth more than matching them — a bin map is read against a legend, and two
118
+ * screenshots of the same city in different ramps invite comparisons that are not there. The default
119
+ * is the measured pick; changing it is a surface-level decision, not a per-view one.
120
+ */
121
+ ramp?: SequentialRamp;
122
+ /**
123
+ * How many buckets the ramp is cut into. Default DENSITY_STEPS (5), which is a MEASURED ceiling.
124
+ *
125
+ * ★ FEWER IS ALWAYS SAFE; MORE STOPS WORKING, and it stops working silently. Composited over
126
+ * Standard's worst ground at DENSITY_ALPHA, five buckets of the brand ramp still clear ΔE00 5.6
127
+ * between neighbours; a sixth lands inside the threshold and two adjacent buckets become one colour
128
+ * to the reader, which makes the legend a promise the paint cannot keep. Three or four is an
129
+ * editorial choice needing no permission. Six or more is a decision to show a distinction that is
130
+ * not there — /maps/density has a control that lets you watch it collapse.
131
+ *
132
+ * ⚠ PASS THE SAME NUMBER TO `densityLegend()`. The layer and its key cut the domain independently,
133
+ * so a mismatch labels buckets the map never paints.
134
+ */
135
+ steps?: number;
136
+ /** draw the cell edges. Default true */
137
+ hairline?: boolean;
138
+ /** opacity ceiling. Default DENSITY_ALPHA, and going past it is a deliberate act */
139
+ alpha?: number;
140
+ }
141
+
142
+ /**
143
+ * The layers for a density overlay.
144
+ *
145
+ * ★ THE DOMAIN IS STATED, NEVER TAKEN FROM THE VIEWPORT. A domain fitted to what is on screen re-scales
146
+ * as the reader pans, so the same cell changes colour because its NEIGHBOURS changed — the map appears
147
+ * to respond to panning, which is the one thing it must never appear to do. `robustDomain()` in bin.ts
148
+ * computes one from the whole feed, once.
149
+ *
150
+ * ★ IT SITS IN `MAP_SLOT.route` (`middle`), NOT WITH THE ZONES, and the difference is what each layer
151
+ * is for. `bottom` is above the polygons and BELOW the entire road network, which is right for a zone:
152
+ * a boundary is a rule about a region, the streets inside it stay fully legible, and the rule is not
153
+ * something the reader is measuring. Density IS the thing being measured, and buried under every road
154
+ * casing, every carriageway fill and every bridge, it reads as a smudge — measured on the demo map,
155
+ * where a heat field in `bottom` came back as grey haze under Bloomsbury's streets.
156
+ *
157
+ * `middle` still sits behind the 3D buildings, behind the basemap's labels and — the part that matters
158
+ * — behind every mark, which stay in `top`. A mark is the data; this is the quantity behind it, and it
159
+ * may never cover one.
160
+ */
161
+ export function densityLayers(opts: DensityOptions): OverlayLayer[] {
162
+ const {
163
+ source,
164
+ domain,
165
+ prop = 'value',
166
+ prefix = 'density',
167
+ ramp = 'forest',
168
+ steps = DENSITY_STEPS,
169
+ hairline = true,
170
+ alpha = DENSITY_ALPHA,
171
+ } = opts;
172
+
173
+ const layers: OverlayLayer[] = [
174
+ {
175
+ id: `${prefix}-fill`,
176
+ type: 'fill',
177
+ source,
178
+ slot: MAP_SLOT.route,
179
+ // ★ Absent is absent: a cell that carries no reading is not painted at all. Without this filter
180
+ // an unvalued cell falls into the `step`'s first bucket and is published as a counted zero.
181
+ filter: ['has', prop],
182
+ paint: {
183
+ 'fill-color': stepColour(prop, domain, steps, (i) => densityStops(steps, ramp)[i]),
184
+ 'fill-opacity': alpha,
185
+ // fill-emissive-strength is injected by useMapOverlay — `fill` is a shaded type, and an unlit
186
+ // overlay paints at about a fifth of its luminance under the night preset.
187
+ },
188
+ },
189
+ ];
190
+
191
+ if (hairline) {
192
+ layers.push({
193
+ id: `${prefix}-edge`,
194
+ type: 'line',
195
+ source,
196
+ slot: MAP_SLOT.route,
197
+ filter: ['has', prop],
198
+ paint: {
199
+ // ★ THE SEAM IS INK, NOT A THIRD COLOUR. Adjacent cells one step apart are separable but not
200
+ // instantly countable; a hairline restores the grid as a texture. It takes the inverted
201
+ // background — dark on a light map, light on a dark one — so it reads as a seam between cells
202
+ // rather than as another value in the ramp.
203
+ 'line-color': token('--ui-bg-inverted'),
204
+ 'line-width': 0.5,
205
+ 'line-opacity': 0.15,
206
+ },
207
+ });
208
+ }
209
+
210
+ return layers;
211
+ }
212
+
213
+ export interface BalanceOptions extends Omit<DensityOptions, 'ramp' | 'steps'> {
214
+ /**
215
+ * Which diverging family the arms paint in. Default `blueOrange`.
216
+ *
217
+ * ★ THE DEFAULT IS MEASURED, NOT CONVENTIONAL. blueOrange holds ΔE00 25 between its arms under the
218
+ * worst dichromacy against redBlue's 18, purpleGreen's 14 and forestWarm's 7 — and it keeps off red
219
+ * and amber, which are spent on severity here (the no-parking zone, the critical battery ring). A
220
+ * balance is not a severity. `redBlue` is what the fleet console paints today, so it is the one to
221
+ * reach for while a surface is migrating.
222
+ */
223
+ ramp?: DivergingRamp;
224
+ /**
225
+ * Readings this close to zero are NOT PAINTED — the honest absence.
226
+ *
227
+ * A balance is a gap against an expectation, and an expectation has a band. Inside it there is
228
+ * nothing to report, and reporting it anyway ("slightly over") invites acting on noise.
229
+ */
230
+ deadband?: number;
231
+ }
232
+
233
+ /**
234
+ * The layers for a signed balance — surplus against deficit, around zero.
235
+ *
236
+ * ★ BINS ONLY. A kernel field is a density of points and carries no sign; there is no honest way to
237
+ * heat-map a quantity that can be negative, because the two arms would add rather than cancel.
238
+ *
239
+ * ★ MAGNITUDE IS THE POINT. The console this replaces reduced the reading to a three-valued status
240
+ * before painting, so a cell one bike over the band and a cell forty over painted identically. The
241
+ * ramp carries the size of the gap; the SIGN carries its direction; and the deadband — not a third
242
+ * colour — carries "no claim".
243
+ */
244
+ export function balanceLayers(opts: BalanceOptions): OverlayLayer[] {
245
+ const {
246
+ source,
247
+ domain,
248
+ prop = 'value',
249
+ prefix = 'balance',
250
+ ramp = 'blueOrange',
251
+ hairline = true,
252
+ alpha = DENSITY_ALPHA,
253
+ deadband = 0,
254
+ } = opts;
255
+
256
+ // ★ The domain must be symmetric or the same colour means +20 on one arm and −8 on the other, and
257
+ // the map reads as a bias that is an artefact of the scale. `symmetricDomain()` builds one.
258
+ if (domain[0] !== -domain[1]) {
259
+ throw new Error(
260
+ `balanceLayers: domain must be symmetric around zero, got [${domain[0]}, ${domain[1]}]. Use symmetricDomain().`,
261
+ );
262
+ }
263
+
264
+ const painted: unknown[] = deadband > 0 ? ['>', ['abs', ['get', prop]], deadband] : ['has', prop];
265
+ const filter: unknown[] = deadband > 0 ? ['all', ['has', prop], painted] : painted;
266
+
267
+ const layers: OverlayLayer[] = [
268
+ {
269
+ id: `${prefix}-fill`,
270
+ type: 'fill',
271
+ source,
272
+ slot: MAP_SLOT.route,
273
+ filter,
274
+ paint: {
275
+ 'fill-color': stepColour(prop, domain, BALANCE_STEPS, (i) => balanceStops(ramp)[i]),
276
+ 'fill-opacity': alpha,
277
+ },
278
+ },
279
+ ];
280
+
281
+ if (hairline) {
282
+ layers.push({
283
+ id: `${prefix}-edge`,
284
+ type: 'line',
285
+ source,
286
+ slot: MAP_SLOT.route,
287
+ filter,
288
+ paint: {
289
+ 'line-color': token('--ui-bg-inverted'),
290
+ 'line-width': 0.5,
291
+ 'line-opacity': 0.15,
292
+ },
293
+ });
294
+ }
295
+
296
+ return layers;
297
+ }
298
+
299
+ // ── the legend model ────────────────────────────────────────────────────────────────────────────
300
+
301
+ export interface RampStop {
302
+ key: string;
303
+ /** a token NAME — the swatch is DOM and references it directly, like MapLegendItem.colour */
304
+ colour: string;
305
+ from: number;
306
+ to: number;
307
+ label: string;
308
+ }
309
+
310
+ export interface RampLegend {
311
+ kind: 'density' | 'balance';
312
+ stops: RampStop[];
313
+ /** what one reading counts, e.g. "rides" — the legend is meaningless without it */
314
+ unit?: string;
315
+ /** cell width in metres, when the readings are binned. Printed so the unit is never implicit */
316
+ cellM?: number;
317
+ /** readings above the domain, painted at the top stop. Stated rather than hidden */
318
+ clamped?: number;
319
+ percentile?: number;
320
+ }
321
+
322
+ const fmt = (v: number, span: number): string => {
323
+ const n = span >= 10 ? Math.round(v) : Math.round(v * 10) / 10;
324
+ return String(n);
325
+ };
326
+
327
+ export interface LegendOptions {
328
+ domain: [number, number];
329
+ /** the family the layer painted with — the key has to match it or the swatches lie */
330
+ ramp?: SequentialRamp | DivergingRamp;
331
+ /** must match the layer's `steps`, or the key describes buckets the map never paints */
332
+ steps?: number;
333
+ unit?: string;
334
+ /** H3 resolution the cells are binned at — turns into the stated cell size */
335
+ res?: number;
336
+ clamped?: number;
337
+ percentile?: number;
338
+ deadband?: number;
339
+ }
340
+
341
+ /** Legend rows for a density ramp — one per painted bucket, labelled in the reading's own units. */
342
+ export function densityLegend(opts: LegendOptions): RampLegend {
343
+ const { domain, unit, res, clamped, percentile, steps = DENSITY_STEPS } = opts;
344
+ const stops = densityStops(steps, (opts.ramp as SequentialRamp) ?? 'forest');
345
+ const edges = stepEdges(domain, steps);
346
+ const span = domain[1] - domain[0];
347
+ return {
348
+ kind: 'density',
349
+ unit,
350
+ cellM: res != null ? Math.round(2 * H3_EDGE_M[res]) : undefined,
351
+ clamped,
352
+ percentile,
353
+ stops: edges.slice(0, -1).map((from, i) => ({
354
+ key: String(i),
355
+ colour: stops[i],
356
+ from,
357
+ to: edges[i + 1],
358
+ // The top bucket is open — everything at or above its floor is painted there, including the
359
+ // clamped readings, so a closed label would understate it.
360
+ label: i === steps - 1 ? `${fmt(from, span)}+` : `${fmt(from, span)}–${fmt(edges[i + 1], span)}`,
361
+ })),
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Legend rows for a balance ramp.
367
+ *
368
+ * ★ IT READS OUTWARD FROM ZERO, not left to right. The reader's question is "how far off, and which
369
+ * way", so the centre is the anchor and each arm is labelled by its own direction. The deadband, when
370
+ * there is one, is a row of its own saying what is deliberately not painted.
371
+ */
372
+ export function balanceLegend(opts: LegendOptions & { low?: string; high?: string }): RampLegend {
373
+ const { domain, unit, res, clamped, percentile, deadband = 0, low = 'short', high = 'over' } = opts;
374
+ const stops = balanceStops((opts.ramp as DivergingRamp) ?? 'blueOrange');
375
+ const edges = stepEdges(domain, BALANCE_STEPS);
376
+ const span = domain[1] - domain[0];
377
+ const mid = Math.floor(BALANCE_STEPS / 2);
378
+ return {
379
+ kind: 'balance',
380
+ unit,
381
+ cellM: res != null ? Math.round(2 * H3_EDGE_M[res]) : undefined,
382
+ clamped,
383
+ percentile,
384
+ stops: edges.slice(0, -1).map((from, i) => {
385
+ const to = edges[i + 1];
386
+ const centre = i === mid;
387
+ const arm = i < mid ? low : high;
388
+ return {
389
+ key: String(i),
390
+ colour: stops[i],
391
+ from,
392
+ to,
393
+ label: centre
394
+ ? deadband > 0
395
+ ? `within ${fmt(deadband, span)}`
396
+ : 'on target'
397
+ : `${fmt(Math.abs(i < mid ? to : from), span)}+ ${arm}`,
398
+ };
399
+ }),
400
+ };
401
+ }