@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,329 @@
1
+ // Fleet scale — the constants and ramps that must agree across surfaces.
2
+ //
3
+ // A fleet map is drawn twice: as CIRCLES while the whole city is on screen (30,000 DOM elements is not
4
+ // a thing anyone can afford), and as SYMBOLS once the camera is close enough for a bike glyph to read.
5
+ // The zoom at which one becomes the other is the seam, and it has to be the same number in the web
6
+ // console, the rider app and the docs — a mismatch is a fleet that changes size or disappears when a
7
+ // user moves between surfaces. So it lives here, exported, rather than being typed into each layer.
8
+ //
9
+ // Every ramp below is a style-spec expression: `useMapOverlay` passes them through untouched, and they
10
+ // evaluate on the GPU rather than in a watcher.
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
13
+ // THE DOUBLING INTERVAL — the one knob behind every size ramp on a map
14
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * ★★ Zoom is log₂ of ground scale, so EVERY marker size ramp is an exponential — whether or not the
18
+ * word `exponential` appears in it. Its base answers one question, and it is not a question of taste:
19
+ * how much of the mark's size belongs to the WORLD rather than to the screen?
20
+ *
21
+ * State the answer as a DOUBLING INTERVAL — the zoom levels over which the mark doubles — because that
22
+ * is the form a human can check against the artwork.
23
+ *
24
+ * base = 2 ** (1 / Z₂)
25
+ *
26
+ * The base is a DECLUTTER RATE. Visible features go as 4^−Δz and ink as N·r², so the ink on screen
27
+ * after zooming in one level, as a fraction of what it was:
28
+ *
29
+ * D = (base / 2)² = 4 ** (1 / Z₂ − 1)
30
+ *
31
+ * Z₂ ∞ base 1.00 D 0.25 screen-anchored — the mark is purely "read me"
32
+ * Z₂ 5 base 1.15 D 0.33 the street tier
33
+ * Z₂ 2 base 1.41 D 0.50 ink halves per zoom level
34
+ * Z₂ 1 base 2.00 D 1.00 world-anchored — never declutters
35
+ *
36
+ * ★★ Z₂ = 1 IS NOT A MARKER. A mark that holds its ground size has real extent, and a thing with
37
+ * extent is geometry — a `fill` or a `line` measured in metres — not a point identity. Every
38
+ * Forest marker sits in Z₂ ∈ [2, ∞]. Reaching for base 2 means the wrong layer type was chosen.
39
+ */
40
+ export const zoomBase = (doubling: number): number => 2 ** (1 / doubling);
41
+
42
+ /** Ink on screen after zooming in one level, as a fraction of what it was. See {@link zoomBase}. */
43
+ export const declutterRate = (doubling: number): number => 4 ** (1 / doubling - 1);
44
+
45
+ /**
46
+ * The doubling interval a pair of endpoints IMPLIES.
47
+ *
48
+ * Not every ramp gets to choose Z₂. Where both ends are already pinned by something else — the mass
49
+ * tier's top must equal the disc it turns into, its bottom is the city-view ink budget — Z₂ is an
50
+ * OUTPUT. Report it; do not move the artwork to reach a rounder number.
51
+ */
52
+ export const doublingInterval = (z1: number, v1: number, z2: number, v2: number): number =>
53
+ (z2 - z1) / Math.log2(v2 / v1);
54
+
55
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
56
+ // THE ARTWORK, AND THE FLOOR IT IMPOSES
57
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
58
+
59
+ /** The marker asset's canvas, in its own units. Every dimension below is read off that same file. */
60
+ export const MARKER_CANVAS_UNITS = 40;
61
+ /** The disc — the body a reader sees as "the mark". The battery ring sits outside it. */
62
+ export const DISC_R_UNITS = 14.4;
63
+ /** Every stroke on the asset: the inner edge, the battery edge, each corner mark's outline. */
64
+ export const EDGE_STROKE_UNITS = 1.6;
65
+
66
+ /**
67
+ * ★★ THE LEGIBILITY FLOOR — `icon-size` 0.625, a 25px mark. MEASURED on the published style.
68
+ *
69
+ * Probed by PEAK PURITY: for each region, the closest pixel to the colour it was given, and the share
70
+ * of its own fill area within ΔE 5 of it. Antialiasing CAPS what a shrinking region can reach, and
71
+ * that cap is what "it stopped being its colour" physically means. (A mean over the region is a trap —
72
+ * it always includes the region's own stroke, which is a constant bias reported as a signal.)
73
+ *
74
+ * icon-size mark px badge pure dot pure edge px edge ΔE
75
+ * 0.42 16.8 31 % 54 % 0.67 4.0
76
+ * 0.55 22 39 % 47 % 0.88 0.2
77
+ * 0.625 25 53 % 64 % 1.00 0 ← the knee
78
+ * 0.78 31.2 59 % 58 % 1.25 0
79
+ * 1.30 52 75 % 71 % 2.08 0
80
+ *
81
+ * Corner marks cross half their own area at 0.625, and it is the same rung where the edge first
82
+ * reaches a full pixel. ONE FLOOR, TWO CHANNELS — which is why this is a single constant.
83
+ *
84
+ * Identical on gl 3.24.1 and 3.28.1, so the number is not a version artefact.
85
+ *
86
+ * ⚠ SUPERSEDES a recorded "0.88px → ΔE 88" edge cliff, which does not reproduce: peak ΔE at 0.88px is
87
+ * 0.2 — a pixel still reaches pure white. What degrades below a pixel is the edge's COVERAGE, not
88
+ * its hue. The old figure was most likely a point sample that missed a sub-pixel stroke.
89
+ */
90
+ export const MARKER_MIN_ICON_SIZE = 0.625;
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
93
+ // THE SEAM
94
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Below this zoom a fleet is circles; at or above it, symbols.
98
+ *
99
+ * ★ The crossover goes where the INCOMING tier already clears its own floor — not where the outgoing
100
+ * tier stops being affordable. The street tier enters at {@link MARKER_MIN_ICON_SIZE}, so it is
101
+ * legible from its first frame and the mass tier is free to leave on its own schedule.
102
+ */
103
+ export const MARKER_CROSSOVER_ZOOM = 15;
104
+
105
+ /**
106
+ * ★★ THE DISSOLVE BAND — the zooms over which one tier becomes the other.
107
+ *
108
+ * The tiers do NOT cross-fade. Compositing the symbol (alpha b, on top) over the circle (alpha a)
109
+ * gives coverage `b + a(1 − b)`, so the obvious complementary fade — a = 1 − b — bottoms out at
110
+ * 0.5 + 0.5² = **0.75 at the midpoint**: the whole fleet visibly washes out halfway through the band
111
+ * and comes back. Holding coverage at 1 requires a = 1 for every b < 1.
112
+ *
113
+ * So the circle stays FULLY OPAQUE while the symbol fades in on top of it, and only leaves afterwards
114
+ * — by which point the symbol, which is strictly larger, already hides it completely.
115
+ *
116
+ * Two registrations make the dissolve almost invisible where the marks agree, which is most of them:
117
+ * · the mass radius reaches {@link CROSSOVER_RADIUS_PX} at the band's START and holds, so both discs
118
+ * are 9px across the whole band — disc colour fades in over the same disc colour, a no-op.
119
+ * · the circle's 1px halo spans r9→r10, and the symbol's inner edge (1.6 units at r15.2, scaled by
120
+ * 0.625) spans r9.0→r10.0 — the SAME pixels, the same `--ui-bg`. Also a no-op.
121
+ * What is left to see is exactly the honest difference between the tiers: the battery ring outside
122
+ * r10, and the glyph in the middle. Those materialise; nothing jumps.
123
+ *
124
+ * ⚠ The symbol layer's `minzoom` must be the band START, not the crossover. A layer minzoom is a HARD
125
+ * CUT — it was the whole bug: the symbol appeared at full opacity in one frame while the circle
126
+ * faded out underneath it, unseen. Measured at 46.7 ΔE in a single 0.05 zoom step at the disc
127
+ * centre, against ≤ 1.0 for every other step in the sweep.
128
+ */
129
+ export const DISSOLVE_START_ZOOM = 14.5;
130
+ export const DISSOLVE_END_ZOOM = 15.5;
131
+
132
+ /** Where the street ramp tops out and the mark stops growing. */
133
+ export const MARKER_RAMP_TOP_ZOOM = 18;
134
+
135
+ /** Z₂ for the street tier — CHOSEN. The mark doubles every five zoom levels. D ≈ 0.33. */
136
+ export const STREET_DOUBLING = 5;
137
+
138
+ /**
139
+ * Derived, never typed beside the floor: 0.625 doubled over five levels ⇒ 0.884, a 35px mark.
140
+ *
141
+ * ★ The ramp starts at the band's END, not at the crossover. The mark holds its floor size for the
142
+ * whole dissolve — growing while it fades in would push its disc out past the circle underneath and
143
+ * open the translucent annulus the registration above exists to avoid. It grows once it is alone.
144
+ */
145
+ export const MARKER_MAX_ICON_SIZE =
146
+ MARKER_MIN_ICON_SIZE * zoomBase(STREET_DOUBLING) ** (MARKER_RAMP_TOP_ZOOM - DISSOLVE_END_ZOOM);
147
+
148
+ /** FMarker's rendered pin diameters, in px. DOM marks do not answer to zoom — they are Z₂ = ∞. */
149
+ export const MARKER_PX = { sm: 24, md: 28, lg: 36 } as const;
150
+
151
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
152
+ // THE RAMPS
153
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * The mass tier's radius at the crossover IS the disc it is about to become — derived, so the two
157
+ * tiers cannot drift apart. `MARKER_PX.md / 4` used to stand here and sat 11 % under the disc.
158
+ */
159
+ export const CROSSOVER_RADIUS_PX = DISC_R_UNITS * MARKER_MIN_ICON_SIZE;
160
+
161
+ /** The city-view radius: the ink budget for the whole fleet on screen at once. */
162
+ export const MASS_MIN_RADIUS_PX = 2;
163
+ export const MASS_MIN_ZOOM = 10;
164
+
165
+ /** Z₂ for the mass tier — REPORTED, not chosen: both endpoints are pinned above. ≈ 2.3 levels. */
166
+ export const MASS_DOUBLING = doublingInterval(
167
+ MASS_MIN_ZOOM,
168
+ MASS_MIN_RADIUS_PX,
169
+ DISSOLVE_START_ZOOM,
170
+ CROSSOVER_RADIUS_PX,
171
+ );
172
+
173
+ /**
174
+ * Mass-tier radius. Ink coverage is quadratic in the EFFECTIVE radius — fill plus the halo stroke,
175
+ * which paints outside it — so this stays small where features are dense and only opens up as the
176
+ * camera closes in and the same features spread out.
177
+ *
178
+ * ★ ONE `exponential`, not three linear stops. The old form carried a middle stop at z13 whose only
179
+ * job was to fake this curve; a stop that shapes a curve rather than stating a value is a fudge.
180
+ * Over five zoom levels the difference is real — under three it would not be (see markerIconSize).
181
+ */
182
+ export const fleetCircleRadius = (): unknown[] => [
183
+ 'interpolate',
184
+ ['exponential', zoomBase(MASS_DOUBLING)],
185
+ ['zoom'],
186
+ MASS_MIN_ZOOM,
187
+ MASS_MIN_RADIUS_PX,
188
+ // ★ tops out at the band's START and holds there — `interpolate` clamps above its last stop. The
189
+ // circle must already be the size of the disc it becomes BEFORE the symbol starts fading in, or
190
+ // the symbol's disc overhangs a smaller circle and the overhang shows as a translucent ring.
191
+ DISSOLVE_START_ZOOM,
192
+ CROSSOVER_RADIUS_PX,
193
+ ];
194
+
195
+ /**
196
+ * The halo. It is what separates one mark from the next over a busy basemap — and it is also the ink
197
+ * budget's biggest term, because a stroke paints OUTSIDE the radius: r=2 with a 1px stroke covers
198
+ * π·9, not π·4. So it stays off while the whole city is on screen, and arrives once marks have room.
199
+ *
200
+ * ★ INTERPOLATED, not stepped. As a `step` at z12.5 it switched 0 → 1px in one frame on an r≈3.7
201
+ * circle — covered area (4.7/3.7)² = +62 % of the fleet's ink, instantly, which reads as a pop.
202
+ * Step on IDENTITY; interpolate on SIZE.
203
+ */
204
+ export const fleetCircleStroke = (): unknown[] => [
205
+ 'interpolate',
206
+ ['linear'],
207
+ ['zoom'],
208
+ 12,
209
+ 0,
210
+ 13,
211
+ 1,
212
+ ];
213
+
214
+ /**
215
+ * The mass tier HOLDS while the symbol tier arrives, then leaves.
216
+ *
217
+ * ★ It does not fade against the incoming tier — see {@link DISSOLVE_START_ZOOM} for why a
218
+ * complementary cross-fade washes the fleet out to 75 % coverage at the midpoint. By the time this
219
+ * drops, the symbol is at full alpha and strictly larger, so the circle is already hidden and the
220
+ * drop cannot be seen. It is a short ramp rather than a step only so that nothing depends on the
221
+ * two layers being composited in a particular order on the last frame.
222
+ */
223
+ export const fleetCircleOpacity = (): unknown[] => [
224
+ 'interpolate',
225
+ ['linear'],
226
+ ['zoom'],
227
+ DISSOLVE_END_ZOOM,
228
+ 1,
229
+ DISSOLVE_END_ZOOM + 0.1,
230
+ 0,
231
+ ];
232
+
233
+ /**
234
+ * The street tier ARRIVES. Zero at the band's start — where the layer's `minzoom` lets it in, so the
235
+ * hard cut lands on an invisible mark — and one at the band's end.
236
+ *
237
+ * ⚠ `icon-opacity` is the only lever here: a layer `minzoom` is a hard cut with no fade of its own,
238
+ * which is exactly what made the old seam a pop.
239
+ */
240
+ export const fleetSymbolOpacity = (): unknown[] => [
241
+ 'interpolate',
242
+ ['linear'],
243
+ ['zoom'],
244
+ DISSOLVE_START_ZOOM,
245
+ 0,
246
+ DISSOLVE_END_ZOOM,
247
+ 1,
248
+ ];
249
+
250
+ /**
251
+ * The street tier's WHOLE size control. Because the artwork's proportions are RATIOS — the ring is a
252
+ * fifth of the disc's radius by construction — this one ramp scales every region together.
253
+ *
254
+ * ★ `['linear']`, deliberately. Over the 2.5 levels this spans, linear and `['exponential', 1.149]`
255
+ * differ by 1.5 % at their farthest point (measured at the midpoint: 0.755 against 0.744) — which is
256
+ * invisible, and readable stops are worth more than a curve nobody can see. Reach for `exponential`
257
+ * past about four levels, where a linear ramp starts needing a fake middle stop to hide its kink.
258
+ *
259
+ * ★ THE STREET TIER HAS NO RADIUS RAMPS. There used to be three — `spriteRadius()`,
260
+ * `markerRingRadius()` and a `MARKER_RING_STROKE = 3` — because the ring and the offline dot were
261
+ * their own `circle` layers stacked around the sprite. They are regions of the uploaded icon now, so
262
+ * the street tier is one symbol layer and `icon-size` is its only size control. Deleted rather than
263
+ * deprecated: a fixed 3px stroke on a 12.5px disc is a ratio of 0.24, and the asset's ring is a
264
+ * fifth of the disc's radius. Two numbers for one edge is how a mark drifts.
265
+ */
266
+ export const markerIconSize = (): unknown[] => [
267
+ 'interpolate',
268
+ ['linear'],
269
+ ['zoom'],
270
+ // `interpolate` clamps below its first stop, so the mark sits at its floor for the whole dissolve —
271
+ // which is what keeps it registered against the circle underneath it.
272
+ DISSOLVE_END_ZOOM,
273
+ MARKER_MIN_ICON_SIZE,
274
+ MARKER_RAMP_TOP_ZOOM,
275
+ MARKER_MAX_ICON_SIZE,
276
+ ];
277
+
278
+ /**
279
+ * The zoom at which the street ramp reaches a given `icon-size` — {@link markerIconSize} inverted,
280
+ * clamped to the ramp's own span.
281
+ *
282
+ * ★ It exists so an ASSET THRESHOLD can become a ZOOM without anyone typing the zoom. A region of the
283
+ * marker becomes legible at some `icon-size` — a property of the artwork, which the generator
284
+ * measures and puts in the manifest — and a layer needs to know which zoom that is, which is a
285
+ * property of the ramp. Two files, one number, and this is the only place they meet.
286
+ */
287
+ export const zoomAtIconSize = (size: number): number => {
288
+ if (size <= MARKER_MIN_ICON_SIZE) return DISSOLVE_END_ZOOM;
289
+ if (size >= MARKER_MAX_ICON_SIZE) return MARKER_RAMP_TOP_ZOOM;
290
+ const span = MARKER_RAMP_TOP_ZOOM - DISSOLVE_END_ZOOM;
291
+ return (
292
+ DISSOLVE_END_ZOOM +
293
+ (span * (size - MARKER_MIN_ICON_SIZE)) / (MARKER_MAX_ICON_SIZE - MARKER_MIN_ICON_SIZE)
294
+ );
295
+ };
296
+
297
+ /**
298
+ * ★★ THE CLAMP ON `map.setScaleFactor()` — never leave it implicit.
299
+ *
300
+ * Mapbox's default is `[0.8, 2]`, which permits a 0.8× DOWNSCALE. Forest cannot allow that: the ramp's
301
+ * minimum IS the mark's measured legibility floor, where every stroke paints exactly 1.00px, so
302
+ * anything below 1× puts the whole mark under it — quietly, everywhere, for a reader who asked for
303
+ * BIGGER type. A floor that the size ramp defends and a scale factor walks straight through is not a
304
+ * floor.
305
+ *
306
+ * MEASURED on 3.28.1 — the clamp is real in both directions. One mark, rendered radius:
307
+ *
308
+ * factor 1.0 15.0px
309
+ * factor 1.5 22.5px exactly 1.5×
310
+ * factor 2.0 30.5px
311
+ * factor 3.0 30.5px clamped by the max
312
+ * factor 0.5, range [1, 2] 15.0px clamped by the min — the floor holds
313
+ *
314
+ * ⚠ `map.getScaleFactor()` reports what was SET, not what renders: it returns 3 while the mark is
315
+ * drawn at 2, and 0.5 while it is drawn at 1. Never read it back as the effective scale.
316
+ */
317
+ export const MARKER_SIZE_SCALE_RANGE: readonly [number, number] = [1, 2];
318
+
319
+ /**
320
+ * Symbol-layer layout for a fleet. These are not stylistic — they are the fix for a silent
321
+ * under-render. Mapbox hides colliding icons by DEFAULT (`icon-allow-overlap: false`), so a dense
322
+ * fleet quietly draws a fraction of itself and the map looks like fewer bikes rather than a placement
323
+ * conflict. `symbol-sort-key` then decides who draws on top when they do overlap.
324
+ */
325
+ export const FLEET_SYMBOL_LAYOUT = {
326
+ 'icon-allow-overlap': true,
327
+ 'icon-ignore-placement': true,
328
+ 'icon-optional': true,
329
+ } as const;
@@ -0,0 +1,67 @@
1
+ // The arithmetic behind <FMapScale>: how far the bar says it is, and how much of its track it fills.
2
+ //
3
+ // Out here rather than in the SFC because it is the only part with anything to get wrong, and
4
+ // packages/ui tests run in node with no DOM. The component measures the map and paints; this decides
5
+ // what the number is.
6
+
7
+ export type ScaleUnit = 'metric' | 'imperial';
8
+
9
+ export interface ScaleReading {
10
+ /** the rounded distance, in the reading's own unit */
11
+ distance: number;
12
+ /** what the bar is labelled — the distance and its unit, already formatted */
13
+ label: string;
14
+ /** how much of the available track the bar fills, 0–1 */
15
+ ratio: number;
16
+ }
17
+
18
+ const FEET_PER_METRE = 3.2808399;
19
+ const FEET_PER_MILE = 5280;
20
+
21
+ /**
22
+ * The largest 1/2/3/5 × 10ⁿ step that fits inside `max`.
23
+ *
24
+ * ★ A scale bar's whole job is a number you can read without arithmetic. Drawing the full available
25
+ * width and labelling it "1.37 km" is technically accurate and useless — the reader wants a round
26
+ * stick to hold against the map. So the BAR shrinks to the number rather than the number stretching
27
+ * to the bar, which is also why this never returns more than it was given.
28
+ */
29
+ export function roundedDistance(max: number): number {
30
+ const pow10 = Math.pow(10, Math.floor(Math.log10(max)));
31
+ const d = max / pow10;
32
+ const step = d >= 10 ? 10 : d >= 5 ? 5 : d >= 3 ? 3 : d >= 2 ? 2 : 1;
33
+ // ★ pow10 * step reintroduces binary-float noise below 1 — 0.1 * 3 is 0.30000000000000004 — and
34
+ // `distance` is public, so it is cleaned here rather than only where the label is formatted.
35
+ return Number((pow10 * step).toPrecision(12));
36
+ }
37
+
38
+ /** Trims float noise without printing trailing zeroes — 1.5 stays 1.5, 3.0 becomes 3. */
39
+ const trim = (n: number): string => String(Number(n.toFixed(2)));
40
+
41
+ /**
42
+ * What to draw for a track that spans `maxMetres` on the ground.
43
+ *
44
+ * Null when the map cannot answer yet — a zero-width canvas during layout, or a degenerate camera.
45
+ * `Math.log10(0)` is -Infinity and every number downstream becomes NaN, which paints a full-width bar
46
+ * carrying a meaningless label. An absent scale bar is honest; a lying one is not.
47
+ */
48
+ export function scaleReading(maxMetres: number, unit: ScaleUnit): ScaleReading | null {
49
+ if (!(maxMetres > 0) || !Number.isFinite(maxMetres)) return null;
50
+
51
+ if (unit === 'imperial') {
52
+ const maxFeet = maxMetres * FEET_PER_METRE;
53
+ if (maxFeet >= FEET_PER_MILE) {
54
+ const miles = roundedDistance(maxFeet / FEET_PER_MILE);
55
+ return { distance: miles, label: `${trim(miles)} mi`, ratio: (miles * FEET_PER_MILE) / maxFeet };
56
+ }
57
+ const feet = roundedDistance(maxFeet);
58
+ return { distance: feet, label: `${trim(feet)} ft`, ratio: feet / maxFeet };
59
+ }
60
+
61
+ if (maxMetres >= 1000) {
62
+ const km = roundedDistance(maxMetres / 1000);
63
+ return { distance: km, label: `${trim(km)} km`, ratio: (km * 1000) / maxMetres };
64
+ }
65
+ const metres = roundedDistance(maxMetres);
66
+ return { distance: metres, label: `${trim(metres)} m`, ratio: metres / maxMetres };
67
+ }
@@ -0,0 +1,50 @@
1
+ // One selection, two vocabularies.
2
+ //
3
+ // `useFeatureSelect` holds a Set of feature ids, because that is what a GL layer paints through
4
+ // feature-state. A table holds TanStack's RowSelectionState — `Record<string, boolean>`, keyed by
5
+ // whatever `get-row-id` returned. This is the translation, and it exists for one reason:
6
+ //
7
+ // ★ TANSTACK'S KEYS ARE STRINGS. GL feature ids are frequently NUMBERS — a GeoJSON `id` of 42 stays a
8
+ // number all the way through `queryRenderedFeatures`. `42` and `'42'` are the same row to a person and
9
+ // different keys to both sides, and nothing throws: the table selects nothing, the map paints nothing,
10
+ // and the surface author concludes the composable is broken. Every id crosses this boundary as a
11
+ // string, and the id type is the first thing the tests pin.
12
+
13
+ export type RowSelection = Record<string, boolean>;
14
+
15
+ /** The one place an id changes type. Everything downstream of here is a string. */
16
+ export const rowKey = (id: string | number): string => String(id);
17
+
18
+ /** The id set as a table's selection record. */
19
+ export function toRowSelection(ids: Iterable<string | number>): RowSelection {
20
+ const out: RowSelection = {};
21
+ for (const id of ids) out[rowKey(id)] = true;
22
+ return out;
23
+ }
24
+
25
+ /**
26
+ * The selected keys out of a table's record.
27
+ *
28
+ * ★ Only the true ones. TanStack keeps a key at `false` after a row is deselected rather than deleting
29
+ * it, so `Object.keys()` on the record is the set of rows that have EVER been selected — which as a
30
+ * map selection means the bikes the reader has been clicking all afternoon stay lit.
31
+ */
32
+ export function selectedKeys(selection: RowSelection): string[] {
33
+ return Object.keys(selection).filter((key) => selection[key]);
34
+ }
35
+
36
+ /**
37
+ * Whether the two sides already agree — the loop guard.
38
+ *
39
+ * Map → record → table → record → map is a cycle, and Vue will run it. Comparing by value before
40
+ * writing either side means a round trip that changes nothing writes nothing. A selection is tens of
41
+ * ids, not thousands, so this is cheaper than the epoch counter it replaces and impossible to get
42
+ * subtly wrong.
43
+ */
44
+ export function sameSelection(ids: Iterable<string | number>, selection: RowSelection): boolean {
45
+ const keys = selectedKeys(selection);
46
+ const wanted = new Set<string>();
47
+ for (const id of ids) wanted.add(rowKey(id));
48
+ if (keys.length !== wanted.size) return false;
49
+ return keys.every((key) => wanted.has(key));
50
+ }
@@ -0,0 +1,58 @@
1
+ // A coordinate pair, pasted into the search field, answered with the place it names.
2
+ //
3
+ // ★ THE REAL CASE IS A SUPPORT TICKET. Ops work from positions all day — a telemetry log, an incident
4
+ // report, a message from a rider's app — and every one of them carries a lat/lng rather than a street.
5
+ // Pasting it into the field and getting "280 Mare Street, Hackney" is the shortest path from a
6
+ // coordinate someone was handed to the place they have to think about. Mapbox's own search box has
7
+ // `allowReverse` for the same reason.
8
+ //
9
+ // ★ IT ALWAYS CARRIES GEOMETRY, so it never needs `resolve`. The query IS the position — the reverse
10
+ // lookup only supplies the NAME. A failed or empty geocode is therefore still a usable result: the
11
+ // camera can fly to a coordinate nobody has a word for, which is exactly the case on a canal towpath
12
+ // or an industrial estate.
13
+ import { geocoder, type Geocoder } from '../../address';
14
+ import { areaLabel, formatAddress } from '../../address/address';
15
+ import { formatCoordinates, parseCoordinates, type ParseCoordinatesOptions } from '../coordinates';
16
+ import type { MapSearchResult, MapSearchSource } from '../mapSearch';
17
+
18
+ export interface CoordinateSourceOptions extends ParseCoordinatesOptions {
19
+ accessToken: string;
20
+ /** heading shown above the row */
21
+ label?: string;
22
+ /** stands in for the geocoder in tests */
23
+ client?: Pick<Geocoder, 'reverse'>;
24
+ }
25
+
26
+ export function coordinateSource(options: CoordinateSourceOptions): MapSearchSource {
27
+ const client = options.client ?? geocoder({ accessToken: options.accessToken });
28
+
29
+ return {
30
+ id: 'coordinates',
31
+ label: options.label ?? 'Coordinates',
32
+ icon: 'i-lucide-crosshair',
33
+ limit: 1,
34
+
35
+ // ★ Reserves the best-match row, and should: a pasted coordinate pair is not a fuzzy query with a
36
+ // ranking, it is an exact statement of where. Cheap and synchronous — it only parses the text.
37
+ mayMatchExactly: (query) => parseCoordinates(query, options) !== null,
38
+
39
+ async search(query, signal): Promise<MapSearchResult[]> {
40
+ const at = parseCoordinates(query, options);
41
+ if (!at) return [];
42
+
43
+ const address = await client.reverse(at, signal).catch(() => null);
44
+ const geometry: GeoJSON.Geometry = { type: 'Point', coordinates: at };
45
+
46
+ // Named where Mapbox knows the place, and still usable where it does not.
47
+ return [
48
+ {
49
+ id: formatCoordinates(at),
50
+ title: address ? (formatAddress(address) || (areaLabel(address) ?? formatCoordinates(at))) : formatCoordinates(at),
51
+ subtitle: address ? formatCoordinates(at) : 'No address here',
52
+ exact: true,
53
+ geometry,
54
+ },
55
+ ];
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,71 @@
1
+ // Mapbox places and addresses as one <FMapSearch> source.
2
+ //
3
+ // It uses @mapbox/search-js-core — the headless half of Mapbox Search JS, no DOM and nothing to theme
4
+ // around — rather than the packaged <mapbox-search-box> element. FMapSearch.vue's header says why the
5
+ // element is not an option.
6
+ //
7
+ // ★ SEPARATE ENTRY POINT (`@humanforest/ui/map-search-places`). An app whose sources are all internal
8
+ // should not ship Mapbox's search client.
9
+ import { SearchBoxCore, SessionToken } from '@mapbox/search-js-core';
10
+ import type { MapSearchResult, MapSearchSource } from '../mapSearch';
11
+
12
+ /** The two calls this source makes, so a test can stand in for the client. */
13
+ type PlacesClient = Pick<SearchBoxCore, 'suggest' | 'retrieve'>;
14
+
15
+ export interface MapboxPlacesOptions {
16
+ accessToken: string;
17
+ /** ISO country codes to restrict to, e.g. `'gb'` */
18
+ country?: string;
19
+ language?: string;
20
+ /** bias results towards a point — usually the map's current centre */
21
+ proximity?: [number, number];
22
+ limit?: number;
23
+ /** stands in for the real client in tests */
24
+ client?: PlacesClient;
25
+ }
26
+
27
+ export function mapboxPlaces(options: MapboxPlacesOptions): MapSearchSource {
28
+ const client: PlacesClient =
29
+ options.client ??
30
+ new SearchBoxCore({
31
+ accessToken: options.accessToken,
32
+ country: options.country,
33
+ language: options.language,
34
+ });
35
+
36
+ // ★ One session token spans the suggests leading to a single retrieve, which is how the Search Box
37
+ // API bills a search: as one session rather than one call per keystroke. It rolls after each
38
+ // retrieve, because that is what ends the session.
39
+ let session = new SessionToken();
40
+
41
+ return {
42
+ id: 'places',
43
+ label: 'Places',
44
+ icon: 'i-lucide-map-pin',
45
+ limit: options.limit ?? 5,
46
+ // No mayMatchExactly: Mapbox ranks by relevance and never claims a query answers itself.
47
+ async search(query: string, signal: AbortSignal): Promise<MapSearchResult[]> {
48
+ const { suggestions } = await client.suggest(query, {
49
+ sessionToken: session,
50
+ proximity: options.proximity,
51
+ signal,
52
+ });
53
+
54
+ return suggestions.map((s) => ({
55
+ id: s.mapbox_id,
56
+ title: s.name,
57
+ subtitle: s.place_formatted,
58
+ // ★ No geometry here — suggest() returns names and addresses but no coordinates. They come
59
+ // from retrieve(), which is why every place result carries a resolve instead.
60
+ resolve: async (retrieveSignal: AbortSignal) => {
61
+ const { features } = await client.retrieve(s, {
62
+ sessionToken: session,
63
+ signal: retrieveSignal,
64
+ });
65
+ session = new SessionToken();
66
+ return features[0].geometry;
67
+ },
68
+ }));
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The house style: Forest's own Mapbox style, which IMPORTS Standard rather than copying it.
3
+ *
4
+ * Every Forest map points here. Standard's own style cannot carry uploaded images, so a surface that
5
+ * needs the Forest marker — or any future Forest icon — has to be on this one; and because it is an
6
+ * import, every `slot` value and every basemap config lever behaves exactly as it does on Standard.
7
+ *
8
+ * ★ Publishing this style in Studio is a PRODUCTION CHANGE: every surface picks it up at once. Pin a
9
+ * version (`…/style-id?version=N`) the moment that becomes a risk worth managing.
10
+ *
11
+ * ★ It lives in a plain module rather than in FMap.vue because `<script setup>` cannot contain ES
12
+ * module exports — vite rejects the SFC outright, and `vue-tsc` does not catch it.
13
+ */
14
+ export const FOREST_MAP_STYLE = 'mapbox://styles/humanforest/cms17j8ib004n01rzhq68cygn';