@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,356 @@
1
+ // The arithmetic behind drawing — rings, the guards that refuse a bad one, snapping and undo.
2
+ //
3
+ // Kept out of the composable so it can be tested in node, where packages/ui's suite runs. What lives
4
+ // here: what a ring IS, what makes one invalid, where a vertex lands when a midpoint is dragged, and
5
+ // what a circle gesture means on the ground. What does not: pointers, GL, and anything that needs a
6
+ // map to project.
7
+ //
8
+ // ★ RINGS ARE STORED OPEN. GeoJSON closes a polygon ring by repeating its first position as its last,
9
+ // and carrying that duplicate through editing is a standing invitation to desync: move vertex 0 and
10
+ // the copy at the end still holds the old position, so the ring silently stops closing. Every
11
+ // operation here works on the open ring and `closeRing` adds the repeat at the boundary, once, where
12
+ // the geometry leaves for GeoJSON.
13
+ //
14
+ // ★ VERTICES ARE GEOGRAPHIC, WHICH IS THE OPPOSITE CALL TO REGION SELECT. A lasso is a transient
15
+ // question about the picture, so `regionSelect.ts` tests in screen space and the catch agrees with the
16
+ // drawn shape under rotation and pitch. A drawn zone is a persisted fact about the ground: it survives
17
+ // panning, zooming and a pause between two vertices, so a vertex is lng/lat from the moment it is
18
+ // placed. The two modules disagree on purpose.
19
+ import type { LngLat } from './coordinates';
20
+ import type { ScreenPoint } from './regionSelect';
21
+
22
+ /**
23
+ * Which tool the canvas is holding. The tool is what a DRAG DOES, and `off` is a state a reader
24
+ * chooses rather than the absence of one.
25
+ *
26
+ * ★ Same reasoning as `RegionTool`: a held modifier is unreachable on a phone, so gating drawing
27
+ * behind one would make authoring desktop-only. An armed tool is what makes a plain drag draw — and
28
+ * once a plain drag can draw, putting the tool down has to be something a reader can see and pick.
29
+ */
30
+ export type DrawTool = 'off' | 'polygon' | 'rectangle' | 'circle' | 'edit';
31
+
32
+ /** An OPEN ring: the first position is not repeated at the end. See the note at the top. */
33
+ export type DrawRing = LngLat[];
34
+
35
+ export interface DrawFeature {
36
+ id: string;
37
+ ring: DrawRing;
38
+ /** the caller's own payload — a zone kind, a name, whatever it persists */
39
+ properties?: Record<string, unknown>;
40
+ }
41
+
42
+ /** Why a ring cannot be committed. `null` is the only shape that means "it can". */
43
+ export type DrawProblem = 'too-few' | 'self-intersects';
44
+
45
+ /**
46
+ * Smaller than this on both axes and the gesture is a click, not a shape.
47
+ *
48
+ * The same floor region select uses, and for a sibling reason: without it a stray press-and-release
49
+ * during a rectangle drag commits a zone a few pixels across, which is invisible on the map and very
50
+ * much present in the payload.
51
+ */
52
+ export const MIN_DRAW_PX = 4;
53
+
54
+ /**
55
+ * How close a pointer has to be to an existing vertex for the new one to land on it exactly.
56
+ *
57
+ * 10px rather than the 3px `useFeatureSelect` hit-tests at: that number is for "did you mean to click
58
+ * this", where a generous radius steals clicks from neighbours. This one is for "did you mean to meet
59
+ * this", where the cost of missing is a hairline gap between two zones that share a boundary — a gap
60
+ * nobody sees until a rider is standing in it.
61
+ */
62
+ export const SNAP_PX = 10;
63
+
64
+ /**
65
+ * How many segments a drawn circle becomes.
66
+ *
67
+ * 64 puts the chord error under a tenth of a percent of the radius, which is below a pixel for any
68
+ * radius an operator draws at city zooms, and keeps the payload small enough to read in a diff.
69
+ */
70
+ export const CIRCLE_SEGMENTS = 64;
71
+
72
+ const EARTH_RADIUS_M = 6_371_008.8;
73
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
74
+ const toDeg = (rad: number) => (rad * 180) / Math.PI;
75
+
76
+ /** GeoJSON's closed ring: the open one with its first position repeated at the end. */
77
+ export function closeRing(ring: DrawRing): LngLat[] {
78
+ if (ring.length < 3) return [...ring];
79
+ const [first] = ring;
80
+ const last = ring[ring.length - 1];
81
+ return first[0] === last[0] && first[1] === last[1] ? [...ring] : [...ring, [first[0], first[1]]];
82
+ }
83
+
84
+ /** A ring as the GeoJSON feature a surface persists. */
85
+ export function drawFeatureToGeoJSON(feature: DrawFeature): GeoJSON.Feature<GeoJSON.Polygon> {
86
+ return {
87
+ type: 'Feature',
88
+ id: feature.id,
89
+ properties: { ...(feature.properties ?? {}) },
90
+ geometry: { type: 'Polygon', coordinates: [closeRing(feature.ring)] },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * The midpoint of every edge, INCLUDING the closing one — those are the handles that insert a vertex,
96
+ * and the edge between the last vertex and the first needs one as much as any other.
97
+ *
98
+ * Interpolated linearly in lng/lat rather than along a great circle. Over a city block the two differ
99
+ * by well under a millimetre, and the linear midpoint has the property that matters to a reader: it
100
+ * sits exactly halfway along the line the map draws.
101
+ */
102
+ export function midpoints(ring: DrawRing): LngLat[] {
103
+ if (ring.length < 2) return [];
104
+ return ring.map((a, i) => {
105
+ const b = ring[(i + 1) % ring.length];
106
+ return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as LngLat;
107
+ });
108
+ }
109
+
110
+ /** Move one vertex. Out-of-range indices return the ring untouched rather than growing it. */
111
+ export function moveVertex(ring: DrawRing, index: number, at: LngLat): DrawRing {
112
+ if (index < 0 || index >= ring.length) return ring;
113
+ const next = [...ring];
114
+ next[index] = [at[0], at[1]];
115
+ return next;
116
+ }
117
+
118
+ /**
119
+ * Insert a vertex AFTER `index` — the contract midpoint handles need, since midpoint `i` sits on the
120
+ * edge leaving vertex `i`. Inserting after the last vertex appends, which is the closing edge's case.
121
+ */
122
+ export function insertVertex(ring: DrawRing, index: number, at: LngLat): DrawRing {
123
+ if (index < 0 || index >= ring.length) return ring;
124
+ const next = [...ring];
125
+ next.splice(index + 1, 0, [at[0], at[1]]);
126
+ return next;
127
+ }
128
+
129
+ /**
130
+ * Drop a vertex, unless dropping it would leave something that is not a polygon.
131
+ *
132
+ * ★ Refusing is right, and returning the ring unchanged is how it refuses. A ring of three is a
133
+ * triangle; take one away and there is no shape left to edit back into existence, so the handle simply
134
+ * stops responding rather than deleting the reader's work in a way they cannot undo by dragging.
135
+ */
136
+ export function removeVertex(ring: DrawRing, index: number): DrawRing {
137
+ if (ring.length <= 3 || index < 0 || index >= ring.length) return ring;
138
+ return ring.filter((_, i) => i !== index);
139
+ }
140
+
141
+ /** Whether two positions are the same place, to within the precision a click can express. */
142
+ const samePoint = (a: LngLat, b: LngLat) => a[0] === b[0] && a[1] === b[1];
143
+
144
+ const orientation = (a: LngLat, b: LngLat, c: LngLat): number => {
145
+ const v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
146
+ return v > 0 ? 1 : v < 0 ? -1 : 0;
147
+ };
148
+
149
+ const onSegment = (a: LngLat, b: LngLat, p: LngLat): boolean =>
150
+ Math.min(a[0], b[0]) <= p[0] &&
151
+ p[0] <= Math.max(a[0], b[0]) &&
152
+ Math.min(a[1], b[1]) <= p[1] &&
153
+ p[1] <= Math.max(a[1], b[1]);
154
+
155
+ /**
156
+ * Do two segments cross? The standard orientation test, with the collinear cases spelled out.
157
+ *
158
+ * Treated in the plane rather than on the sphere: two segments that cross on a city map cross in
159
+ * mercator too, and the great-circle version of this test costs an order of magnitude more for a
160
+ * disagreement that begins at continental scale.
161
+ */
162
+ export function segmentsCross(a: LngLat, b: LngLat, c: LngLat, d: LngLat): boolean {
163
+ const o1 = orientation(a, b, c);
164
+ const o2 = orientation(a, b, d);
165
+ const o3 = orientation(c, d, a);
166
+ const o4 = orientation(c, d, b);
167
+ if (o1 !== o2 && o3 !== o4) return true;
168
+ if (o1 === 0 && onSegment(a, b, c)) return true;
169
+ if (o2 === 0 && onSegment(a, b, d)) return true;
170
+ if (o3 === 0 && onSegment(c, d, a)) return true;
171
+ if (o4 === 0 && onSegment(c, d, b)) return true;
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Does the ring cross itself?
177
+ *
178
+ * ★ ADJACENT EDGES ARE SKIPPED, because they always touch — that is what makes them adjacent. The
179
+ * test is over pairs of edges that share no vertex, which for a closed ring means the first and last
180
+ * edge are neighbours too and are skipped with the rest.
181
+ *
182
+ * A figure of eight is the case this exists for. Mapbox renders one happily, with the crossing filled
183
+ * by the even-odd rule, so nothing on screen says the polygon is degenerate — and a zone whose
184
+ * boundary crosses itself has no answer to "is this point inside it" that a rider and a billing run
185
+ * would agree on.
186
+ */
187
+ export function selfIntersects(ring: DrawRing): boolean {
188
+ const n = ring.length;
189
+ if (n < 4) return false;
190
+ for (let i = 0; i < n; i++) {
191
+ const a = ring[i];
192
+ const b = ring[(i + 1) % n];
193
+ for (let j = i + 1; j < n; j++) {
194
+ // share a vertex — adjacent edges, and the wrap-around pair the modulo makes adjacent too
195
+ if (j === i || (j + 1) % n === i || (i + 1) % n === j) continue;
196
+ const c = ring[j];
197
+ const d = ring[(j + 1) % n];
198
+ if (segmentsCross(a, b, c, d)) return true;
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+
204
+ /**
205
+ * What is wrong with this ring, or `null` if nothing is.
206
+ *
207
+ * ★ It reports rather than repairs. A draft that silently dropped its crossing vertex would move
208
+ * geometry the reader placed, in a way they did not ask for and cannot see happen. The composable
209
+ * paints the problem instead and refuses the commit.
210
+ */
211
+ export function ringProblem(ring: DrawRing): DrawProblem | null {
212
+ const distinct = ring.filter((p, i) => i === 0 || !samePoint(p, ring[i - 1]));
213
+ if (distinct.length < 3) return 'too-few';
214
+ if (selfIntersects(ring)) return 'self-intersects';
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * The four corners of a screen-space drag, wound consistently.
220
+ *
221
+ * Screen corners rather than a lng/lat box: the composable unprojects each one, so under rotation the
222
+ * result is a quad aligned to the screen — the shape the reader actually drew. An axis-aligned
223
+ * geographic box would be a different shape from the rectangle they watched themselves make.
224
+ */
225
+ export function boxCorners(a: ScreenPoint, b: ScreenPoint): ScreenPoint[] {
226
+ return [
227
+ [a[0], a[1]],
228
+ [b[0], a[1]],
229
+ [b[0], b[1]],
230
+ [a[0], b[1]],
231
+ ];
232
+ }
233
+
234
+ /** Great-circle distance in metres — what a circle's radius is measured in, and reported as. */
235
+ export function metresBetween(a: LngLat, b: LngLat): number {
236
+ const dLat = toRad(b[1] - a[1]);
237
+ const dLng = toRad(b[0] - a[0]);
238
+ const lat1 = toRad(a[1]);
239
+ const lat2 = toRad(b[1]);
240
+ const h =
241
+ Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
242
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
243
+ }
244
+
245
+ /**
246
+ * A circle on the GROUND: `segments` positions at a fixed distance from the centre.
247
+ *
248
+ * ★ NOT AN UNPROJECTED SCREEN CIRCLE. On a pitched map a screen circle unprojects to an egg, and the
249
+ * reader's question — "everything within a couple of hundred metres of this bay" — is about the
250
+ * ground, not the picture. Going through metres also means the radius is a number the control can
251
+ * show, which is worth more to an operator than matching the drag pixel for pixel.
252
+ */
253
+ export function geodesicRing(centre: LngLat, metres: number, segments = CIRCLE_SEGMENTS): DrawRing {
254
+ const angular = metres / EARTH_RADIUS_M;
255
+ const lat = toRad(centre[1]);
256
+ const lng = toRad(centre[0]);
257
+ const sinLat = Math.sin(lat) * Math.cos(angular);
258
+ const cosLat = Math.cos(lat) * Math.sin(angular);
259
+
260
+ const ring: DrawRing = [];
261
+ for (let i = 0; i < segments; i++) {
262
+ const bearing = (i / segments) * 2 * Math.PI;
263
+ const lat2 = Math.asin(sinLat + cosLat * Math.cos(bearing));
264
+ const lng2 =
265
+ lng +
266
+ Math.atan2(Math.sin(bearing) * cosLat, Math.cos(angular) - Math.sin(lat) * Math.sin(lat2));
267
+ ring.push([toDeg(lng2), toDeg(lat2)]);
268
+ }
269
+ return ring;
270
+ }
271
+
272
+ /**
273
+ * Every vertex a snap may land on, given the shapes that exist.
274
+ *
275
+ * ★ `exclude` IS WHAT MAKES DRAGGING SAFE. A corner being dragged is itself a candidate, and the
276
+ * nearest vertex to a corner is always that corner — so without this the handle would weld itself in
277
+ * place the moment it was picked up. Excluding the WHOLE feature rather than the one index is the
278
+ * stronger rule and the right one: snapping a corner onto another corner of its own ring pinches the
279
+ * polygon into a shape that touches itself, which `ringProblem` then has to refuse. An operator
280
+ * aligning a boundary means their neighbour's corner, never their own.
281
+ */
282
+ export function snapVertices(features: readonly DrawFeature[], exclude?: string): LngLat[] {
283
+ const out: LngLat[] = [];
284
+ for (const f of features) {
285
+ if (f.id === exclude) continue;
286
+ for (const v of f.ring) out.push(v);
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * The nearest of `points` to `at`, if one is within tolerance — the snap test, in screen px.
293
+ *
294
+ * Screen space rather than metres, because the tolerance a reader feels is a distance on the display:
295
+ * the same 10px is metres at street zoom and kilometres at city zoom, and in both cases it is "near
296
+ * enough that I was aiming at it".
297
+ */
298
+ export function nearestWithin(
299
+ points: readonly ScreenPoint[],
300
+ at: ScreenPoint,
301
+ tolerance = SNAP_PX,
302
+ ): number | null {
303
+ let best: number | null = null;
304
+ let bestDistance = tolerance;
305
+ points.forEach((p, i) => {
306
+ const d = Math.hypot(p[0] - at[0], p[1] - at[1]);
307
+ if (d <= bestDistance) {
308
+ best = i;
309
+ bestDistance = d;
310
+ }
311
+ });
312
+ return best;
313
+ }
314
+
315
+ /** Whether a drag was big enough to have been meant as a shape. */
316
+ export function worthDrawing(a: ScreenPoint, b: ScreenPoint): boolean {
317
+ return Math.abs(b[0] - a[0]) > MIN_DRAW_PX || Math.abs(b[1] - a[1]) > MIN_DRAW_PX;
318
+ }
319
+
320
+ /**
321
+ * Undo, as a value.
322
+ *
323
+ * ★ Whole snapshots, not a log of operations. The state being remembered is a list of rings a person
324
+ * can hold in their head, so an inverse-operation log buys nothing and costs the class of bug where
325
+ * undo and redo disagree about what an operation meant. The cap keeps a long editing session from
326
+ * growing without bound.
327
+ */
328
+ export interface DrawHistory<T> {
329
+ past: T[];
330
+ present: T;
331
+ future: T[];
332
+ }
333
+
334
+ export const HISTORY_CAP = 50;
335
+
336
+ export const historyOf = <T>(present: T): DrawHistory<T> => ({ past: [], present, future: [] });
337
+
338
+ export function pushHistory<T>(h: DrawHistory<T>, next: T, cap = HISTORY_CAP): DrawHistory<T> {
339
+ const past = [...h.past, h.present].slice(-cap);
340
+ // ★ A new edit clears the redo branch. Keeping it would let a reader redo their way into a state
341
+ // that never followed from what is now on screen.
342
+ return { past, present: next, future: [] };
343
+ }
344
+
345
+ export function undoHistory<T>(h: DrawHistory<T>): DrawHistory<T> {
346
+ if (!h.past.length) return h;
347
+ const past = [...h.past];
348
+ const present = past.pop() as T;
349
+ return { past, present, future: [h.present, ...h.future] };
350
+ }
351
+
352
+ export function redoHistory<T>(h: DrawHistory<T>): DrawHistory<T> {
353
+ if (!h.future.length) return h;
354
+ const [present, ...future] = h.future;
355
+ return { past: [...h.past, h.present], present, future };
356
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A CSS easing curve, evaluated in JS.
3
+ *
4
+ * ★ WHY THIS EXISTS. Mapbox's camera moves take an `easing` FUNCTION, not a CSS timing function — so a
5
+ * camera glide running alongside a CSS transition has no way to share its curve. The alternative was to
6
+ * pick a JS easing that "looks about right", which is exactly how two motions that are supposed to be one
7
+ * gesture drift apart: the panel decelerating on one curve while the map decelerates on another reads as
8
+ * two things happening, not one thing moving.
9
+ *
10
+ * So the curve is read from the same token the CSS uses (--motion-ease-out) and sampled here. One
11
+ * definition, two runtimes.
12
+ */
13
+
14
+ /** Evaluates a cubic-bezier(x1, y1, x2, y2) the way CSS does: y as a function of x, where x is time. */
15
+ export const cubicBezier = (x1: number, y1: number, x2: number, y2: number) => {
16
+ // The standard parametric form with p0 = (0,0) and p3 = (1,1).
17
+ const curve = (a: number, b: number, t: number) => {
18
+ const u = 1 - t;
19
+ return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t;
20
+ };
21
+ return (time: number): number => {
22
+ if (time <= 0) return 0;
23
+ if (time >= 1) return 1;
24
+ // Bisection rather than Newton–Raphson: 20 halvings is ~1e-6 on t, cheaper to reason about than
25
+ // derivative handling near the flat ends of a strong curve, and this runs once per frame at most.
26
+ let lo = 0;
27
+ let hi = 1;
28
+ let t = time;
29
+ for (let i = 0; i < 20; i++) {
30
+ const x = curve(x1, x2, t);
31
+ if (Math.abs(x - time) < 1e-5) break;
32
+ if (x < time) lo = t;
33
+ else hi = t;
34
+ t = (lo + hi) / 2;
35
+ }
36
+ return curve(y1, y2, t);
37
+ };
38
+ };
39
+
40
+ const LINEAR = (t: number) => t;
41
+
42
+ /**
43
+ * Parses a `cubic-bezier(...)` declaration into a sampler. Anything else — a keyword, an empty string, a
44
+ * malformed value — falls back to linear rather than throwing: a camera move with the wrong curve is a
45
+ * cosmetic loss, and a camera move that throws is a broken map.
46
+ */
47
+ export const parseEasing = (value: string): ((t: number) => number) => {
48
+ const m = value.trim().match(/^cubic-bezier\(([^)]+)\)$/);
49
+ if (!m) return LINEAR;
50
+ const parts = m[1].split(',').map((n) => Number.parseFloat(n));
51
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return LINEAR;
52
+ return cubicBezier(parts[0], parts[1], parts[2], parts[3]);
53
+ };
54
+
55
+ /** Reads a CSS time token (`240ms`, `0.24s`) off an element, in ms. 0 for anything unparseable. */
56
+ export const readMs = (el: Element | null, prop: string): number => {
57
+ if (!el) return 0;
58
+ const raw = getComputedStyle(el).getPropertyValue(prop).trim();
59
+ if (!raw) return 0;
60
+ const n = Number.parseFloat(raw);
61
+ if (Number.isNaN(n)) return 0;
62
+ return raw.endsWith('ms') ? n : n * 1000;
63
+ };
64
+
65
+ /** Reads a CSS easing token off an element and returns it as a sampler. */
66
+ export const readEasing = (el: Element | null, prop: string): ((t: number) => number) =>
67
+ el ? parseEasing(getComputedStyle(el).getPropertyValue(prop)) : LINEAR;
@@ -0,0 +1,59 @@
1
+ // The fullscreen API, resolved once as data.
2
+ //
3
+ // ★ RESOLVED, NOT BRANCHED AT EACH CALL. Three spellings are in play — the standard names, Safari's
4
+ // prefixed ones, and nothing at all on iPhone — and a component that tested for them at every use ends
5
+ // up with a template condition and a click handler that can disagree about which one it is in.
6
+ //
7
+ // ★ ABSENT AND FORBIDDEN COLLAPSE TO ONE ANSWER. `null` means "there is no fullscreen here", whether
8
+ // the methods are missing (Safari on iPhone, which does fullscreen video and nothing else) or present
9
+ // and disallowed (an iframe without `allowfullscreen`). A control that can never do its one thing
10
+ // renders nothing, and one null check is the whole condition.
11
+
12
+ export interface FullscreenApi {
13
+ /** method name on the ELEMENT */
14
+ request: string;
15
+ /** method name on the DOCUMENT */
16
+ exit: string;
17
+ /** property on the document holding the element currently fullscreen */
18
+ element: string;
19
+ /** the change event. The prefixed spelling lower-cases the whole word, not just the prefix */
20
+ event: string;
21
+ }
22
+
23
+ type DocLike = Record<string, unknown> & { documentElement?: unknown };
24
+
25
+ const STANDARD: FullscreenApi = {
26
+ request: 'requestFullscreen',
27
+ exit: 'exitFullscreen',
28
+ element: 'fullscreenElement',
29
+ event: 'fullscreenchange',
30
+ };
31
+
32
+ const WEBKIT: FullscreenApi = {
33
+ request: 'webkitRequestFullscreen',
34
+ exit: 'webkitExitFullscreen',
35
+ element: 'webkitFullscreenElement',
36
+ event: 'webkitfullscreenchange',
37
+ };
38
+
39
+ /** Which spelling this document speaks, or null where it does not speak one at all. */
40
+ export function fullscreenApi(doc: DocLike | null | undefined): FullscreenApi | null {
41
+ if (!doc) return null;
42
+ const root = doc.documentElement as Record<string, unknown> | undefined;
43
+ // Support is read off the ELEMENT method, not the document flag: the flag is the permission, and a
44
+ // browser with no fullscreen at all has neither.
45
+ if (root && typeof root[STANDARD.request] === 'function') {
46
+ return doc.fullscreenEnabled === false ? null : STANDARD;
47
+ }
48
+ if (root && typeof root[WEBKIT.request] === 'function') {
49
+ return doc.webkitFullscreenEnabled === false ? null : WEBKIT;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /** Whether THIS element is the one filling the screen — not merely whether something is. */
55
+ export function isFullscreen(doc: DocLike | null | undefined, el: Element | null): boolean {
56
+ if (!doc || !el) return false;
57
+ const api = fullscreenApi(doc);
58
+ return !!api && doc[api.element] === el;
59
+ }
@@ -0,0 +1,160 @@
1
+ // A KERNEL FIELD — where the weight is, with no cell and no countable unit.
2
+ //
3
+ // ★ IT SHARES NOTHING WITH BINS BUT A BASEMAP. That is why it is its own module. A bin is a counted
4
+ // cell: it has a size, an id, a hoverable edge, a resolution on a ladder, and a domain measured in
5
+ // per-cell totals. A kernel field has none of those. It takes raw events, spreads each one over a
6
+ // radius somebody chose, and paints the sum. The two used to share one function and one `domain`
7
+ // option, and that cost a day: the same word meant per-cell totals to one and per-event weight to the
8
+ // other, so a heat layer handed a bins domain scaled every event to a twentieth of its weight and
9
+ // painted an empty map. Nothing failed; the map was just blank.
10
+ //
11
+ // ★ AND IT KNOWS NOTHING ABOUT H3. `bin.ts` is the H3 seam — cell sizes, the zoom→resolution ladder,
12
+ // the join. None of it applies here, and this module does not import it. A surface that has events
13
+ // does not need a grid to draw a field, and pretending otherwise is how a heat layer ends up drawing
14
+ // its own sample points back at the reader.
15
+ //
16
+ // ★ HEAT FOR THE GLANCE, BINS FOR THE DECISION. The radius is a choice, the same points draw a
17
+ // different picture at a different radius, and nothing in the image says which one is on screen. A
18
+ // dispatch instruction — send twelve bikes to this cell — may never be read off this layer.
19
+ import { MAP_SLOT, token, type OverlayLayer } from './useMapOverlay';
20
+ import { SEQUENTIAL_RAMPS, rampStops, type SequentialRamp } from './ramp';
21
+
22
+ /**
23
+ * The ramps a field may use — the engine's sequential families, sampled by `ramp.ts`.
24
+ *
25
+ * ★ A PROP, UNLIKE THE BIN RAMP, AND THE ASYMMETRY IS DELIBERATE. Bins carry one ramp because a bin
26
+ * map is read against a legend of buckets, and a surface free to re-ramp per view would make two
27
+ * screenshots of the same city incomparable. A field has no buckets to compare — it is an impression —
28
+ * and different fields (demand, faults, dwell time) sitting in one console want to be told apart at a
29
+ * glance more than they want to match.
30
+ */
31
+ export const HEAT_RAMPS = SEQUENTIAL_RAMPS;
32
+ export type HeatRamp = SequentialRamp;
33
+
34
+
35
+ /**
36
+ * Where the colour stops sit on the density axis.
37
+ *
38
+ * ★ THE FIRST ONE IS NOT ZERO. Interpolating from `rgba(0,0,0,0)` up to `floor` is what fades the
39
+ * fringe in — alpha ramps with density across that gap, so a thin reading is faint rather than a hard
40
+ * edge of the palest colour. Below `floor` the field is effectively transparent.
41
+ */
42
+ export const HEAT_FLOOR = 0.15;
43
+
44
+
45
+
46
+ export interface HeatOptions {
47
+ /** source id holding the EVENTS — one feature per thing that happened */
48
+ source: string;
49
+ /**
50
+ * Which ramp the field paints in. Default `forest`.
51
+ *
52
+ * ★ THERE IS NO STOP COUNT HERE, AND THAT IS THE POINT. A field is CONTINUOUS — nobody matches a
53
+ * pixel to a swatch, so it has no buckets, and a knob for "how many" would invite reading one as if
54
+ * it did. `heatmap-color` needs discrete interpolation stops to describe a gradient, so the module
55
+ * spends every stop the ramp declares and spaces them evenly; that is a detail of the style spec,
56
+ * not a decision a surface makes. Buckets belong to bins, where they are counted and legended.
57
+ */
58
+ ramp?: HeatRamp;
59
+ /** kernel radius on the ground, in metres. Default 300 */
60
+ radiusM?: number;
61
+ /** latitude the radius is computed at. Default London */
62
+ lat?: number;
63
+ /**
64
+ * Property carrying each event's weight, when events are not equal.
65
+ *
66
+ * ★ DEFAULT IS UNWEIGHTED, AND THAT IS THE COMMON CASE. A ride start is a ride start; the field is
67
+ * the density of events and one event counts once. Reach for this only when a point genuinely
68
+ * carries a magnitude — a fault's severity, a bay's capacity.
69
+ */
70
+ weightProp?: string;
71
+ /** the per-EVENT weight range, required only with `weightProp` */
72
+ weightRange?: [number, number];
73
+ /** id prefix, so two fields can coexist. Default `heat` */
74
+ prefix?: string;
75
+ /** opacity ceiling. Default 0.5 — the basemap has to survive underneath */
76
+ alpha?: number;
77
+ }
78
+
79
+ /**
80
+ * A kernel radius that holds its size ON THE GROUND rather than on the screen.
81
+ *
82
+ * ★ `heatmap-radius` IN PIXELS IS THE ZOOM LIE IN ITS PUREST FORM. A fixed pixel radius means the same
83
+ * points draw a tight archipelago at z16 and one continuous blob at z11, and neither picture is about
84
+ * the data. A radius in metres is a claim a reader can hold: "weight within 300 m of here".
85
+ *
86
+ * ★ THE BASE-2 EXPONENTIAL IS EXACT, not an approximation. Ground resolution halves per zoom step
87
+ * (metres/px = 156543·cos(lat)/2^zoom), so a radius in pixels is proportional to 2^zoom — and mapbox's
88
+ * exponential interpolation with base 2 reproduces that curve exactly between two anchors. Two stops
89
+ * are therefore enough, and any zoom between them is right rather than nearly right.
90
+ *
91
+ * ⚠ Outside the anchor range mapbox CLAMPS to the endpoint value, so the ground size is only held
92
+ * between them. The defaults span a city map; a surface that flies to z19 should widen them.
93
+ */
94
+ export function heatRadiusByZoom(metres: number, lat = 51.5, anchors: [number, number] = [10, 18]): unknown {
95
+ const px = (z: number) => (metres * 2 ** z) / (156543.03392 * Math.cos((lat * Math.PI) / 180));
96
+ const [z0, z1] = anchors;
97
+ return ['interpolate', ['exponential', 2], ['zoom'], z0, px(z0), z1, px(z1)];
98
+ }
99
+
100
+ /**
101
+ * The layers for a kernel field.
102
+ *
103
+ * ★ IT SITS IN `MAP_SLOT.route` (`middle`), NOT WITH THE ZONES. `bottom` is below the entire road
104
+ * network, and a field buried under every casing, carriageway and bridge reads as grey haze — measured
105
+ * on the demo map, where exactly that happened. `middle` clears the roads and still sits behind the 3D
106
+ * buildings, the basemap's labels and every mark, which stay in `top`.
107
+ */
108
+ export function heatLayers(opts: HeatOptions): OverlayLayer[] {
109
+ const {
110
+ source,
111
+ ramp = 'forest',
112
+ radiusM = 300,
113
+ lat = 51.5,
114
+ weightProp,
115
+ weightRange,
116
+ prefix = 'heat',
117
+ alpha = 0.5,
118
+ } = opts;
119
+
120
+ // Every stop the family declares: a gradient is smoothest when it spends what the ramp actually has.
121
+ const stops = rampStops(ramp);
122
+ // Evenly spaced from the floor to full density. The gradient is read as a ramp, so uneven spacing
123
+ // would put a perceptual edge at a density that means nothing.
124
+ const at = (i: number) => HEAT_FLOOR + ((1 - HEAT_FLOOR) * i) / (stops.length - 1);
125
+
126
+ return [
127
+ {
128
+ id: `${prefix}-field`,
129
+ type: 'heatmap',
130
+ source,
131
+ slot: MAP_SLOT.route,
132
+ paint: {
133
+ // ★ ONE EVENT COUNTS ONCE unless the caller says otherwise, and a weight without a range is
134
+ // the empty-map bug waiting to happen — an unscaled magnitude of 4 against a domain built for
135
+ // per-cell totals lands at a twentieth of a weight and the field never reaches a paintable
136
+ // density.
137
+ 'heatmap-weight':
138
+ weightProp && weightRange && weightRange[1] > weightRange[0]
139
+ ? ['interpolate', ['linear'], ['get', weightProp], weightRange[0], 0, weightRange[1], 1]
140
+ : 1,
141
+ 'heatmap-intensity': 1,
142
+ // ★ THE FIRST STOP MUST BE TRANSPARENT, for two reasons. Mechanically, `heatmap-color` is
143
+ // evaluated across the WHOLE viewport including everywhere the density is zero, so a ramp that
144
+ // starts opaque paints the entire screen. And perceptually, interpolating FROM transparent is
145
+ // what fades the fringe in: alpha ramps with density between 0 and the first stop, so a low
146
+ // reading is faint rather than a hard edge of the palest colour.
147
+ 'heatmap-color': [
148
+ 'interpolate',
149
+ ['linear'],
150
+ ['heatmap-density'],
151
+ 0,
152
+ 'rgba(0, 0, 0, 0)',
153
+ ...stops.flatMap((name, i) => [at(i), token(name)]),
154
+ ],
155
+ 'heatmap-radius': heatRadiusByZoom(radiusM, lat),
156
+ 'heatmap-opacity': alpha,
157
+ },
158
+ },
159
+ ];
160
+ }