@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,776 @@
1
+ // Drawing on the map — author a boundary and hand back GeoJSON.
2
+ //
3
+ // const tool = ref<DrawTool>('off')
4
+ // const draw = useMapDraw(map, () => ({ tool: tool.value }))
5
+ // <FMapDrawTools v-model:tool="tool" :drafting="!!draw.draft.value" @finish="draw.finish" @cancel="draw.cancel" />
6
+ //
7
+ // ★ IT OWNS THE FEATURES IT DRAWS, and it paints them. `features` is the source of truth while the
8
+ // editor is open, which means a surface that also paints those polygons — a zone overlay, say — hands
9
+ // them over with `load()` and stops painting them itself for as long as it is editing. Two pipelines
10
+ // over one set of polygons is how a shape ends up drawn twice in two colours, one of them stale.
11
+ //
12
+ // ★ THE SHAPE IS PAINTED IN THE DRAW COLOUR, NOT ITS OWN. A ring under the cursor has no kind yet, and
13
+ // the ones beside it are being compared against it, so authoring is a MODE the whole set is in rather
14
+ // than a property of one feature. What a zone means is decided after it exists.
15
+ //
16
+ // ★ EVERY DRAW LAYER IS UNSLOTTED, so it paints above every layer in the style. Handles a mark can
17
+ // cover are handles a reader cannot grab. The globe caveat on MARK_CLAIMS_SPACE applies — an unslotted
18
+ // layer lands below the labels under the globe projection — and a city map never reaches those zooms.
19
+ //
20
+ // ★ THE GESTURE DISCIPLINE IS REGION SELECT'S, measured against the same GL JS. An armed tool holds
21
+ // dragPan, boxZoom, dragRotate and doubleClickZoom off for as long as it is armed rather than for the
22
+ // duration of a drag, pointer capture keeps a drag alive when it leaves the canvas, and the click a
23
+ // completed drag emits is swallowed before GL JS can turn it into a map click. See useRegionSelect for
24
+ // what each of those cost to learn.
25
+ import { computed, onScopeDispose, ref, shallowRef, watch, type ShallowRef } from 'vue';
26
+ import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
27
+ import { point as turfPoint, polygon as turfPolygon } from '@turf/helpers';
28
+ import type { Map as MapboxMap } from 'mapbox-gl';
29
+ import { token, useMapOverlay, type OverlayLayer } from './useMapOverlay';
30
+ import type { LngLat } from './coordinates';
31
+ import type { ScreenPoint } from './regionSelect';
32
+ import {
33
+ boxCorners,
34
+ closeRing,
35
+ drawFeatureToGeoJSON,
36
+ geodesicRing,
37
+ historyOf,
38
+ insertVertex,
39
+ metresBetween,
40
+ midpoints,
41
+ moveVertex,
42
+ pushHistory,
43
+ redoHistory,
44
+ removeVertex,
45
+ ringProblem,
46
+ nearestWithin,
47
+ snapVertices,
48
+ undoHistory,
49
+ worthDrawing,
50
+ type DrawFeature,
51
+ type DrawHistory,
52
+ type DrawProblem,
53
+ type DrawRing,
54
+ type DrawTool,
55
+ } from './draw';
56
+
57
+ export interface MapDrawOptions {
58
+ /** what a drag does. `off` pans the map, and is the only tool that does */
59
+ tool?: DrawTool;
60
+ /** land a new vertex exactly on an existing one when it is within reach. Default true */
61
+ snap?: boolean;
62
+ /** a hard stop for a read-only map — no tool draws, whatever is armed */
63
+ disabled?: boolean;
64
+ /** fires on every committed change, with the whole set. Persistence is the caller's business */
65
+ onChange?: (features: DrawFeature[]) => void;
66
+ }
67
+
68
+ /**
69
+ * How near the pointer has to be to a handle to grab it, in px.
70
+ *
71
+ * Wider than the handle is drawn (5px radius) and wider than the snap radius, because grabbing is the
72
+ * one interaction with no second chance: a miss does not select the vertex behind, it starts a
73
+ * different gesture entirely — on a body drag, it moves the whole shape.
74
+ */
75
+ const HANDLE_HIT_PX = 12;
76
+
77
+ /** A double click, as this module measures one: two presses close together in time and in space. */
78
+ const DOUBLE_CLICK_MS = 350;
79
+ const DOUBLE_CLICK_PX = 8;
80
+
81
+ const SOURCE_SHAPE = 'forest-draw-shape';
82
+ const SOURCE_HANDLE = 'forest-draw-handle';
83
+
84
+ /** Ids are sequential rather than random so a test can predict them and a diff can be read. */
85
+ let sequence = 0;
86
+ const nextId = () => `draw-${++sequence}`;
87
+
88
+ type Drag =
89
+ | { kind: 'box'; from: ScreenPoint }
90
+ | { kind: 'circle'; centre: ScreenPoint }
91
+ | { kind: 'vertex'; id: string; index: number }
92
+ | { kind: 'body'; id: string; from: LngLat }
93
+ | null;
94
+
95
+ export function useMapDraw(map: ShallowRef<MapboxMap | null>, options: () => MapDrawOptions) {
96
+ const features = shallowRef<DrawFeature[]>([]);
97
+ /** the ring being placed a vertex at a time, before it closes */
98
+ const draft = shallowRef<DrawRing | null>(null);
99
+ /** where the pointer is, so an open draft can rubber-band to it. Null on touch, which never hovers */
100
+ const cursor = shallowRef<LngLat | null>(null);
101
+ /** the shape a rectangle or circle drag is describing right now */
102
+ const preview = shallowRef<DrawRing | null>(null);
103
+ const selectedId = ref<string | null>(null);
104
+ /** a circle drag's ground radius, so a control can say how big it is */
105
+ const radius = ref<number | null>(null);
106
+
107
+ let history: DrawHistory<DrawFeature[]> = historyOf<DrawFeature[]>([]);
108
+ const canUndo = ref(false);
109
+ const canRedo = ref(false);
110
+ const syncHistoryFlags = () => {
111
+ canUndo.value = history.past.length > 0 || !!draft.value;
112
+ canRedo.value = history.future.length > 0;
113
+ };
114
+
115
+ let drag: Drag = null;
116
+ let pressedAt: ScreenPoint | null = null;
117
+ let lastPress = { at: [0, 0] as ScreenPoint, time: 0 };
118
+
119
+ const tool = () => options().tool ?? 'off';
120
+ const armed = () => !options().disabled && tool() !== 'off';
121
+
122
+ /** The ring under the cursor: a draft, a live preview, or the selected feature in edit. */
123
+ const activeRing = computed<DrawRing | null>(() => {
124
+ if (draft.value) return draft.value;
125
+ if (preview.value) return preview.value;
126
+ const id = selectedId.value;
127
+ return id ? (features.value.find((f) => f.id === id)?.ring ?? null) : null;
128
+ });
129
+
130
+ /**
131
+ * Why the shape under the cursor cannot be committed, or null.
132
+ *
133
+ * ★ A draft of two vertices is not a problem, it is an unfinished sentence. `too-few` only reads as
134
+ * an error once the reader asks to finish, so an open draft below three vertices reports nothing and
135
+ * `finish()` is what refuses.
136
+ */
137
+ const problem = computed<DrawProblem | null>(() => {
138
+ const ring = activeRing.value;
139
+ if (!ring) return null;
140
+ if (draft.value && ring.length < 3) return null;
141
+ return ringProblem(ring);
142
+ });
143
+
144
+ const invalid = computed(() => problem.value !== null);
145
+
146
+ function emit(next: DrawFeature[]) {
147
+ features.value = next;
148
+ options().onChange?.(next);
149
+ }
150
+
151
+ /** Commit a change to the set, with an undo step. Every mutation that outlives a drag goes here. */
152
+ function commit(next: DrawFeature[]) {
153
+ history = pushHistory(history, next);
154
+ syncHistoryFlags();
155
+ emit(next);
156
+ }
157
+
158
+ function replaceRing(id: string, ring: DrawRing) {
159
+ emit(features.value.map((f) => (f.id === id ? { ...f, ring } : f)));
160
+ }
161
+
162
+ function addFeature(ring: DrawRing) {
163
+ if (ringProblem(ring)) return null;
164
+ const feature: DrawFeature = { id: nextId(), ring };
165
+ commit([...features.value, feature]);
166
+ selectedId.value = feature.id;
167
+ return feature;
168
+ }
169
+
170
+ // ── geometry ⇄ screen ────────────────────────────────────────────────────────────────────────────
171
+ const canvasPoint = (m: MapboxMap, e: PointerEvent): ScreenPoint => {
172
+ const r = m.getCanvas().getBoundingClientRect();
173
+ return [e.clientX - r.left, e.clientY - r.top];
174
+ };
175
+ const toLngLat = (m: MapboxMap, p: ScreenPoint): LngLat => {
176
+ const { lng, lat } = m.unproject(p);
177
+ return [lng, lat];
178
+ };
179
+ const toScreen = (m: MapboxMap, p: LngLat): ScreenPoint => {
180
+ const { x, y } = m.project(p);
181
+ return [x, y];
182
+ };
183
+
184
+ /**
185
+ * Every vertex a corner may land on, projected — the other features', and the draft's own.
186
+ *
187
+ * ★ Recomputed per call, which on a vertex drag means once per pointermove. That is a `project()`
188
+ * per candidate vertex per frame, and `project()` is a matrix multiply: a console with fifty zones
189
+ * of twenty corners costs a thousand of them, well under a frame. It stops being free at an order
190
+ * of magnitude more, and the fix then is a spatial index rather than a cache, since the whole set
191
+ * reprojects on every camera move anyway.
192
+ */
193
+ function snapCandidates(m: MapboxMap, exclude?: string): { at: ScreenPoint; lngLat: LngLat }[] {
194
+ const out = snapVertices(features.value, exclude).map((v) => ({ at: toScreen(m, v), lngLat: v }));
195
+ for (const v of draft.value ?? []) out.push({ at: toScreen(m, v), lngLat: v });
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * Where a corner actually lands — used for both placing one and dragging one.
201
+ *
202
+ * ★ Snapping is to an EXISTING POSITION, not to a rounded one. Two zones that share a boundary must
203
+ * share the coordinates of it exactly, or they leave a hairline gap that no one sees on the map and
204
+ * a point-in-polygon test finds every time.
205
+ *
206
+ * ★ AND IT HAS TO COVER THE DRAG, not just the click. Placing a corner on a neighbour is the tidy
207
+ * case; the one an operator actually reaches for is picking up a corner that is nearly right and
208
+ * putting it on its neighbour. A snap that only fired while drawing would leave the commonest path
209
+ * to a shared boundary as the one path that cannot produce one.
210
+ */
211
+ function place(m: MapboxMap, at: ScreenPoint, exclude?: string): LngLat {
212
+ const raw = toLngLat(m, at);
213
+ if (options().snap === false) return raw;
214
+ const candidates = snapCandidates(m, exclude);
215
+ const hit = nearestWithin(
216
+ candidates.map((c) => c.at),
217
+ at,
218
+ );
219
+ return hit == null ? raw : candidates[hit].lngLat;
220
+ }
221
+
222
+ // ── the draft ────────────────────────────────────────────────────────────────────────────────────
223
+ const apart = (a: ScreenPoint, b: ScreenPoint): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
224
+
225
+ function placeVertex(m: MapboxMap, at: ScreenPoint) {
226
+ const ring = draft.value;
227
+ const next = place(m, at);
228
+ if (!ring) {
229
+ draft.value = [next];
230
+ syncHistoryFlags();
231
+ return;
232
+ }
233
+ // Clicking the first vertex closes the ring — the gesture every drawing tool has, and the reason
234
+ // the draft's own vertices are snap candidates.
235
+ if (ring.length >= 3 && apart(toScreen(m, ring[0]), at) <= HANDLE_HIT_PX) {
236
+ finish();
237
+ return;
238
+ }
239
+ draft.value = [...ring, next];
240
+ syncHistoryFlags();
241
+ }
242
+
243
+ function dropLastVertex() {
244
+ const ring = draft.value;
245
+ if (!ring) return;
246
+ const next = ring.slice(0, -1);
247
+ draft.value = next.length ? next : null;
248
+ syncHistoryFlags();
249
+ }
250
+
251
+ /** Close the draft into a feature. Refuses a ring that is not a polygon, and says so by doing nothing. */
252
+ function finish() {
253
+ const ring = draft.value;
254
+ if (!ring || ringProblem(ring)) return;
255
+ draft.value = null;
256
+ cursor.value = null;
257
+ addFeature(ring);
258
+ }
259
+
260
+ /** Escape's job: abandon the draft first, and only then let go of the selection. */
261
+ function cancel() {
262
+ if (draft.value) {
263
+ draft.value = null;
264
+ cursor.value = null;
265
+ syncHistoryFlags();
266
+ return;
267
+ }
268
+ selectedId.value = null;
269
+ }
270
+
271
+ function remove(id: string) {
272
+ if (selectedId.value === id) selectedId.value = null;
273
+ commit(features.value.filter((f) => f.id !== id));
274
+ }
275
+
276
+ /**
277
+ * ★ UNDO MEANS "TAKE BACK THE LAST THING I DID", and during a draft that is the last vertex — not the
278
+ * whole shape that existed before the draft started. Sending an open draft through the feature
279
+ * history would throw away a dozen placed vertices for one keystroke.
280
+ */
281
+ function undo() {
282
+ if (draft.value) {
283
+ dropLastVertex();
284
+ return;
285
+ }
286
+ history = undoHistory(history);
287
+ syncHistoryFlags();
288
+ emit(history.present);
289
+ }
290
+
291
+ function redo() {
292
+ history = redoHistory(history);
293
+ syncHistoryFlags();
294
+ emit(history.present);
295
+ }
296
+
297
+ /** Seed the editor, without an undo step — there is nothing yet to undo back to. */
298
+ function load(next: DrawFeature[]) {
299
+ history = historyOf(next);
300
+ syncHistoryFlags();
301
+ features.value = next;
302
+ }
303
+
304
+ // ── hit testing ──────────────────────────────────────────────────────────────────────────────────
305
+ function handleAt(m: MapboxMap, at: ScreenPoint) {
306
+ const id = selectedId.value;
307
+ const ring = id ? features.value.find((f) => f.id === id)?.ring : null;
308
+ if (!id || !ring) return null;
309
+
310
+ const vertex = nearestWithin(ring.map((v) => toScreen(m, v)), at, HANDLE_HIT_PX);
311
+ if (vertex != null) return { kind: 'vertex' as const, id, index: vertex };
312
+
313
+ const mid = nearestWithin(midpoints(ring).map((v) => toScreen(m, v)), at, HANDLE_HIT_PX);
314
+ if (mid != null) return { kind: 'midpoint' as const, id, index: mid };
315
+
316
+ return null;
317
+ }
318
+
319
+ /** The topmost feature under a point. Last drawn wins, which is what paints on top. */
320
+ function featureAt(m: MapboxMap, at: ScreenPoint): DrawFeature | null {
321
+ const p = turfPoint(toLngLat(m, at));
322
+ for (let i = features.value.length - 1; i >= 0; i--) {
323
+ const f = features.value[i];
324
+ if (f.ring.length < 3) continue;
325
+ if (booleanPointInPolygon(p, turfPolygon([closeRing(f.ring)]))) return f;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ // ── the gesture ──────────────────────────────────────────────────────────────────────────────────
331
+ /**
332
+ * Eat the click a completed drag is about to emit. Lifted from useRegionSelect, where the comment
333
+ * explaining why lives: capture phase on the canvas CONTAINER, and a timer as well as `once`,
334
+ * because a drag that ends outside the canvas emits no click at all.
335
+ */
336
+ function swallowNextClick(m: MapboxMap) {
337
+ const el = m.getCanvasContainer();
338
+ const eat = (ev: Event) => {
339
+ ev.stopPropagation();
340
+ cancelEat();
341
+ };
342
+ const cancelEat = () => {
343
+ clearTimeout(timer);
344
+ el.removeEventListener('click', eat, true);
345
+ };
346
+ let timer: ReturnType<typeof setTimeout>;
347
+ el.addEventListener('click', eat, true);
348
+ timer = setTimeout(cancelEat, 300);
349
+ }
350
+
351
+ function onDown(e: PointerEvent) {
352
+ const m = map.value;
353
+ if (!m || !armed() || e.button !== 0) return;
354
+ const at = canvasPoint(m, e);
355
+ pressedAt = at;
356
+ m.getCanvas().setPointerCapture?.(e.pointerId);
357
+
358
+ if (tool() === 'rectangle') drag = { kind: 'box', from: at };
359
+ else if (tool() === 'circle') drag = { kind: 'circle', centre: at };
360
+ else if (tool() === 'edit') {
361
+ const handle = handleAt(m, at);
362
+ if (handle?.kind === 'vertex') {
363
+ drag = { kind: 'vertex', id: handle.id, index: handle.index };
364
+ } else if (handle?.kind === 'midpoint') {
365
+ // ★ Dragging a midpoint INSERTS, then drags what it inserted. Placing a vertex and then having
366
+ // to grab it again is two gestures for one intention, and the second one has to find a handle
367
+ // that was not there when the reader started moving.
368
+ const f = features.value.find((x) => x.id === handle.id);
369
+ if (f) {
370
+ const mid = midpoints(f.ring)[handle.index];
371
+ replaceRing(handle.id, insertVertex(f.ring, handle.index, mid));
372
+ drag = { kind: 'vertex', id: handle.id, index: handle.index + 1 };
373
+ }
374
+ } else {
375
+ const hit = featureAt(m, at);
376
+ selectedId.value = hit?.id ?? null;
377
+ if (hit) drag = { kind: 'body', id: hit.id, from: toLngLat(m, at) };
378
+ }
379
+ }
380
+ e.preventDefault();
381
+ }
382
+
383
+ function onMove(e: PointerEvent) {
384
+ const m = map.value;
385
+ if (!m || !armed()) return;
386
+ const at = canvasPoint(m, e);
387
+
388
+ if (!drag) {
389
+ // An open draft rubber-bands to the pointer, so the edge about to be committed is visible before
390
+ // it is. Nothing else needs to know where the pointer is.
391
+ if (tool() === 'polygon' && draft.value) cursor.value = toLngLat(m, at);
392
+ return;
393
+ }
394
+
395
+ if (drag.kind === 'box') {
396
+ preview.value = boxCorners(drag.from, at).map((p) => toLngLat(m, p));
397
+ } else if (drag.kind === 'circle') {
398
+ const centre = toLngLat(m, drag.centre);
399
+ const metres = metresBetween(centre, toLngLat(m, at));
400
+ radius.value = metres;
401
+ preview.value = metres > 0 ? geodesicRing(centre, metres) : null;
402
+ } else if (drag.kind === 'vertex') {
403
+ // Held in a const: `drag` is a mutable outer binding, so its narrowing does not survive into the
404
+ // callback below.
405
+ const held = drag;
406
+ const f = features.value.find((x) => x.id === held.id);
407
+ // Excluding the dragged feature is load-bearing — see snapVertices. Its own corners are the
408
+ // nearest candidates to the one in hand, and the handle would weld itself in place.
409
+ if (f) replaceRing(f.id, moveVertex(f.ring, held.index, place(m, at, held.id)));
410
+ } else if (drag.kind === 'body') {
411
+ const held = drag;
412
+ const to = toLngLat(m, at);
413
+ const dLng = to[0] - held.from[0];
414
+ const dLat = to[1] - held.from[1];
415
+ const f = features.value.find((x) => x.id === held.id);
416
+ if (f) {
417
+ replaceRing(f.id, f.ring.map(([lng, lat]) => [lng + dLng, lat + dLat] as LngLat));
418
+ // The anchor moves with the pointer, so the next frame measures its own delta rather than
419
+ // re-applying the whole drag from where it started.
420
+ held.from = to;
421
+ }
422
+ }
423
+ }
424
+
425
+ function onUp(e: PointerEvent) {
426
+ const m = map.value;
427
+ if (!m || !armed()) return;
428
+ const at = canvasPoint(m, e);
429
+ const from = pressedAt;
430
+ const current = drag;
431
+ m.getCanvas().releasePointerCapture?.(e.pointerId);
432
+ drag = null;
433
+ pressedAt = null;
434
+
435
+ // A press that never travelled is a click, whatever tool is armed.
436
+ const isClick = !from || !worthDrawing(from, at);
437
+ const doubled =
438
+ isClick &&
439
+ e.timeStamp - lastPress.time < DOUBLE_CLICK_MS &&
440
+ apart(at, lastPress.at) < DOUBLE_CLICK_PX;
441
+ if (isClick) lastPress = { at, time: e.timeStamp };
442
+
443
+ if (current?.kind === 'box' || current?.kind === 'circle') {
444
+ const ring = preview.value;
445
+ preview.value = null;
446
+ radius.value = null;
447
+ if (!isClick && ring) {
448
+ swallowNextClick(m);
449
+ addFeature(ring);
450
+ }
451
+ return;
452
+ }
453
+
454
+ if (current?.kind === 'vertex' || current?.kind === 'body') {
455
+ // ★ A double click ON A VERTEX removes it — the counterpart to dragging a midpoint, which is how
456
+ // one arrives. Delete would be the other spelling, but it is already spoken for by the feature,
457
+ // and a reader who has just grabbed a handle is holding a pointer rather than the keyboard.
458
+ if (doubled && current.kind === 'vertex') {
459
+ const f = features.value.find((x) => x.id === current.id);
460
+ // removeVertex refuses below three and says so by returning the ring untouched; committing the
461
+ // same ring back would then spend an undo step on nothing.
462
+ const next = f ? removeVertex(f.ring, current.index) : null;
463
+ if (f && next && next !== f.ring) {
464
+ commit(features.value.map((x) => (x.id === f.id ? { ...x, ring: next } : x)));
465
+ }
466
+ return;
467
+ }
468
+ // The drag mutated the ring on every move so the reader could see it; the undo step is the whole
469
+ // drag, taken once here rather than sixty times on the way.
470
+ if (!isClick) {
471
+ swallowNextClick(m);
472
+ commit(features.value);
473
+ }
474
+ return;
475
+ }
476
+
477
+ if (tool() === 'polygon' && isClick) {
478
+ // ★ The second click of a double click has already placed its vertex. Closing without dropping it
479
+ // would leave a duplicate of the previous position in the ring — invisible on the map, and a
480
+ // zero-length edge in the payload.
481
+ if (doubled) {
482
+ dropLastVertex();
483
+ finish();
484
+ return;
485
+ }
486
+ placeVertex(m, at);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Controls that own the keys this composable also wants.
492
+ *
493
+ * `closest`, not a tag test, so a keystroke landing on a span inside a button still counts as the
494
+ * button's.
495
+ */
496
+ const OWNS_ITS_KEYS =
497
+ 'input, textarea, select, button, a[href], [contenteditable=""], [contenteditable="true"], [role="button"], [role="textbox"]';
498
+
499
+ /**
500
+ * ★ A KEY TYPED INTO A FIELD IS NOT A MAP SHORTCUT.
501
+ *
502
+ * The listener is on the window, and it has to be: a shortcut must work wherever the reader's focus
503
+ * happens to sit, and there is no one element to hang it on. The cost of that reach is this guard.
504
+ * A surface puts <FMapSearch> on the same canvas; an armed tool outlives the reader's trip to the
505
+ * search field; and without the check Backspace deletes a ZONE instead of a letter — with
506
+ * preventDefault stopping the field from ever seeing the key it was given. Enter and ⌘Z fail the
507
+ * same way, quietly, on the surface this component was built for.
508
+ *
509
+ * ★ BUTTONS ARE IN THE LIST TOO, and not for symmetry. <FMapDrawTools> puts finish and discard a tab
510
+ * apart: focus discard, press Enter, and the browser's click is suppressed by this handler's
511
+ * preventDefault while the handler runs FINISH. The reader asks to throw the shape away and it is
512
+ * committed instead — the worst available outcome from one keystroke.
513
+ */
514
+ function ownsItsKeys(e: KeyboardEvent): boolean {
515
+ const el = e.target as Element | null;
516
+ return !!el && typeof el.closest === 'function' && !!el.closest(OWNS_ITS_KEYS);
517
+ }
518
+
519
+ function onKey(e: KeyboardEvent) {
520
+ if (!armed() || ownsItsKeys(e)) return;
521
+ const meta = e.metaKey || e.ctrlKey;
522
+ if (meta && e.key.toLowerCase() === 'z') {
523
+ e.preventDefault();
524
+ if (e.shiftKey) redo();
525
+ else undo();
526
+ return;
527
+ }
528
+ if (e.key === 'Escape') {
529
+ e.preventDefault();
530
+ cancel();
531
+ return;
532
+ }
533
+ if (e.key === 'Enter') {
534
+ e.preventDefault();
535
+ finish();
536
+ return;
537
+ }
538
+ if (e.key === 'Backspace' || e.key === 'Delete') {
539
+ e.preventDefault();
540
+ if (draft.value) dropLastVertex();
541
+ else if (selectedId.value) remove(selectedId.value);
542
+ }
543
+ }
544
+
545
+ // ── paint ────────────────────────────────────────────────────────────────────────────────────────
546
+ /**
547
+ * One source for shapes, one for handles.
548
+ *
549
+ * The draft goes in as a LineString until it has three vertices — a fill layer draws polygons and a
550
+ * line layer draws both, so an unfinished ring paints as the open path it is and starts filling the
551
+ * moment it could close.
552
+ */
553
+ const shapeData = computed<GeoJSON.FeatureCollection>(() => {
554
+ const out: GeoJSON.Feature[] = features.value.map((f) => ({
555
+ ...drawFeatureToGeoJSON(f),
556
+ properties: {
557
+ ...(f.properties ?? {}),
558
+ draft: false,
559
+ selected: f.id === selectedId.value,
560
+ invalid: f.id === selectedId.value && invalid.value,
561
+ },
562
+ }));
563
+
564
+ const ring = draft.value ?? preview.value;
565
+ if (ring?.length) {
566
+ // The rubber band is part of the draft's own path, not a second feature: one line, so the join at
567
+ // the last placed vertex is a join rather than two ends meeting.
568
+ const withCursor = draft.value && cursor.value ? [...ring, cursor.value] : ring;
569
+ const props = { draft: true, selected: true, invalid: invalid.value };
570
+ out.push(
571
+ withCursor.length >= 3
572
+ ? {
573
+ type: 'Feature',
574
+ properties: props,
575
+ geometry: { type: 'Polygon', coordinates: [closeRing(withCursor)] },
576
+ }
577
+ : {
578
+ type: 'Feature',
579
+ properties: props,
580
+ geometry: { type: 'LineString', coordinates: withCursor },
581
+ },
582
+ );
583
+ }
584
+ return { type: 'FeatureCollection', features: out };
585
+ });
586
+
587
+ /**
588
+ * Handles for the ring under the cursor, and only that one.
589
+ *
590
+ * ★ A draft shows its vertices but no midpoints. There is nothing to insert BETWEEN yet — the ring is
591
+ * still being extended at its end — and drawing insertion handles on an open path invites a reader to
592
+ * grab one mid-sentence.
593
+ *
594
+ * ★ AND HANDLES ONLY APPEAR WHERE THEY CAN BE GRABBED. A shape stays selected after it is drawn, but
595
+ * only the edit tool moves a vertex — so painting handles under the polygon tool would offer a grip
596
+ * that does nothing, which is worse than no grip at all.
597
+ */
598
+ const handleData = computed<GeoJSON.FeatureCollection>(() => {
599
+ const editing = tool() === 'edit' && selectedId.value;
600
+ const ring = draft.value ?? (editing ? activeRing.value : null);
601
+ if (!ring?.length || preview.value) return { type: 'FeatureCollection', features: [] };
602
+
603
+ const vertices: GeoJSON.Feature[] = ring.map((at, index) => ({
604
+ type: 'Feature',
605
+ properties: { kind: 'vertex', index, invalid: invalid.value },
606
+ geometry: { type: 'Point', coordinates: at },
607
+ }));
608
+ if (draft.value) return { type: 'FeatureCollection', features: vertices };
609
+
610
+ const mids: GeoJSON.Feature[] = midpoints(ring).map((at, index) => ({
611
+ type: 'Feature',
612
+ properties: { kind: 'midpoint', index, invalid: invalid.value },
613
+ geometry: { type: 'Point', coordinates: at },
614
+ }));
615
+ return { type: 'FeatureCollection', features: [...vertices, ...mids] };
616
+ });
617
+
618
+ /** invalid paints in the error colour, everything else in the draw colour */
619
+ const drawColour = ['case', ['get', 'invalid'], token('--forest-map-draw-invalid'), token('--forest-map-draw')];
620
+
621
+ const layers = computed<OverlayLayer[]>(() => [
622
+ {
623
+ id: 'forest-draw-fill',
624
+ type: 'fill',
625
+ source: SOURCE_SHAPE,
626
+ filter: ['==', ['geometry-type'], 'Polygon'],
627
+ paint: { 'fill-color': drawColour, 'fill-opacity': 0.12 },
628
+ },
629
+ {
630
+ // Solid: a committed boundary. Dashes are not data-driven in mapbox, so the two states are two
631
+ // layers — the same reason zoneLayers() draws one line layer per kind.
632
+ id: 'forest-draw-line',
633
+ type: 'line',
634
+ source: SOURCE_SHAPE,
635
+ filter: ['!', ['get', 'draft']],
636
+ paint: {
637
+ 'line-color': drawColour,
638
+ 'line-width': ['case', ['get', 'selected'], 2.5, 1.5],
639
+ },
640
+ },
641
+ {
642
+ id: 'forest-draw-draft-line',
643
+ type: 'line',
644
+ source: SOURCE_SHAPE,
645
+ filter: ['get', 'draft'],
646
+ paint: { 'line-color': drawColour, 'line-width': 2, 'line-dasharray': [2, 1.5] },
647
+ },
648
+ {
649
+ id: 'forest-draw-midpoint',
650
+ type: 'circle',
651
+ source: SOURCE_HANDLE,
652
+ filter: ['==', ['get', 'kind'], 'midpoint'],
653
+ paint: {
654
+ 'circle-radius': 3.5,
655
+ 'circle-color': token('--forest-map-draw-handle'),
656
+ 'circle-opacity': 0.85,
657
+ 'circle-stroke-width': 1.5,
658
+ 'circle-stroke-color': drawColour,
659
+ // A midpoint is an offer, not a vertex. Half-strength stroke is what keeps the two legible as
660
+ // different kinds of handle at a glance, without a second shape to learn.
661
+ 'circle-stroke-opacity': 0.55,
662
+ },
663
+ },
664
+ {
665
+ id: 'forest-draw-vertex',
666
+ type: 'circle',
667
+ source: SOURCE_HANDLE,
668
+ filter: ['==', ['get', 'kind'], 'vertex'],
669
+ paint: {
670
+ 'circle-radius': 5,
671
+ 'circle-color': token('--forest-map-draw-handle'),
672
+ 'circle-stroke-width': 2,
673
+ 'circle-stroke-color': drawColour,
674
+ },
675
+ },
676
+ ]);
677
+
678
+ useMapOverlay(map, () => ({
679
+ sources: {
680
+ [SOURCE_SHAPE]: { type: 'geojson', data: shapeData.value },
681
+ [SOURCE_HANDLE]: { type: 'geojson', data: handleData.value },
682
+ },
683
+ layers: layers.value,
684
+ }));
685
+
686
+ // ── wiring ───────────────────────────────────────────────────────────────────────────────────────
687
+ watch(
688
+ map,
689
+ (m, _old, onCleanup) => {
690
+ if (!m) return;
691
+ const el = m.getCanvasContainer();
692
+ el.addEventListener('pointerdown', onDown);
693
+ // move/up on the window: a gesture that leaves the canvas mid-drag must still finish.
694
+ window.addEventListener('pointermove', onMove);
695
+ window.addEventListener('pointerup', onUp);
696
+ window.addEventListener('keydown', onKey);
697
+ onCleanup(() => {
698
+ el.removeEventListener('pointerdown', onDown);
699
+ window.removeEventListener('pointermove', onMove);
700
+ window.removeEventListener('pointerup', onUp);
701
+ window.removeEventListener('keydown', onKey);
702
+ });
703
+ },
704
+ { immediate: true },
705
+ );
706
+
707
+ /**
708
+ * ★ THE MAP'S OWN GESTURES STAND DOWN FOR AS LONG AS A TOOL IS ARMED, not for the duration of a drag.
709
+ * Disabling inside pointerdown is a race that cannot be won: GL JS drives dragPan from `mousedown`,
710
+ * which a real press fires alongside `pointerdown`, and preventDefault on a pointer event does not
711
+ * reliably suppress the mouse event behind it.
712
+ *
713
+ * doubleClickZoom joins the three region select disables, because a polygon closes on a double click
714
+ * and zooming the map at the same moment moves every vertex under the reader's cursor.
715
+ */
716
+ watch(
717
+ [map, armed],
718
+ ([m, on]) => {
719
+ if (!m) return;
720
+ for (const h of [m.dragPan, m.boxZoom, m.dragRotate, m.doubleClickZoom]) {
721
+ if (on) h.disable();
722
+ else h.enable();
723
+ }
724
+ m.getCanvas().style.cursor = on ? 'crosshair' : '';
725
+ },
726
+ { immediate: true },
727
+ );
728
+
729
+ // Putting the tool down abandons what was in flight. Leaving a half-drawn ring on the map while the
730
+ // reader pans would promise an editor that is no longer listening.
731
+ watch(armed, (on) => {
732
+ if (on) return;
733
+ draft.value = null;
734
+ preview.value = null;
735
+ cursor.value = null;
736
+ });
737
+
738
+ onScopeDispose(() => {
739
+ window.removeEventListener('pointermove', onMove);
740
+ window.removeEventListener('pointerup', onUp);
741
+ window.removeEventListener('keydown', onKey);
742
+ const m = map.value;
743
+ if (!m) return;
744
+ for (const h of [m.dragPan, m.boxZoom, m.dragRotate, m.doubleClickZoom]) h.enable();
745
+ m.getCanvas().style.cursor = '';
746
+ });
747
+
748
+ return {
749
+ /** every shape the editor holds — the source of truth while it is open */
750
+ features,
751
+ /** the ring being placed, before it closes */
752
+ draft,
753
+ /** the shape a rectangle or circle drag is describing */
754
+ preview,
755
+ /** a circle drag's ground radius in metres, for a control that reports it */
756
+ radius,
757
+ selectedId,
758
+ /** why the shape under the cursor cannot be committed, or null */
759
+ problem,
760
+ invalid,
761
+ canUndo,
762
+ canRedo,
763
+ /** seed the editor. No undo step — there is nothing to go back to yet */
764
+ load,
765
+ finish,
766
+ cancel,
767
+ remove,
768
+ undo,
769
+ redo,
770
+ /** the set as GeoJSON, which is what a surface persists */
771
+ toGeoJSON: (): GeoJSON.FeatureCollection => ({
772
+ type: 'FeatureCollection',
773
+ features: features.value.map(drawFeatureToGeoJSON),
774
+ }),
775
+ };
776
+ }