@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,208 @@
1
+ <script setup lang="ts">
2
+ // "Where am I" — Mapbox's behaviour, Forest's chrome.
3
+ //
4
+ // <FMapGeolocate :map="map" />
5
+ //
6
+ // ★ WRAPPED, NOT REBUILT, and this is the one control where that is the right call. Zoom is two methods
7
+ // and a compass is a bearing, but geolocation is a permission flow, a `watchPosition` subscription with
8
+ // its own error taxonomy, an accuracy ring that has to track the position's own confidence, a heading
9
+ // cone, and two tracking modes (active-lock, and the background mode a map enters once the user pans
10
+ // away). Rebuilding that is a liability with no upside; what was wrong with the stock control was never
11
+ // its behaviour.
12
+ //
13
+ // What IS wrong with it is placement: Mapbox mounts controls into its own corner containers, which are
14
+ // not the HUD's areas — so a stock control cannot sit in a rail, cannot take the canvas-chrome surface
15
+ // with the rest of the chrome, and cannot step aside for an open <FSidePanel> (the HUD insets its areas
16
+ // from the strip the panel publishes; Mapbox's corners never hear about it). So the control is added for
17
+ // its behaviour and its own button is hidden; ours drives it and reports back.
18
+ //
19
+ // ★ The user dot, its accuracy ring and the heading cone are still Mapbox's, drawn on the map itself
20
+ // rather than in the control container — they survive hiding the button, which is the whole trick.
21
+ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
22
+ import mapboxgl from 'mapbox-gl';
23
+ import FCanvasControl from '../canvas/FCanvasControl.vue';
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ /** The live map, from <FMap @ready>. */
28
+ map?: mapboxgl.Map | null;
29
+ /** Keep following the user after the first fix, which is what a rider-facing surface wants. */
30
+ track?: boolean;
31
+ /** The heading cone. Needs `track`, and a device that reports heading. */
32
+ heading?: boolean;
33
+ /** High accuracy costs battery; on by default because a bay 30m away is the wrong bay. */
34
+ highAccuracy?: boolean;
35
+ }>(),
36
+ { map: null, track: true, heading: true, highAccuracy: true },
37
+ );
38
+
39
+ const emit = defineEmits<{
40
+ geolocate: [position: GeolocationPosition];
41
+ error: [error: GeolocationPositionError];
42
+ }>();
43
+
44
+ const control = shallowRef<mapboxgl.GeolocateControl | null>(null);
45
+
46
+ /**
47
+ * ★ THREE STATES, because that is what the underlying control has — and collapsing them lost the one a
48
+ * rider is in most of the time. Mapbox documents them as:
49
+ *
50
+ * active the camera follows the user; the dot stays centred.
51
+ * passive the DOT still updates, the camera does not. Entered the moment the user pans away.
52
+ * disabled geolocation unavailable, or the permission refused.
53
+ *
54
+ * This used to render active and "off", so panning made the control look exactly as it had before the
55
+ * user ever located themselves — while their position was still live on the map. Passive is a real
56
+ * state and it now looks like one: located, not followed, and one click from following again.
57
+ */
58
+ export type GeolocateState = 'idle' | 'active' | 'passive' | 'disabled';
59
+
60
+ const following = ref(false);
61
+ const located = ref(false);
62
+ const unavailable = ref(false);
63
+
64
+ const state = computed<GeolocateState>(() =>
65
+ unavailable.value ? 'disabled' : following.value ? 'active' : located.value ? 'passive' : 'idle',
66
+ );
67
+
68
+ const onGeolocate = (e: unknown) => {
69
+ located.value = true;
70
+ emit('geolocate', e as GeolocationPosition);
71
+ };
72
+ const onStart = () => (following.value = true);
73
+ // Not "stop": the dot lives on. Only the camera let go.
74
+ const onEnd = () => (following.value = false);
75
+ const onError = (e: unknown) => {
76
+ following.value = false;
77
+ located.value = false;
78
+ // PERMISSION_DENIED is terminal for the session: the browser will not re-prompt, so a control that
79
+ // stays enabled invites a click that can only fail.
80
+ const err = e as GeolocationPositionError;
81
+ if (err?.code === 1) unavailable.value = true;
82
+ emit('error', err);
83
+ };
84
+
85
+ watch(
86
+ () => props.map,
87
+ (m, _old, onCleanup) => {
88
+ if (!m) return;
89
+
90
+ // No geolocation at all — an insecure origin, or a browser without it. Say so by disabling rather
91
+ // than by failing on click.
92
+ if (typeof navigator === 'undefined' || !navigator.geolocation) {
93
+ unavailable.value = true;
94
+ return;
95
+ }
96
+
97
+ // ★ ASK THE PERMISSIONS API UP FRONT, which is what the stock control does — and not asking showed:
98
+ // on an origin the user had already refused, Mapbox's own (hidden) button was disabled and labelled
99
+ // "Location not available" while ours sat there offering a click that could only fail. Reacting to
100
+ // the error alone means the control looks available until someone proves it is not.
101
+ //
102
+ // Watched, not read once: a permission granted or revoked in site settings has to reach the button
103
+ // without a reload.
104
+ void navigator.permissions
105
+ ?.query({ name: 'geolocation' as PermissionName })
106
+ .then((status) => {
107
+ const read = () => (unavailable.value = status.state === 'denied');
108
+ read();
109
+ status.addEventListener('change', read);
110
+ onCleanup(() => status.removeEventListener('change', read));
111
+ })
112
+ // Safari has refused `permissions.query` for geolocation historically: no answer is not a denial,
113
+ // so the control stays available and the error path remains the backstop.
114
+ .catch(() => {});
115
+
116
+ const ctrl = new mapboxgl.GeolocateControl({
117
+ positionOptions: { enableHighAccuracy: props.highAccuracy },
118
+ trackUserLocation: props.track,
119
+ showUserHeading: props.heading && props.track,
120
+ showAccuracyCircle: true,
121
+ // ★ The control's OWN button, suppressed the supported way. This used to be `display: none` on
122
+ // its `_container` — a private field, and a hack that would break the day Mapbox renamed it.
123
+ // The option exists for exactly this pattern: no button, `trigger()` called from ours, and the
124
+ // map-side layers (dot, ring, heading cone) unaffected because they are drawn on the map rather
125
+ // than in the control.
126
+ showButton: false,
127
+ });
128
+ m.addControl(ctrl);
129
+ control.value = ctrl;
130
+
131
+ ctrl.on('geolocate', onGeolocate);
132
+ ctrl.on('trackuserlocationstart', onStart);
133
+ ctrl.on('trackuserlocationend', onEnd);
134
+ ctrl.on('error', onError);
135
+
136
+ onCleanup(() => {
137
+ ctrl.off('geolocate', onGeolocate);
138
+ ctrl.off('trackuserlocationstart', onStart);
139
+ ctrl.off('trackuserlocationend', onEnd);
140
+ ctrl.off('error', onError);
141
+ if (m.hasControl(ctrl)) m.removeControl(ctrl);
142
+ control.value = null;
143
+ following.value = false;
144
+ located.value = false;
145
+ });
146
+ },
147
+ { immediate: true },
148
+ );
149
+
150
+ onBeforeUnmount(() => {
151
+ const m = props.map;
152
+ const ctrl = control.value;
153
+ if (m && ctrl && m.hasControl(ctrl)) m.removeControl(ctrl);
154
+ });
155
+
156
+ // `trigger()` is the control's own entry point, so the permission prompt, the fix and the tracking
157
+ // transitions all run exactly as they do for the stock button.
158
+ //
159
+ // ★ `following` is BOUND with v-model, so the click marks the button pressed before any fix arrives, and
160
+ // the control's own events then correct it — `trackuserlocationend` when the user pans away, `error`
161
+ // when the permission is refused. Optimistic on purpose: a geolocation fix can take seconds, and a
162
+ // button that looks inert until it lands reads as a button that did not take the press.
163
+ const locate = () => control.value?.trigger();
164
+
165
+ // Passive earns the FILLED icon without the pressed cut: the position is known and on screen, but the
166
+ // camera is the user's again. Idle is the outline. The difference has to be visible at a glance, or
167
+ // "am I still being followed?" costs a pan to answer.
168
+ //
169
+ // ★ And `disabled` takes the SLASHED glyph. A crossed-out icon means "this cannot happen" in every set,
170
+ // which is wrong for a resting control and exactly right for a refused permission — the one state where
171
+ // the control genuinely cannot act. Dimming alone left it looking like a button someone had simply not
172
+ // pressed yet.
173
+ const icon = computed(() => {
174
+ switch (state.value) {
175
+ case 'disabled':
176
+ return 'i-lucide-locate-off';
177
+ case 'idle':
178
+ return 'i-lucide-locate';
179
+ default:
180
+ return 'i-lucide-locate-fixed';
181
+ }
182
+ });
183
+
184
+ const label = computed(() => {
185
+ switch (state.value) {
186
+ case 'disabled':
187
+ return 'Location unavailable';
188
+ case 'active':
189
+ return 'Stop following your location';
190
+ case 'passive':
191
+ return 'Follow your location again';
192
+ default:
193
+ return 'Show your location';
194
+ }
195
+ });
196
+
197
+ defineExpose({ state });
198
+ </script>
199
+
200
+ <template>
201
+ <FCanvasControl
202
+ :icon="icon"
203
+ :label="label"
204
+ :disabled="!map || unavailable"
205
+ v-model:pressed="following"
206
+ @click="locate()"
207
+ />
208
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ // The switch for what the map is painting — the legend's interactive twin.
3
+ //
4
+ // <FMapLayers v-model="hidden" :sections="[{ label: 'Zones', items: zoneLegendItems('operations') }]" />
5
+ //
6
+ // ★ IT TAKES THE LEGEND'S OWN ROWS. A legend row says what a colour means; a layers row says the same
7
+ // thing and adds "and you can turn it off". Two lists with two vocabularies over one map would be drift
8
+ // with nothing to recommend it, so MapLegendItem is the contract for both and the swatch is drawn by
9
+ // one shared function.
10
+ //
11
+ // ★ IT CARRIES NO SURFACE AND DOES NOT POSITION ITSELF, for the reasons <FMapLegend> gives: over a
12
+ // basemap a bare list is unreadable, <FCanvasCard> is that panel already, and the same rows have to be
13
+ // able to sit in a side panel or a settings sheet where a card would be an intrusion. A rail trigger
14
+ // needs no new primitive either — <FCanvasControl v-model:pressed> opens an <FCanvasCard>.
15
+ //
16
+ // ★ IT TAKES NO MAP. The model feeds `visible:` in the surface's own OverlaySpec, and useMapOverlay
17
+ // applies it. Calling setLayoutProperty from here would work until the next apply — a spec change, a
18
+ // colour-mode flip, a new style — and then be reverted, with the switch still reporting the state it
19
+ // asked for. Given the map it could also enumerate layers the audience filter had already removed from
20
+ // the legend, and offer them; taking rows makes that impossible rather than merely unlikely.
21
+ //
22
+ // ★ AUDIENCE IS NOT A SWITCH. Rows come from the same builder the legend uses — zoneLegendItems(audience)
23
+ // — which has already dropped what that audience must not see. Visibility is the reader's; existence is
24
+ // the audience's, and it is settled upstream in the source and the geometry.
25
+ import { legendSwatchStyle } from './mapLegend';
26
+ import { sectionState, toggleKey, toggleSection, type MapLayerSection } from './mapLayers';
27
+
28
+ /** The keys NOT painted. `[]` is the map as the surface authored it — see mapLayers.ts. */
29
+ const hidden = defineModel<string[]>({ default: () => [] });
30
+
31
+ withDefaults(
32
+ defineProps<{
33
+ /** Groups of rows. One section without a label is a flat list, and needs no second API. */
34
+ sections?: MapLayerSection[];
35
+ }>(),
36
+ { sections: () => [] },
37
+ );
38
+ </script>
39
+
40
+ <template>
41
+ <div class="flex flex-col gap-4">
42
+ <div v-for="(section, i) in sections" :key="section.label ?? i" class="flex flex-col gap-2">
43
+ <!-- A labelled section gets the all/none header; an unlabelled one is a bare list, because
44
+ there is nothing to name the toggle after. `some` renders indeterminate and reveals on
45
+ press — the direction that cannot empty a map by accident. -->
46
+ <UCheckbox
47
+ v-if="section.label"
48
+ :model-value="
49
+ sectionState(section.items, hidden) === 'some' ? 'indeterminate' : sectionState(section.items, hidden) === 'all'
50
+ "
51
+ :label="section.label"
52
+ :ui="{ label: 'type-caption font-bold text-default' }"
53
+ @update:model-value="hidden = toggleSection(section.items, hidden)"
54
+ />
55
+
56
+ <ul role="list" class="flex flex-col gap-1.5" :class="section.label ? 'ps-6' : ''">
57
+ <li v-for="item in section.items" :key="item.key">
58
+ <USwitch
59
+ :model-value="!hidden.includes(item.key)"
60
+ @update:model-value="hidden = toggleKey(item.key, hidden)"
61
+ >
62
+ <template #label>
63
+ <span class="flex items-center gap-2">
64
+ <!-- The swatch is drawn the way the layer is drawn, by the legend's own function: a
65
+ reader checks either list by looking from it to the map and matching. -->
66
+ <span class="size-3 shrink-0 rounded-[2px] border" :style="legendSwatchStyle(item)" aria-hidden="true" />
67
+ <span class="type-caption text-default">{{ item.label }}</span>
68
+ </span>
69
+ </template>
70
+ </USwitch>
71
+ </li>
72
+ </ul>
73
+ </div>
74
+ </div>
75
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ // The key to what a map is painting.
3
+ //
4
+ // <FCanvasCard><FMapLegend :items="zoneLegendItems('operations')" /></FCanvasCard>
5
+ //
6
+ // ★ IT CARRIES NO SURFACE. Over a basemap a bare list is unreadable, so a legend on a map needs a
7
+ // panel — and <FCanvasCard> is that panel, with the translucent fill, the radius and the pointer
8
+ // opt-in already settled. Owning a second one here would mean maintaining two. Bare also lets the
9
+ // same rows sit in a side panel or a table, where a card would be an intrusion.
10
+ //
11
+ // ★ THE SWATCH IS DRAWN THE WAY THE LAYER IS DRAWN. A reader checks a legend by looking from the key
12
+ // to the map, and that only works if a zone painted as a bare dashed outline appears here as a bare
13
+ // dashed outline. See MapLegendItem — `swatch` and `dash` exist for exactly this, and are the reason
14
+ // this takes items rather than a list of colours and strings.
15
+ //
16
+ // ★ IT KNOWS NOTHING ABOUT ZONES. `zoneLegendItems()` in zone.ts builds the rows, including the
17
+ // audience rule that keeps the exclusion zone off a rider's map. Anything else with a paint vocabulary
18
+ // can build its own.
19
+ // ★ The swatch's style lives in mapLegend.ts, not here: <FMapLayers> draws the same row with a switch
20
+ // on it, and two copies of the wash would drift exactly where it costs most — a bounded zone shown as
21
+ // a filled one in the list a reader is using to decide what to turn off.
22
+ import { legendSwatchStyle, type MapLegendItem } from './mapLegend';
23
+
24
+ withDefaults(
25
+ defineProps<{
26
+ items: MapLegendItem[];
27
+ /** `vertical` stacks the rows for a corner card; `horizontal` wraps them for the hub band. */
28
+ orientation?: 'vertical' | 'horizontal';
29
+ }>(),
30
+ { orientation: 'vertical' },
31
+ );
32
+ </script>
33
+
34
+ <template>
35
+ <ul
36
+ role="list"
37
+ class="flex"
38
+ :class="orientation === 'horizontal' ? 'flex-wrap items-center gap-x-4 gap-y-2' : 'flex-col gap-1.5'"
39
+ >
40
+ <li v-for="item in items" :key="item.key" class="flex items-center gap-2">
41
+ <span class="size-3 shrink-0 rounded-[2px] border" :style="legendSwatchStyle(item)" aria-hidden="true" />
42
+ <span class="type-caption text-default">{{ item.label }}</span>
43
+ </li>
44
+ </ul>
45
+ </template>
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ // FMapPopup — a Mapbox popup whose contents are a Vue slot, so a callout can hold real components
3
+ // (a badge, a button) instead of an HTML string built by hand. Put it in <FMarker>'s `popup` slot and
4
+ // it binds to that marker's click; give it `lng-lat` and `v-model:open` to place one anywhere on the
5
+ // map. It must be the `popup` slot, not the default one — that slot replaces the pin itself.
6
+ //
7
+ // The chrome itself — surface, radius, tip, close button — is tokened in styles/map.css, which FMap
8
+ // imports. A popup is still Mapbox chrome: keep it to a line or two and put anything richer in app
9
+ // UI beside the map.
10
+ import { inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
11
+ import mapboxgl from 'mapbox-gl';
12
+ import { FOREST_MAP, FOREST_MARKER } from './mapContext';
13
+ import { useLazyHost } from './useMap';
14
+
15
+ type Anchor =
16
+ | 'center' | 'top' | 'bottom' | 'left' | 'right'
17
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** standalone placement. Omit inside <FMarker>, which supplies the position */
22
+ lngLat?: [number, number];
23
+ /** standalone open state — pair with v-model:open. Ignored when bound to a marker */
24
+ open?: boolean;
25
+ offset?: number | [number, number];
26
+ closeButton?: boolean;
27
+ anchor?: Anchor;
28
+ /** CSS max-width mapbox applies to the popup box */
29
+ maxWidth?: string;
30
+ }>(),
31
+ {
32
+ lngLat: undefined,
33
+ open: false,
34
+ offset: 18,
35
+ closeButton: false,
36
+ anchor: undefined,
37
+ maxWidth: '260px',
38
+ },
39
+ );
40
+
41
+ const emit = defineEmits<{ 'update:open': [value: boolean] }>();
42
+
43
+ const map = inject(FOREST_MAP, null);
44
+ const marker = inject(FOREST_MARKER, null);
45
+
46
+ // Slot content lives in this element; mapbox mounts it as the popup body. Created on demand rather
47
+ // than during setup, which may run on a server.
48
+ const { host, ensure } = useLazyHost();
49
+ const popup = shallowRef<mapboxgl.Popup | null>(null);
50
+
51
+ // Focus, the whole contract: a popup is a callout that just appeared somewhere on a canvas, so a
52
+ // keyboard user has to be able to reach it, dismiss it, and land back where they were. The body takes
53
+ // focus on open, Escape closes, and whatever was focused before (usually the marker button) gets it
54
+ // back — which is also why the popup must not be focused when it opens for no reason.
55
+ const body = ref<HTMLElement | null>(null);
56
+ let returnFocusTo: HTMLElement | null = null;
57
+
58
+ function onKeydown(event: KeyboardEvent) {
59
+ if (event.key !== 'Escape') return;
60
+ event.stopPropagation();
61
+ popup.value?.remove();
62
+ }
63
+
64
+ function build() {
65
+ if (popup.value) return popup.value;
66
+ const p = new mapboxgl.Popup({
67
+ offset: props.offset,
68
+ closeButton: props.closeButton,
69
+ maxWidth: props.maxWidth,
70
+ ...(props.anchor ? { anchor: props.anchor } : {}),
71
+ }).setDOMContent(ensure());
72
+ // Mapbox closes a popup on its own (close button, click-away, marker toggle) — mirror that back so
73
+ // a bound `open` never drifts from what is on screen.
74
+ p.on('close', () => {
75
+ emit('update:open', false);
76
+ // Only take focus back if it is still inside the popup that just went away; a click elsewhere has
77
+ // already moved it somewhere the user chose, and yanking it back would be worse than doing nothing.
78
+ const active = document.activeElement;
79
+ if (returnFocusTo && (!active || active === document.body || body.value?.contains(active))) {
80
+ returnFocusTo.focus();
81
+ }
82
+ returnFocusTo = null;
83
+ });
84
+ p.on('open', () => {
85
+ emit('update:open', true);
86
+ const active = document.activeElement;
87
+ returnFocusTo = active instanceof HTMLElement && active !== document.body ? active : null;
88
+ body.value?.focus();
89
+ });
90
+ popup.value = p;
91
+ return p;
92
+ }
93
+
94
+ // Bound to a marker: mapbox toggles it on marker click, and tracks the marker's position for us.
95
+ if (marker) {
96
+ watch(marker, (mk) => mk?.setPopup(build()), { immediate: true });
97
+ }
98
+
99
+ // Standalone: `open` + `lng-lat` drive it.
100
+ if (!marker && map) {
101
+ const sync = () => {
102
+ const m = map.value;
103
+ if (!m || !props.lngLat) return;
104
+ const p = build().setLngLat(props.lngLat);
105
+ if (props.open) p.addTo(m);
106
+ else p.remove();
107
+ };
108
+ watch([map, () => props.open, () => props.lngLat], sync, { immediate: true, deep: true });
109
+ }
110
+
111
+ onBeforeUnmount(() => popup.value?.remove());
112
+ </script>
113
+
114
+ <template>
115
+ <Teleport v-if="host" :to="host">
116
+ <!-- tabindex="-1" so the body can be focused programmatically without joining the tab order;
117
+ Escape is handled here rather than on the document, so it never steals the key from a dialog
118
+ or a command palette above the map. -->
119
+ <div
120
+ ref="body"
121
+ tabindex="-1"
122
+ role="dialog"
123
+ class="type-body font-sans focus:outline-none"
124
+ @keydown="onKeydown"
125
+ >
126
+ <slot />
127
+ </div>
128
+ </Teleport>
129
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // The key to a QUANTITY on the map — the ordered counterpart to <FMapLegend>.
3
+ //
4
+ // <FCanvasCard><FMapRamp :legend="densityLegend({ domain, unit: 'rides', res: 8 })" /></FCanvasCard>
5
+ //
6
+ // ★ IT IS A SEPARATE COMPONENT BECAUSE A RAMP IS NOT A LIST. `MapLegendItem` describes an unordered
7
+ // category by how it is DRAWN — filled or bounded, solid or dashed — and its rows are separated
8
+ // because the categories are unrelated to each other. A ramp is one continuous claim cut into
9
+ // buckets: the rows are in an order that means something, the swatches carry no fill/line
10
+ // distinction, and the numbers between them are the content. Forcing that through the categorical
11
+ // legend would mean spending its swatch vocabulary on something it does not describe.
12
+ //
13
+ // ★ THE SWATCHES ABUT. A gap between two swatches says "these are different things"; a density ramp
14
+ // says the opposite, and the reader has to see it as one bar to read it as an order.
15
+ //
16
+ // ★ AND IT STATES ITS UNIT. A shaded map with no unit is the most confidently wrong object in data
17
+ // visualisation: the reader takes the darkest cell as "a lot" without ever learning a lot of what, at
18
+ // what size, or whether the top of the ramp is the maximum. `unit`, `cellM` and `clamped` are all
19
+ // carried by the legend model rather than left to a caption someone might not write.
20
+ import type { RampLegend } from './density';
21
+
22
+ withDefaults(
23
+ defineProps<{
24
+ legend: RampLegend;
25
+ /** `horizontal` is the bar; `vertical` stacks the buckets for a narrow rail. */
26
+ orientation?: 'horizontal' | 'vertical';
27
+ }>(),
28
+ { orientation: 'horizontal' },
29
+ );
30
+ </script>
31
+
32
+ <template>
33
+ <div class="flex flex-col gap-1.5">
34
+ <p v-if="legend.unit" class="type-caption text-muted">{{ legend.unit }}</p>
35
+
36
+ <!-- Horizontal: one bar, labels under the buckets they belong to. -->
37
+ <ul v-if="orientation === 'horizontal'" role="list" class="flex items-stretch">
38
+ <li v-for="stop in legend.stops" :key="stop.key" class="flex min-w-0 flex-1 flex-col gap-1">
39
+ <span
40
+ class="h-3 w-full first:rounded-l-[2px] last:rounded-r-[2px]"
41
+ :style="{ backgroundColor: `var(${stop.colour})` }"
42
+ aria-hidden="true"
43
+ />
44
+ <span class="type-caption truncate text-muted">{{ stop.label }}</span>
45
+ </li>
46
+ </ul>
47
+
48
+ <!-- Vertical: rows, for a rail too narrow for a bar to carry its own labels. -->
49
+ <ul v-else role="list" class="flex flex-col">
50
+ <li v-for="stop in legend.stops" :key="stop.key" class="flex items-center gap-2">
51
+ <span
52
+ class="size-3 shrink-0 first:rounded-t-[2px] last:rounded-b-[2px]"
53
+ :style="{ backgroundColor: `var(${stop.colour})` }"
54
+ aria-hidden="true"
55
+ />
56
+ <span class="type-caption text-default">{{ stop.label }}</span>
57
+ </li>
58
+ </ul>
59
+
60
+ <!-- ★ The footnote is not optional decoration. The cell size is what makes a count comparable
61
+ between two cells, and the clamp is the difference between "the top of the ramp" and "the
62
+ largest reading" — a ramp that hides its clamp overstates the quiet parts of the map. -->
63
+ <p v-if="legend.cellM || legend.clamped" class="type-caption text-dimmed">
64
+ <span v-if="legend.cellM">cell ≈ {{ legend.cellM }} m across</span>
65
+ <span v-if="legend.cellM && legend.clamped"> · </span>
66
+ <span v-if="legend.clamped">{{ legend.clamped }} above the top of the ramp</span>
67
+ </p>
68
+ </div>
69
+ </template>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ // The shape a region gesture is drawing, painted over the canvas.
3
+ //
4
+ // const region = useRegionSelect(map, select, () => ({ layers: ['bikes'] }))
5
+ // <FMapRegion :path="region.path.value" :shape="'lasso'" />
6
+ //
7
+ // ★ IT DRAWS, IT DOES NOT LISTEN. Every pointer event belongs to useRegionSelect, which owns the
8
+ // gesture and needs the map to disable panning at exactly the right moment. An overlay that captured
9
+ // its own events would either fight that or need the same modifier logic a second time.
10
+ //
11
+ // ★ SCREEN SPACE, so it never reprojects. The path is already canvas px — the same coordinates the
12
+ // gesture was captured in and the same ones the hit test runs in — so the drawn shape and the caught
13
+ // features cannot disagree. An overlay that stored lng/lat would visibly lag a pan by one frame.
14
+ import { computed } from 'vue';
15
+ import { circleFromPath, type RegionShape, type ScreenPoint } from './regionSelect';
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ /** the live path from useRegionSelect, in canvas px. Empty draws nothing */
20
+ path: readonly ScreenPoint[];
21
+ shape?: RegionShape;
22
+ }>(),
23
+ { shape: 'lasso' },
24
+ );
25
+
26
+ const ring = computed(() => circleFromPath(props.path));
27
+
28
+ const points = computed(() => props.path.map(([x, y]) => `${x},${y}`).join(' '));
29
+
30
+ /** A box is its two corners; normalised so dragging up or left still yields a positive rect. */
31
+ const rect = computed(() => {
32
+ const [a, b] = [props.path[0], props.path[props.path.length - 1]];
33
+ if (!a || !b) return null;
34
+ return { x: Math.min(a[0], b[0]), y: Math.min(a[1], b[1]), w: Math.abs(b[0] - a[0]), h: Math.abs(b[1] - a[1]) };
35
+ });
36
+ </script>
37
+
38
+ <template>
39
+ <!-- pointer-events-none throughout: the gesture is captured on the map's own canvas container, and
40
+ an overlay that swallowed events would break the drag it exists to visualise. -->
41
+ <svg
42
+ v-if="path.length > 1"
43
+ class="pointer-events-none absolute inset-0 z-10 h-full w-full overflow-visible"
44
+ aria-hidden="true"
45
+ >
46
+ <rect
47
+ v-if="shape === 'box' && rect"
48
+ :x="rect.x"
49
+ :y="rect.y"
50
+ :width="rect.w"
51
+ :height="rect.h"
52
+ class="fill-primary/10 stroke-primary"
53
+ stroke-width="1.5"
54
+ stroke-dasharray="4 3"
55
+ />
56
+ <!-- Centre-out, matching the gesture: the press is the centre and the pointer is on the rim. -->
57
+ <circle
58
+ v-else-if="shape === 'circle' && ring"
59
+ :cx="ring.centre[0]"
60
+ :cy="ring.centre[1]"
61
+ :r="ring.radius"
62
+ class="fill-primary/10 stroke-primary"
63
+ stroke-width="1.5"
64
+ stroke-dasharray="4 3"
65
+ />
66
+ <!-- The lasso is drawn as a polygon rather than a polyline: the shape it will TEST against is
67
+ closed, so showing it open would promise a different catch than the one it makes. -->
68
+ <polygon
69
+ v-else-if="shape === 'lasso'"
70
+ :points="points"
71
+ class="fill-primary/10 stroke-primary"
72
+ stroke-width="1.5"
73
+ stroke-linejoin="round"
74
+ stroke-dasharray="4 3"
75
+ />
76
+ </svg>
77
+ </template>