@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,163 @@
1
+ // useMarkerSource — many markers from one clustered GeoJSON source.
2
+ //
3
+ // The source is GL-world (a source plus the layer that makes mapbox tile it), so it is a composable,
4
+ // not a component. It publishes the features currently in view; the page maps those to <FMarker>
5
+ // children, which keeps reconciliation in Vue's keyed v-for: a key leaving the list unmounts its
6
+ // marker and mapbox drops the element. Element count therefore tracks what is ON SCREEN rather than
7
+ // what exists, which is what moves the ceiling from a few hundred markers to a fleet.
8
+ //
9
+ // ★ The anchor layer is not optional. Mapbox only tiles a source that some layer references, and
10
+ // querySourceFeatures reads tiles — measured on a live map: 0 features with no layer, all of them once
11
+ // a zero-opacity circle layer is attached. It paints nothing; it exists so the source is tiled.
12
+ import { computed, onScopeDispose, shallowRef, watch, type ShallowRef } from 'vue';
13
+ import type { GeoJSONSource, Map } from 'mapbox-gl';
14
+
15
+ let uid = 0;
16
+
17
+ export interface MarkerSourceOptions {
18
+ /** the collection to place. Keep the reference stable (a computed) — a new object re-uploads it */
19
+ data: GeoJSON.FeatureCollection;
20
+ cluster?: boolean;
21
+ clusterRadius?: number;
22
+ clusterMaxZoom?: number;
23
+ clusterMinPoints?: number;
24
+ /** supercluster aggregations, e.g. { low: ['+', ['get', 'low']] } — read back off `props` */
25
+ clusterProperties?: Record<string, unknown>;
26
+ /** feature property to key markers by. Falls back to the feature's own id */
27
+ keyBy?: string;
28
+ /** `idle` re-reads once motion settles (cheap); `move` tracks every frame */
29
+ refresh?: 'idle' | 'move';
30
+ }
31
+
32
+ export interface MarkerSourceItem {
33
+ /** stable key for a v-for — cluster id, or the feature key */
34
+ key: string;
35
+ feature: GeoJSON.Feature;
36
+ at: [number, number];
37
+ isCluster: boolean;
38
+ /** members of the cluster; 0 for a single feature */
39
+ count: number;
40
+ /** feature properties, or the cluster's aggregated ones */
41
+ props: Record<string, unknown>;
42
+ }
43
+
44
+ export function useMarkerSource(map: ShallowRef<Map | null>, options: () => MarkerSourceOptions) {
45
+ const sourceId = `f-markers-${uid++}`;
46
+ const anchorId = `${sourceId}-anchor`;
47
+ const features = shallowRef<{ key: string; feature: GeoJSON.Feature }[]>([]);
48
+ let attached: Map | null = null;
49
+ let listening: 'idle' | 'move' = 'idle';
50
+
51
+ function read() {
52
+ const m = attached;
53
+ if (!m || !m.getSource(sourceId)) return;
54
+ const { keyBy } = options();
55
+ // querySourceFeatures returns one copy per tile, so a marker on a seam would double.
56
+ const seen = new Set<string>();
57
+ const out: { key: string; feature: GeoJSON.Feature }[] = [];
58
+ for (const feature of m.querySourceFeatures(sourceId)) {
59
+ const p = (feature.properties ?? {}) as Record<string, unknown>;
60
+ const key =
61
+ p.cluster_id != null
62
+ ? `c${p.cluster_id}`
63
+ : String(feature.id ?? (keyBy ? p[keyBy] : undefined) ?? JSON.stringify(feature.geometry));
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ out.push({ key, feature });
67
+ }
68
+ features.value = out;
69
+ }
70
+
71
+ function attach(m: Map) {
72
+ if (attached) return;
73
+ const o = options();
74
+ attached = m;
75
+ m.addSource(sourceId, {
76
+ type: 'geojson',
77
+ data: o.data,
78
+ ...(o.keyBy ? { promoteId: o.keyBy } : {}),
79
+ ...(o.cluster
80
+ ? {
81
+ cluster: true,
82
+ clusterRadius: o.clusterRadius ?? 50,
83
+ ...(o.clusterMaxZoom != null ? { clusterMaxZoom: o.clusterMaxZoom } : {}),
84
+ ...(o.clusterMinPoints != null ? { clusterMinPoints: o.clusterMinPoints } : {}),
85
+ ...(o.clusterProperties ? { clusterProperties: o.clusterProperties } : {}),
86
+ }
87
+ : {}),
88
+ } as never);
89
+ m.addLayer({
90
+ id: anchorId,
91
+ type: 'circle',
92
+ source: sourceId,
93
+ // The one layer whose slot carries no meaning: it paints nothing, so nothing can cover it. It is
94
+ // in `bottom` to stay out of the collision index's way, NOT via MAP_SLOT — the markers this
95
+ // source feeds are DOM elements over the canvas, which no basemap layer can hide.
96
+ slot: 'bottom',
97
+ paint: { 'circle-radius': 0, 'circle-opacity': 0 },
98
+ } as never);
99
+ listening = o.refresh === 'move' ? 'move' : 'idle';
100
+ m.on(listening, read);
101
+ m.on('sourcedata', read);
102
+ read();
103
+ }
104
+
105
+ // The style must be ready before addSource. `styledata` fires without needing a frame; `idle` is the
106
+ // backstop. Waiting on style.load alone deadlocks when the map arrives already loaded.
107
+ function start() {
108
+ const m = map.value;
109
+ if (!m || attached) return;
110
+ if (m.isStyleLoaded()) {
111
+ attach(m);
112
+ return;
113
+ }
114
+ m.once('styledata', start);
115
+ m.once('idle', start);
116
+ }
117
+
118
+ watch(map, start, { immediate: true });
119
+ watch(
120
+ () => options().data,
121
+ (data) => (attached?.getSource(sourceId) as GeoJSONSource | undefined)?.setData(data as never),
122
+ );
123
+
124
+ onScopeDispose(() => {
125
+ const m = attached;
126
+ if (!m) return;
127
+ m.off(listening, read);
128
+ m.off('sourcedata', read);
129
+ if (!m.isStyleLoaded()) return;
130
+ if (m.getLayer(anchorId)) m.removeLayer(anchorId);
131
+ if (m.getSource(sourceId)) m.removeSource(sourceId);
132
+ });
133
+
134
+ const items = computed<MarkerSourceItem[]>(() =>
135
+ features.value.map(({ key, feature }) => {
136
+ const props = (feature.properties ?? {}) as Record<string, unknown>;
137
+ return {
138
+ key,
139
+ feature,
140
+ at: (feature.geometry as GeoJSON.Point).coordinates as [number, number],
141
+ isCluster: !!props.cluster,
142
+ count: Number(props.point_count ?? 0),
143
+ props,
144
+ };
145
+ }),
146
+ );
147
+
148
+ return {
149
+ /** what is in view right now — feed this to a keyed v-for of <FMarker> */
150
+ items,
151
+ sourceId,
152
+ /** zoom to where a cluster breaks apart. No `essential` — reduced motion is honoured */
153
+ expand(item: MarkerSourceItem) {
154
+ const src = attached?.getSource(sourceId) as GeoJSONSource | undefined;
155
+ const clusterId = item.props.cluster_id as number | undefined;
156
+ if (!src || clusterId == null) return;
157
+ src.getClusterExpansionZoom(clusterId, (err, zoom) => {
158
+ if (!err && zoom != null) attached?.easeTo({ center: item.at, zoom });
159
+ });
160
+ },
161
+ refresh: read,
162
+ };
163
+ }
@@ -0,0 +1,309 @@
1
+ // Region selection — drag a lasso, box or circle over the map and act on everything inside it.
2
+ //
3
+ // const select = useFeatureSelect(map, () => ({ layers: ['bikes'], multiple: true }))
4
+ // const region = useRegionSelect(map, select, () => ({ layers: ['bikes'], tool: tool.value }))
5
+ //
6
+ // ★ THE TOOL IS THE ONLY WAY IN, by default. An earlier cut armed on a held modifier instead, and it
7
+ // was wrong twice over: the map had already claimed the keys — ctrl-drag rotates, shift-drag
8
+ // box-zooms — so a modifier drag fired the map's gesture and this one together; and a hidden gesture
9
+ // with no affordance is unreachable on touch, which made the whole feature desktop-only. `modifier`
10
+ // survives as an opt-in for a surface that wants a transient shortcut and has checked what it costs.
11
+ //
12
+ // ★ IT DOES NOT OWN A SELECTION. useFeatureSelect does, and this feeds it — which is the whole reason
13
+ // the two are separate. A second set would have to be reconciled with the first on every click, and
14
+ // the bulk-action bar would have to ask which one to believe.
15
+ //
16
+ // ★ TWO PASSES, AND THE FIRST IS FREE. `queryRenderedFeatures` takes a screen-space BOX, so the box
17
+ // around the drawn path is a native query with no geometry work at all. Only then is the result
18
+ // trimmed against the path itself, and only for a lasso — a box gesture is already its own answer, so
19
+ // it skips the polygon test entirely.
20
+ //
21
+ // ★ THE TRIM IS IN SCREEN SPACE, not lng/lat. A lasso is drawn on the picture, so it should catch what
22
+ // it visually encloses; testing in geographic space would disagree with the drawn shape the moment the
23
+ // map is rotated or pitched.
24
+ import { onScopeDispose, ref, shallowRef, watch, type Ref, type ShallowRef } from 'vue';
25
+ import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
26
+ import { point } from '@turf/helpers';
27
+ import type { Map as MapboxMap } from 'mapbox-gl';
28
+ import type { PickedFeature } from './useFeatureSelect';
29
+ import {
30
+ applyMode,
31
+ armsOnPlainDrag,
32
+ circleBounds,
33
+ circleFromPath,
34
+ inCircle,
35
+ pathBounds,
36
+ pathPolygon,
37
+ regionMode,
38
+ shapeForTool,
39
+ worthQuerying,
40
+ type RegionMode,
41
+ type RegionShape,
42
+ type RegionTool,
43
+ type ScreenPoint,
44
+ } from './regionSelect';
45
+
46
+ export interface RegionSelectOptions {
47
+ /** layers to catch features from, same ids you gave useFeatureSelect */
48
+ layers: string[];
49
+ /**
50
+ * Which tool the canvas is holding. `off` leaves a drag panning; `lasso` and `box` arm it, so a
51
+ * PLAIN drag draws a region.
52
+ *
53
+ * ★ This is what makes region selection exist on a phone. The modifier below is unreachable there,
54
+ * so without an armed tool the whole feature would be desktop-only — and once a plain drag can
55
+ * select, `off` has to be a state a reader can see and choose rather than an implied default.
56
+ */
57
+ tool?: RegionTool;
58
+ /**
59
+ * A transient shortcut: hold this and drag to draw a region without arming a tool. OFF by default,
60
+ * and opting in is a deliberate act because the map has already claimed most of the keyboard.
61
+ *
62
+ * ★ MEASURED AGAINST GL JS 3.24: `ctrl`+drag is dragRotate (`ctrlKey || button === RIGHT_BUTTON`)
63
+ * and `shift`+drag is boxZoom (`shiftKey && e.button === 0`). Either would fire the map's gesture
64
+ * AND this one from the same drag — and since the trim runs in screen space while the camera moves
65
+ * underneath it, the caught set would be wrong as well as the map spinning. `alt` is the one that
66
+ * is free for a drag; GL JS only reads it to slow scroll-zoom.
67
+ *
68
+ * ★ There is no `'none'`. It used to mean "no modifier required", which is what an armed `tool`
69
+ * means now — two spellings of one state, and the one that could be set by accident.
70
+ */
71
+ modifier?: 'meta' | 'alt' | 'shift';
72
+ /** A hard stop, for a read-only map. Unlike `tool: 'off'`, the modifier does not work either. */
73
+ disabled?: boolean;
74
+ }
75
+
76
+ /** The half of useFeatureSelect this needs. Narrow on purpose: it is a consumer, not a peer. */
77
+ export interface RegionSelectTarget {
78
+ selected: Ref<Set<string | number>>;
79
+ set: (features: PickedFeature[]) => void;
80
+ add: (features: PickedFeature[]) => void;
81
+ subtract: (ids: Iterable<string | number>) => void;
82
+ }
83
+
84
+ export function useRegionSelect(
85
+ map: ShallowRef<MapboxMap | null>,
86
+ target: RegionSelectTarget,
87
+ options: () => RegionSelectOptions,
88
+ ) {
89
+ /** the live path, in screen px — what the overlay draws */
90
+ const path = shallowRef<ScreenPoint[]>([]);
91
+ const active = ref(false);
92
+
93
+ let mode: RegionMode = 'replace';
94
+ let dragging = false;
95
+
96
+ /**
97
+ * The shape the CURRENT gesture is drawing, fixed at pointerdown.
98
+ *
99
+ * ★ Captured rather than read live, for the same reason the modifier is: switching tool mid-drag
100
+ * would otherwise re-shape a gesture already in flight, so the path drawn and the path tested would
101
+ * stop being the same path.
102
+ */
103
+ const shape = ref<RegionShape>('lasso');
104
+
105
+ const heldModifier = (e: PointerEvent): boolean => {
106
+ const mod = options().modifier;
107
+ // Nothing configured, no transient path — the tool is the way in.
108
+ if (!mod) return false;
109
+ if (mod === 'meta') return e.metaKey || e.ctrlKey;
110
+ if (mod === 'alt') return e.altKey;
111
+ return e.shiftKey;
112
+ };
113
+
114
+ // An armed tool selects on a plain drag; with none armed, only the modifier does.
115
+ const wanted = (e: PointerEvent): boolean =>
116
+ armsOnPlainDrag(options().tool ?? 'off') || heldModifier(e);
117
+
118
+ function canvasPoint(m: MapboxMap, e: PointerEvent): ScreenPoint {
119
+ const r = m.getCanvas().getBoundingClientRect();
120
+ return [e.clientX - r.left, e.clientY - r.top];
121
+ }
122
+
123
+ /**
124
+ * Eat the one `click` a completed drag emits, before GL JS can turn it into a map click.
125
+ *
126
+ * ★ Capture phase on the canvas CONTAINER, which is where GL JS binds its own listener. The click's
127
+ * target is the canvas inside it, so a capture listener on the container runs before the container's
128
+ * bubble listener — which is the only window in which this can be stopped.
129
+ *
130
+ * ★ And a timer, not just `once`, because a drag that ends outside the canvas emits no click at all.
131
+ * Left armed, the listener would swallow the reader's NEXT genuine click instead.
132
+ */
133
+ function swallowNextClick(m: MapboxMap) {
134
+ const el = m.getCanvasContainer();
135
+ const eat = (ev: Event) => {
136
+ ev.stopPropagation();
137
+ cancel();
138
+ };
139
+ const cancel = () => {
140
+ clearTimeout(timer);
141
+ el.removeEventListener('click', eat, true);
142
+ };
143
+ let timer: ReturnType<typeof setTimeout>;
144
+ el.addEventListener('click', eat, true);
145
+ timer = setTimeout(cancel, 300);
146
+ }
147
+
148
+ function onDown(e: PointerEvent) {
149
+ const m = map.value;
150
+ if (!m || options().disabled || e.button !== 0 || !wanted(e)) return;
151
+ // ★ Read the modifiers ONCE, here. A reader who releases shift mid-drag still meant "add".
152
+ mode = regionMode(e);
153
+ shape.value = shapeForTool(options().tool ?? 'off');
154
+ dragging = true;
155
+ active.value = true;
156
+ path.value = [canvasPoint(m, e)];
157
+ // Belt and braces for the modifier path, where panning is still on between gestures.
158
+ m.dragPan.disable();
159
+ m.getCanvas().setPointerCapture?.(e.pointerId);
160
+ e.preventDefault();
161
+ }
162
+
163
+ function onMove(e: PointerEvent) {
164
+ const m = map.value;
165
+ if (!dragging || !m) return;
166
+ const p = canvasPoint(m, e);
167
+ // A lasso is everything between press and release; a box is its two corners, and a circle its
168
+ // centre and a point on the rim — both of which only ever need the first point and the current one.
169
+ path.value = shape.value === 'lasso' ? [...path.value, p] : [path.value[0], p];
170
+ }
171
+
172
+ function onUp(e: PointerEvent) {
173
+ const m = map.value;
174
+ if (!dragging || !m) return;
175
+ dragging = false;
176
+ active.value = false;
177
+ // Only hand panning back if no tool is holding it. An armed tool keeps it off between gestures.
178
+ if (!armsOnPlainDrag(options().tool ?? 'off')) m.dragPan.enable();
179
+ m.getCanvas().releasePointerCapture?.(e.pointerId);
180
+
181
+ const drawn = path.value;
182
+ path.value = [];
183
+ // ★ A circle's own path bounds are a QUARTER of it — the drag runs centre to rim, so everything
184
+ // left of and above the centre would never be queried.
185
+ const circle = shape.value === 'circle' ? circleFromPath(drawn) : null;
186
+ const box = circle ? circleBounds(circle.centre, circle.radius) : pathBounds(drawn);
187
+ // Too small to have been meant — leave the selection alone and let the click handler have it.
188
+ if (!worthQuerying(box)) return;
189
+
190
+ // ★ SWALLOW THE CLICK THIS DRAG IS ABOUT TO PRODUCE, or the selection is destroyed the instant it
191
+ // is made. A press and release on the same element fire a `click` after `pointerup`, GL JS turns
192
+ // that into its own map click, and useFeatureSelect's handler treats a plain click as REPLACE — so
193
+ // it hit-tests the point the pointer happened to stop on, finds nothing, and commits an empty set
194
+ // over the region's result.
195
+ //
196
+ // It is invisible in synthetic tests, which never dispatch click, and it is why the modifier path
197
+ // appeared to work: a ⌘-click reads as ADDITIVE to that same handler, so it left the result alone
198
+ // instead of wiping it.
199
+ swallowNextClick(m);
200
+
201
+ const caught = query(m, drawn, box, circle);
202
+ if (mode === 'replace') target.set(caught);
203
+ else if (mode === 'add') target.add(caught);
204
+ else target.subtract(caught.map((f) => f.id));
205
+ }
206
+
207
+ function query(
208
+ m: MapboxMap,
209
+ drawn: ScreenPoint[],
210
+ box: [ScreenPoint, ScreenPoint],
211
+ circle: { centre: ScreenPoint; radius: number } | null,
212
+ ): PickedFeature[] {
213
+ const layers = options().layers.filter((id) => !!m.getLayer(id));
214
+ if (!layers.length) return [];
215
+
216
+ const hits = m.queryRenderedFeatures([box[0], box[1]], { layers });
217
+ // A box needs no trim — its bounds already are the answer. A circle trims by distance, a lasso
218
+ // by point-in-polygon.
219
+ const ring = shape.value === 'lasso' ? pathPolygon(drawn) : null;
220
+
221
+ const out: PickedFeature[] = [];
222
+ const seen = new Set<string | number>();
223
+ for (const f of hits) {
224
+ // No source means the feature cannot be painted: setFeatureState addresses one by
225
+ // (source, sourceLayer?, id), so an id alone is ambiguous across sources. Skipping is right —
226
+ // selecting something we cannot show as selected is worse than not selecting it.
227
+ if (f.id == null || !f.source || seen.has(f.id)) continue;
228
+ const geom = f.geometry;
229
+ if (geom.type !== 'Point') continue;
230
+ const at = geom.coordinates as [number, number];
231
+ // Trim the box down to the drawn shape. Projecting back to screen space keeps the test in the
232
+ // same space the lasso was drawn in.
233
+ if (ring || circle) {
234
+ const p = m.project(at);
235
+ if (ring && !booleanPointInPolygon(point([p.x, p.y]), ring)) continue;
236
+ if (circle && !inCircle([p.x, p.y], circle.centre, circle.radius)) continue;
237
+ }
238
+ seen.add(f.id);
239
+ out.push({
240
+ id: f.id,
241
+ source: f.source,
242
+ sourceLayer: f.sourceLayer,
243
+ at,
244
+ properties: f.properties ?? {},
245
+ });
246
+ }
247
+ return out;
248
+ }
249
+
250
+ watch(
251
+ map,
252
+ (m, _old, onCleanup) => {
253
+ if (!m) return;
254
+ const el = m.getCanvasContainer();
255
+ el.addEventListener('pointerdown', onDown);
256
+ // move/up on the window: a gesture that leaves the canvas mid-drag must still finish.
257
+ window.addEventListener('pointermove', onMove);
258
+ window.addEventListener('pointerup', onUp);
259
+ onCleanup(() => {
260
+ el.removeEventListener('pointerdown', onDown);
261
+ window.removeEventListener('pointermove', onMove);
262
+ window.removeEventListener('pointerup', onUp);
263
+ });
264
+ },
265
+ { immediate: true },
266
+ );
267
+
268
+ // ★ AN ARMED TOOL HOLDS PANNING OFF FOR AS LONG AS IT IS ARMED, rather than for the duration of a
269
+ // drag. Disabling it inside pointerdown is a race that cannot be won: GL JS drives dragPan from
270
+ // `mousedown`, which a real press fires alongside `pointerdown`, and preventDefault on a pointer
271
+ // event does not reliably suppress the mouse event that follows it. The map panned and the region
272
+ // never drew — the modifier path only looked fine because ⌘-drag is not a pan to begin with.
273
+ //
274
+ // Putting the tool down is how panning comes back, which is the other half of why `off` had to be a
275
+ // state a reader can choose.
276
+ watch(
277
+ [map, () => armsOnPlainDrag(options().tool ?? 'off')],
278
+ ([m, armed]) => {
279
+ if (!m) return;
280
+ // ★ boxZoom and dragRotate stand down too, not just panning. While a tool is armed the drag
281
+ // belongs to it, and GL JS binds both to modifiers this control also uses: shift-drag is
282
+ // box-zoom and it is how a region ADDS to the selection, so leaving it on would zoom the map
283
+ // every time a reader extended their selection. ctrl-drag rotates, for the same collision.
284
+ for (const h of [m.dragPan, m.boxZoom, m.dragRotate]) {
285
+ if (armed) h.disable();
286
+ else h.enable();
287
+ }
288
+ },
289
+ { immediate: true },
290
+ );
291
+
292
+ onScopeDispose(() => {
293
+ window.removeEventListener('pointermove', onMove);
294
+ window.removeEventListener('pointerup', onUp);
295
+ const m = map.value;
296
+ if (m) for (const h of [m.dragPan, m.boxZoom, m.dragRotate]) h.enable();
297
+ });
298
+
299
+ return {
300
+ /** the path being drawn, in canvas px — hand to <FMapRegion> */
301
+ path,
302
+ /** the shape this gesture is drawing, fixed at pointerdown — hand to <FMapRegion> */
303
+ shape,
304
+ /** whether a gesture is in flight */
305
+ active,
306
+ /** how many things a bulk action would act on, straight from the selection it feeds */
307
+ count: target.selected,
308
+ };
309
+ }
@@ -0,0 +1,61 @@
1
+ // The colour-mode seam for anything that RESOLVES a token rather than referencing it.
2
+ //
3
+ // ★ THE ONE-MODE-BEHIND BUG, and the only reliable fix.
4
+ //
5
+ // A CSS variable used as `var(--ui-primary)` needs no help — the browser re-resolves it when the mode
6
+ // class changes. But anything that reads a token's *computed value* (`getComputedStyle`, and therefore
7
+ // `resolveMapColor`) is taking a snapshot, and the snapshot is only correct if it is taken after the
8
+ // mode has actually landed on the document.
9
+ //
10
+ // A Vue watcher on an `isDark` ref does NOT satisfy that. The ref flips first and the class is written
11
+ // to <html> after, so a watcher — or a computed that reads the ref — resolves the mode the page has
12
+ // just LEFT. The symptom is unmistakable and is what sent people looking: the first toggle appears to
13
+ // do nothing, the second toggle works but shows the wrong mode, and it stays one behind forever.
14
+ //
15
+ // So: watch the DOM, not the ref. This ticks when the class actually lands.
16
+ //
17
+ // const mode = useTokenMode()
18
+ // const colour = computed(() => { void mode.value; return resolveMapColor('var(--ui-primary)') })
19
+ import { onScopeDispose, shallowRef, type ShallowRef } from 'vue';
20
+
21
+ /**
22
+ * A counter that increments whenever the resolved colour mode changes — the class or `data-theme`
23
+ * landing on `<html>`, or the OS preference flipping under a surface that follows it.
24
+ *
25
+ * Read it (`void mode.value`) inside any computed that calls `resolveMapColor`, or watch it to redo
26
+ * work that baked a token into something immutable — a rasterised sprite, a canvas, a chart's cached
27
+ * palette.
28
+ */
29
+ export function useTokenMode(): ShallowRef<number> {
30
+ const tick = shallowRef(0);
31
+ if (typeof document === 'undefined') return tick;
32
+
33
+ const bump = () => (tick.value += 1);
34
+ const observer = new MutationObserver(bump);
35
+ observer.observe(document.documentElement, { attributeFilter: ['class', 'data-theme'] });
36
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
37
+ media.addEventListener('change', bump);
38
+
39
+ onScopeDispose(() => {
40
+ observer.disconnect();
41
+ media.removeEventListener('change', bump);
42
+ });
43
+
44
+ return tick;
45
+ }
46
+
47
+ /**
48
+ * The same signal as a callback, for code that is not inside a reactive scope — `useMapOverlay` uses
49
+ * this to re-apply its resolved paint. Returns a disposer.
50
+ */
51
+ export function onTokenModeChange(fn: () => void): () => void {
52
+ if (typeof document === 'undefined') return () => {};
53
+ const observer = new MutationObserver(fn);
54
+ observer.observe(document.documentElement, { attributeFilter: ['class', 'data-theme'] });
55
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
56
+ media.addEventListener('change', fn);
57
+ return () => {
58
+ observer.disconnect();
59
+ media.removeEventListener('change', fn);
60
+ };
61
+ }
@@ -0,0 +1,131 @@
1
+ // The clock that reads a track.
2
+ //
3
+ // It owns time and nothing else: no map, no camera, no chrome. That is what lets a surface drive other
4
+ // layers from the same `at` — a global time cursor stays a composition rather than a rewrite — and what
5
+ // lets the arithmetic live in track.ts, where a node test can reach it.
6
+ import { computed, onScopeDispose, shallowRef, watch } from 'vue';
7
+ import {
8
+ advance,
9
+ fitSpeed,
10
+ positionAt,
11
+ progressAt,
12
+ trackSpan,
13
+ type Track,
14
+ type TrackPoint,
15
+ type TrackSpan,
16
+ } from './track';
17
+
18
+ export interface TrackPlaybackOptions {
19
+ track: Track;
20
+ /** a multiplier, or `fit` to play the whole track in `fitSeconds`. Default `fit` */
21
+ speed?: number | 'fit';
22
+ /** how long a `fit` playback takes, whatever the trip's real duration. Default 30 */
23
+ fitSeconds?: number;
24
+ maxGapMs?: number;
25
+ }
26
+
27
+ export function useTrackPlayback(options: () => TrackPlaybackOptions) {
28
+ const span = computed<TrackSpan>(() => trackSpan(options().track));
29
+ const at = shallowRef(span.value.start);
30
+ const playing = shallowRef(false);
31
+
32
+ /**
33
+ * ★ `fit` is the default. A 22-minute rental at 1× is not something anyone watches, and a control
34
+ * whose default setting nobody uses is a control that opens wrong every time.
35
+ */
36
+ const speed = computed(() => {
37
+ const o = options();
38
+ return o.speed === undefined || o.speed === 'fit' ? fitSpeed(span.value, o.fitSeconds ?? 30) : o.speed;
39
+ });
40
+
41
+ /**
42
+ * What was ASKED FOR, as opposed to what it resolved to.
43
+ *
44
+ * ★ A control must show this one. `fit` on a 50-minute trip resolves to about 101×, and a speed menu
45
+ * offering 1/2/4/8 with 101.4 selected shows a value that is in no list and means nothing to a
46
+ * reader — the setting is `fit`, and that is the word for it.
47
+ */
48
+ const mode = computed<number | 'fit'>(() => {
49
+ const o = options();
50
+ return o.speed === undefined ? 'fit' : o.speed;
51
+ });
52
+
53
+ const position = computed<TrackPoint | null>(() =>
54
+ positionAt(options().track, at.value, options().maxGapMs),
55
+ );
56
+ const progress = computed(() => progressAt(options().track, at.value));
57
+
58
+ let raf = 0;
59
+ let last = 0;
60
+
61
+ function frame(now: number) {
62
+ const next = advance(at.value, now - last, speed.value, span.value);
63
+ last = now;
64
+ at.value = next;
65
+ if (next >= span.value.end) {
66
+ playing.value = false;
67
+ raf = 0;
68
+ return;
69
+ }
70
+ raf = requestAnimationFrame(frame);
71
+ }
72
+
73
+ function play() {
74
+ // A composable that runs during SSR must not reach for rAF, and a track with no duration has
75
+ // nothing to play.
76
+ if (playing.value || !span.value.ms || typeof requestAnimationFrame === 'undefined') return;
77
+ // Pressing play at the end restarts, which is what the button looks like it should do.
78
+ if (at.value >= span.value.end) at.value = span.value.start;
79
+ playing.value = true;
80
+ last = performance.now();
81
+ raf = requestAnimationFrame(frame);
82
+ }
83
+
84
+ function pause() {
85
+ playing.value = false;
86
+ if (raf) cancelAnimationFrame(raf);
87
+ raf = 0;
88
+ }
89
+
90
+ /** Seeking mid-playback keeps playing: the operator is moving the playhead, not stopping the clock. */
91
+ function seek(t: number) {
92
+ const s = span.value;
93
+ at.value = Math.min(Math.max(t, s.start), s.end);
94
+ // Without this, the next frame's delta covers the time spent dragging and the playhead lurches.
95
+ last = typeof performance === 'undefined' ? 0 : performance.now();
96
+ }
97
+
98
+ const seekProgress = (p: number) =>
99
+ seek(span.value.start + Math.min(1, Math.max(0, p)) * span.value.ms);
100
+
101
+ // A new track is a new clock. Without this the playhead keeps a timestamp from a trip nobody is
102
+ // looking at any more, and the scrubber opens at a position that means nothing.
103
+ watch(span, (s) => {
104
+ pause();
105
+ at.value = s.start;
106
+ });
107
+
108
+ onScopeDispose(pause);
109
+
110
+ return {
111
+ /** epoch ms of the playhead */
112
+ at,
113
+ playing,
114
+ /** 0–1, linear in TIME — a four-minute halt occupies four minutes of the scrubber */
115
+ progress,
116
+ /** where the entity is at `at`, or null for an empty track */
117
+ position,
118
+ span,
119
+ /** the resolved multiplier the clock actually advances by */
120
+ speed,
121
+ /** the SETTING — `fit`, or the multiplier that was asked for. This is what a control shows */
122
+ mode,
123
+ play,
124
+ pause,
125
+ toggle: () => (playing.value ? pause() : play()),
126
+ seek,
127
+ seekProgress,
128
+ };
129
+ }
130
+
131
+ export type TrackPlayback = ReturnType<typeof useTrackPlayback>;