@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.
- package/LICENSE +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- package/theme.gen.json +1 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The map's time axis, as data.
|
|
2
|
+
//
|
|
3
|
+
// Four features are four readings of one list of fixes: a route is the whole track painted, playback
|
|
4
|
+
// reads it at a time, a trail is its tail, and a live vehicle is a track of length two growing at the
|
|
5
|
+
// head. So there is one primitive here and three consumers over it.
|
|
6
|
+
//
|
|
7
|
+
// It imports nothing from mapbox and nothing from Vue, which is what lets every rule below be pinned
|
|
8
|
+
// by a node test rather than checked by eye on a live map.
|
|
9
|
+
|
|
10
|
+
export interface Fix {
|
|
11
|
+
/** epoch ms */
|
|
12
|
+
at: number;
|
|
13
|
+
lng: number;
|
|
14
|
+
lat: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Ascending by `at`. A bare array: there is no metadata a track needs that its fixes do not carry. */
|
|
18
|
+
export type Track = readonly Fix[];
|
|
19
|
+
|
|
20
|
+
export interface TrackSpan {
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
ms: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TrackPoint {
|
|
27
|
+
lng: number;
|
|
28
|
+
lat: number;
|
|
29
|
+
/** degrees clockwise from north, along the segment being travelled */
|
|
30
|
+
bearing: number;
|
|
31
|
+
/** the reading falls inside a gap: this is the last KNOWN position, not an interpolated one */
|
|
32
|
+
gap: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ★ WHAT COUNTS AS SIGNAL LOSS.
|
|
37
|
+
*
|
|
38
|
+
* Telematics drop out — a basement, a tunnel, a flat modem. Two minutes without a fix at a cadence of
|
|
39
|
+
* a few seconds is unambiguous loss rather than a slow tick, and everything downstream treats it as a
|
|
40
|
+
* break: the line splits, and the mark waits where it was last seen.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_GAP_MS = 120_000;
|
|
43
|
+
|
|
44
|
+
/** How much of the past a trail shows. Long enough to read as a path, short enough not to be the trip. */
|
|
45
|
+
export const TRAIL_WINDOW_MS = 90_000;
|
|
46
|
+
|
|
47
|
+
/** IUGG mean earth radius, metres. */
|
|
48
|
+
const EARTH_RADIUS = 6_371_008.8;
|
|
49
|
+
const rad = (d: number) => (d * Math.PI) / 180;
|
|
50
|
+
const deg = (r: number) => (r * 180) / Math.PI;
|
|
51
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Great-circle distance in metres.
|
|
55
|
+
*
|
|
56
|
+
* Haversine rather than a planar approximation because these numbers are reported to operators: a
|
|
57
|
+
* flat estimate drifts visibly over a whole trip, and "how far did this rental go" is an answer
|
|
58
|
+
* somebody acts on.
|
|
59
|
+
*/
|
|
60
|
+
export function metresBetween(a: Fix, b: Fix): number {
|
|
61
|
+
const lat1 = rad(a.lat);
|
|
62
|
+
const lat2 = rad(b.lat);
|
|
63
|
+
const dLat = lat2 - lat1;
|
|
64
|
+
const dLng = rad(b.lng - a.lng);
|
|
65
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
|
66
|
+
return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Initial bearing, degrees clockwise from north, normalised to 0–360 so a mark's rotation is never negative. */
|
|
70
|
+
export function bearingBetween(a: Fix, b: Fix): number {
|
|
71
|
+
const lat1 = rad(a.lat);
|
|
72
|
+
const lat2 = rad(b.lat);
|
|
73
|
+
const dLng = rad(b.lng - a.lng);
|
|
74
|
+
const y = Math.sin(dLng) * Math.cos(lat2);
|
|
75
|
+
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
76
|
+
return (deg(Math.atan2(y, x)) + 360) % 360;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A zero span for an empty track rather than a throw: a scrubber renders before its data arrives. */
|
|
80
|
+
export function trackSpan(track: Track): TrackSpan {
|
|
81
|
+
if (!track.length) return { start: 0, end: 0, ms: 0 };
|
|
82
|
+
const start = track[0].at;
|
|
83
|
+
const end = track[track.length - 1].at;
|
|
84
|
+
return { start, end, ms: Math.max(0, end - start) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Index of the last fix at or before `at`, or -1. Binary search: a long trip is thousands of fixes. */
|
|
88
|
+
function indexAt(track: Track, at: number): number {
|
|
89
|
+
let lo = 0;
|
|
90
|
+
let hi = track.length - 1;
|
|
91
|
+
let best = -1;
|
|
92
|
+
while (lo <= hi) {
|
|
93
|
+
const mid = (lo + hi) >> 1;
|
|
94
|
+
if (track[mid].at <= at) {
|
|
95
|
+
best = mid;
|
|
96
|
+
lo = mid + 1;
|
|
97
|
+
} else {
|
|
98
|
+
hi = mid - 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return best;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Where the entity was at `at`.
|
|
106
|
+
*
|
|
107
|
+
* Interpolation is linear in lng/lat between the bracketing fixes. At a city's scale with seconds
|
|
108
|
+
* between them the great-circle correction is far below a pixel, and a spherical interpolation would
|
|
109
|
+
* be arithmetic nobody can check by eye.
|
|
110
|
+
*
|
|
111
|
+
* ★ Inside a gap this returns the last KNOWN fix with `gap: true`. It does not interpolate across
|
|
112
|
+
* one, because the straight line between two fixes twenty minutes apart is a journey that never
|
|
113
|
+
* happened — and it is the most convincing wrong thing this subsystem could draw.
|
|
114
|
+
*/
|
|
115
|
+
export function positionAt(track: Track, at: number, maxGapMs = MAX_GAP_MS): TrackPoint | null {
|
|
116
|
+
if (!track.length) return null;
|
|
117
|
+
|
|
118
|
+
const first = track[0];
|
|
119
|
+
const last = track[track.length - 1];
|
|
120
|
+
if (track.length === 1) return { lng: first.lng, lat: first.lat, bearing: 0, gap: false };
|
|
121
|
+
if (at <= first.at) {
|
|
122
|
+
return { lng: first.lng, lat: first.lat, bearing: bearingBetween(first, track[1]), gap: false };
|
|
123
|
+
}
|
|
124
|
+
if (at >= last.at) {
|
|
125
|
+
return { lng: last.lng, lat: last.lat, bearing: bearingBetween(track[track.length - 2], last), gap: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const i = indexAt(track, at);
|
|
129
|
+
const a = track[i];
|
|
130
|
+
const b = track[i + 1];
|
|
131
|
+
const bearing = bearingBetween(a, b);
|
|
132
|
+
const dt = b.at - a.at;
|
|
133
|
+
if (dt > maxGapMs) return { lng: a.lng, lat: a.lat, bearing, gap: true };
|
|
134
|
+
|
|
135
|
+
// Duplicate timestamps are a real feed condition. Dividing by that zero would hand mapbox NaN
|
|
136
|
+
// coordinates, and it stops drawing the mark without saying why.
|
|
137
|
+
const t = dt > 0 ? (at - a.at) / dt : 0;
|
|
138
|
+
return { lng: a.lng + (b.lng - a.lng) * t, lat: a.lat + (b.lat - a.lat) * t, bearing, gap: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* How far through the track `at` is, 0–1.
|
|
143
|
+
*
|
|
144
|
+
* ★ LINEAR IN TIME, NEVER IN DISTANCE. A trip audit exists to show what happened, and what happened
|
|
145
|
+
* includes the four minutes the rider stood at a junction. A distance-linear scrubber skips exactly
|
|
146
|
+
* that: the halt covers no distance, so it occupies no scrubber, and the one moment worth auditing is
|
|
147
|
+
* the one the control cannot reach.
|
|
148
|
+
*/
|
|
149
|
+
export function progressAt(track: Track, at: number): number {
|
|
150
|
+
const span = trackSpan(track);
|
|
151
|
+
if (!span.ms) return 0;
|
|
152
|
+
return clamp((at - span.start) / span.ms, 0, 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Split where the signal was lost. Every consumer that draws a track goes through this first. */
|
|
156
|
+
export function trackSegments(track: Track, maxGapMs = MAX_GAP_MS): Fix[][] {
|
|
157
|
+
if (!track.length) return [];
|
|
158
|
+
const out: Fix[][] = [[track[0]]];
|
|
159
|
+
for (let i = 1; i < track.length; i++) {
|
|
160
|
+
if (track[i].at - track[i - 1].at > maxGapMs) out.push([track[i]]);
|
|
161
|
+
else out[out.length - 1].push(track[i]);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The track as a GeoJSON feature, ready for a source.
|
|
168
|
+
*
|
|
169
|
+
* ★ A MultiLineString when there is a gap, so the break is drawn as a break.
|
|
170
|
+
*
|
|
171
|
+
* Null rather than an empty geometry when nothing can be a line: a single fix is a position, not a
|
|
172
|
+
* path, and an empty coordinate array is invalid GeoJSON that mapbox rejects in silence.
|
|
173
|
+
*/
|
|
174
|
+
export function trackLine(
|
|
175
|
+
track: Track,
|
|
176
|
+
maxGapMs = MAX_GAP_MS,
|
|
177
|
+
): GeoJSON.Feature<GeoJSON.LineString | GeoJSON.MultiLineString> | null {
|
|
178
|
+
const parts = trackSegments(track, maxGapMs)
|
|
179
|
+
.filter((seg) => seg.length > 1)
|
|
180
|
+
.map((seg) => seg.map((f) => [f.lng, f.lat] as [number, number]));
|
|
181
|
+
if (!parts.length) return null;
|
|
182
|
+
const geometry: GeoJSON.LineString | GeoJSON.MultiLineString =
|
|
183
|
+
parts.length === 1
|
|
184
|
+
? { type: 'LineString', coordinates: parts[0] }
|
|
185
|
+
: { type: 'MultiLineString', coordinates: parts };
|
|
186
|
+
return { type: 'Feature', properties: {}, geometry };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The track between two times, with both ends interpolated.
|
|
191
|
+
*
|
|
192
|
+
* ★ The interpolated ends are the point. A trail cut at the last fix BEFORE the playhead visibly lags
|
|
193
|
+
* the mark it belongs to — it stutters forward a fix at a time while the mark moves smoothly.
|
|
194
|
+
*/
|
|
195
|
+
export function trackSlice(track: Track, from: number, to: number, maxGapMs = MAX_GAP_MS): Fix[] {
|
|
196
|
+
if (!track.length || to <= from) return [];
|
|
197
|
+
const span = trackSpan(track);
|
|
198
|
+
const lo = Math.max(from, span.start);
|
|
199
|
+
const hi = Math.min(to, span.end);
|
|
200
|
+
if (hi <= lo) return [];
|
|
201
|
+
|
|
202
|
+
const head = positionAt(track, lo, maxGapMs);
|
|
203
|
+
const tail = positionAt(track, hi, maxGapMs);
|
|
204
|
+
const out: Fix[] = [];
|
|
205
|
+
if (head) out.push({ at: lo, lng: head.lng, lat: head.lat });
|
|
206
|
+
out.push(...track.filter((f) => f.at > lo && f.at < hi));
|
|
207
|
+
if (tail) out.push({ at: hi, lng: tail.lng, lat: tail.lat });
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The window of the past a trail shows, ending exactly at the reading. */
|
|
212
|
+
export function trackTail(
|
|
213
|
+
track: Track,
|
|
214
|
+
at: number,
|
|
215
|
+
windowMs = TRAIL_WINDOW_MS,
|
|
216
|
+
maxGapMs = MAX_GAP_MS,
|
|
217
|
+
): Fix[] {
|
|
218
|
+
return trackSlice(track, at - windowMs, at, maxGapMs);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The box the whole track fits in, as `[[west, south], [east, north]]`.
|
|
223
|
+
*
|
|
224
|
+
* Gaps are irrelevant here: a fix is a place the vehicle was, whether or not the line reaches it, so
|
|
225
|
+
* every fix counts toward the frame. Null for a track with nothing in it, so a caller can fall back to
|
|
226
|
+
* a default camera rather than framing a box that does not exist.
|
|
227
|
+
*/
|
|
228
|
+
export function trackBounds(track: Track): [[number, number], [number, number]] | null {
|
|
229
|
+
if (!track.length) return null;
|
|
230
|
+
let w = Infinity;
|
|
231
|
+
let s = Infinity;
|
|
232
|
+
let e = -Infinity;
|
|
233
|
+
let n = -Infinity;
|
|
234
|
+
for (const f of track) {
|
|
235
|
+
if (f.lng < w) w = f.lng;
|
|
236
|
+
if (f.lng > e) e = f.lng;
|
|
237
|
+
if (f.lat < s) s = f.lat;
|
|
238
|
+
if (f.lat > n) n = f.lat;
|
|
239
|
+
}
|
|
240
|
+
return [
|
|
241
|
+
[w, s],
|
|
242
|
+
[e, n],
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Metres travelled, gaps excluded — the distance we can evidence, not the distance we can guess. */
|
|
247
|
+
export function trackDistance(track: Track, maxGapMs = MAX_GAP_MS): number {
|
|
248
|
+
let total = 0;
|
|
249
|
+
for (const seg of trackSegments(track, maxGapMs)) {
|
|
250
|
+
for (let i = 1; i < seg.length; i++) total += metresBetween(seg[i - 1], seg[i]);
|
|
251
|
+
}
|
|
252
|
+
return total;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* ★ THE LONGEST FRAME THE CLOCK WILL BELIEVE.
|
|
257
|
+
*
|
|
258
|
+
* A backgrounded tab throttles rAF to roughly one frame a second. Without a clamp, returning to the
|
|
259
|
+
* tab advances the playhead by however long the operator was elsewhere — the trip jumps to its end and
|
|
260
|
+
* the audit is over. Clamping means a backgrounded playback falls behind, which is the honest failure.
|
|
261
|
+
*/
|
|
262
|
+
export const MAX_FRAME_MS = 100;
|
|
263
|
+
|
|
264
|
+
/** One frame of the clock. The whole of playback's arithmetic, testable with no browser and no map. */
|
|
265
|
+
export function advance(at: number, dtMs: number, speed: number, span: TrackSpan): number {
|
|
266
|
+
return Math.min(at + clamp(dtMs, 0, MAX_FRAME_MS) * speed, span.end);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The multiplier that plays a whole track in `fitSeconds`. 1 when there is nothing to divide. */
|
|
270
|
+
export function fitSpeed(span: TrackSpan, fitSeconds: number): number {
|
|
271
|
+
if (!span.ms || fitSeconds <= 0) return 1;
|
|
272
|
+
return span.ms / (fitSeconds * 1000);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Elapsed time as a stopwatch — `m:ss`, counting minutes past an hour rather than adding a field. */
|
|
276
|
+
export function elapsedLabel(ms: number): string {
|
|
277
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
278
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
279
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Hit-testing and selection for the mass tier.
|
|
2
|
+
//
|
|
3
|
+
// At city zoom a bike is a 3px circle. A click is not going to land on it, and `queryRenderedFeatures`
|
|
4
|
+
// at a single point returns nothing more often than it returns the bike the user meant — so every
|
|
5
|
+
// surface that has tried this has hand-rolled the same tolerance box, the same nearest-feature tie
|
|
6
|
+
// break, and the same pointermove throttle. This owns all three.
|
|
7
|
+
//
|
|
8
|
+
// What it does NOT own: the DOM marker for the focused item. Selection is a SET (dispatch assigns
|
|
9
|
+
// groups of bikes to a van), and the set is painted by feature-state on the GL layer; exactly one
|
|
10
|
+
// member — the focused one — gets an <FMarker> and an <FMapPopup>, and that stays the page's call
|
|
11
|
+
// because only the page knows what the callout says.
|
|
12
|
+
//
|
|
13
|
+
// ★ Selection is BY FEATURE ID, never by a captured feature object. A captured feature holds stale
|
|
14
|
+
// coordinates the moment the fleet ticks; an id can be re-read, and re-reading is what lets the focused
|
|
15
|
+
// marker follow a moving bike and disappear with a deleted one.
|
|
16
|
+
import { computed, onScopeDispose, ref, shallowRef, watch, type Ref, type ShallowRef } from 'vue';
|
|
17
|
+
// Aliased: `Map` would shadow the global one, and this file keeps a JS Map of the selection.
|
|
18
|
+
import type { FeatureSelector, Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
|
|
19
|
+
|
|
20
|
+
export interface FeatureSelectOptions {
|
|
21
|
+
/** layers to hit-test, in priority order. A layer not on the map yet is skipped, not an error */
|
|
22
|
+
layers: string[];
|
|
23
|
+
/** half-width of the hit box, in px. 8–12 is the range that feels right for 3px marks */
|
|
24
|
+
tolerance?: number;
|
|
25
|
+
/** paint `hover` feature-state as the pointer moves. Off for a read-only picture */
|
|
26
|
+
hover?: boolean;
|
|
27
|
+
/** allow more than one selected feature — the dispatch case */
|
|
28
|
+
multiple?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PickedFeature {
|
|
32
|
+
id: string | number;
|
|
33
|
+
source: string;
|
|
34
|
+
sourceLayer?: string;
|
|
35
|
+
at: [number, number];
|
|
36
|
+
properties: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// setFeatureState addresses a feature by (source, sourceLayer?, id) — an id on its own is ambiguous
|
|
40
|
+
// across sources, which is why PickedFeature carries where it came from.
|
|
41
|
+
const stateRef = (f: PickedFeature): FeatureSelector => ({
|
|
42
|
+
id: f.id,
|
|
43
|
+
source: f.source,
|
|
44
|
+
...(f.sourceLayer ? { sourceLayer: f.sourceLayer } : {}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function useFeatureSelect(map: ShallowRef<MapboxMap | null>, options: () => FeatureSelectOptions) {
|
|
48
|
+
/** every selected feature id — this is what the GL layer paints */
|
|
49
|
+
const selected = ref(new Set<string | number>());
|
|
50
|
+
/** the one member with a DOM marker and a callout. Null when the set is empty */
|
|
51
|
+
const focused = shallowRef<PickedFeature | null>(null);
|
|
52
|
+
const hovered = shallowRef<PickedFeature | null>(null);
|
|
53
|
+
|
|
54
|
+
let painted: PickedFeature[] = [];
|
|
55
|
+
let hoverPainted: PickedFeature | null = null;
|
|
56
|
+
let frame = 0;
|
|
57
|
+
|
|
58
|
+
const present = (m: MapboxMap) => options().layers.filter((id) => m.getLayer(id));
|
|
59
|
+
|
|
60
|
+
/** nearest to the pointer, not first in the box — a box returns whatever the tile order was */
|
|
61
|
+
function pick(m: MapboxMap, point: MapMouseEvent['point']): PickedFeature | null {
|
|
62
|
+
const layers = present(m);
|
|
63
|
+
if (!layers.length) return null;
|
|
64
|
+
const t = options().tolerance ?? 10;
|
|
65
|
+
const box: [[number, number], [number, number]] = [
|
|
66
|
+
[point.x - t, point.y - t],
|
|
67
|
+
[point.x + t, point.y + t],
|
|
68
|
+
];
|
|
69
|
+
const hits = m.queryRenderedFeatures(box, { layers });
|
|
70
|
+
let best: PickedFeature | null = null;
|
|
71
|
+
let bestDistance = Infinity;
|
|
72
|
+
for (const f of hits) {
|
|
73
|
+
if (f.id == null || f.geometry.type !== 'Point') continue;
|
|
74
|
+
const at = f.geometry.coordinates as [number, number];
|
|
75
|
+
const screen = m.project(at);
|
|
76
|
+
const d = (screen.x - point.x) ** 2 + (screen.y - point.y) ** 2;
|
|
77
|
+
if (d >= bestDistance) continue;
|
|
78
|
+
bestDistance = d;
|
|
79
|
+
best = {
|
|
80
|
+
id: f.id,
|
|
81
|
+
source: f.source ?? '',
|
|
82
|
+
sourceLayer: f.sourceLayer ?? undefined,
|
|
83
|
+
at,
|
|
84
|
+
properties: (f.properties ?? {}) as Record<string, unknown>,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function paint(m: MapboxMap) {
|
|
91
|
+
for (const f of painted) m.setFeatureState(stateRef(f), { selected: false });
|
|
92
|
+
painted = [...selectedFeatures.values()];
|
|
93
|
+
for (const f of painted) m.setFeatureState(stateRef(f), { selected: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Feature-state needs the source (and source-layer) an id came from, which an id alone does not carry
|
|
97
|
+
// — so the picked features are kept beside the id set rather than derived from it.
|
|
98
|
+
const selectedFeatures = new Map<string | number, PickedFeature>();
|
|
99
|
+
|
|
100
|
+
function commit(features: PickedFeature[]) {
|
|
101
|
+
selectedFeatures.clear();
|
|
102
|
+
for (const f of features) selectedFeatures.set(f.id, f);
|
|
103
|
+
selected.value = new Set(selectedFeatures.keys());
|
|
104
|
+
focused.value = features.at(-1) ?? null;
|
|
105
|
+
const m = map.value;
|
|
106
|
+
if (m) paint(m);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** click semantics: plain click replaces the set, ⌘/ctrl/shift-click adds to it when `multiple` */
|
|
110
|
+
function onClick(e: MapMouseEvent) {
|
|
111
|
+
const m = map.value;
|
|
112
|
+
if (!m) return;
|
|
113
|
+
const hit = pick(m, e.point);
|
|
114
|
+
const additive =
|
|
115
|
+
!!options().multiple &&
|
|
116
|
+
!!(e.originalEvent.metaKey || e.originalEvent.ctrlKey || e.originalEvent.shiftKey);
|
|
117
|
+
if (!hit) {
|
|
118
|
+
if (!additive) commit([]);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!additive) {
|
|
122
|
+
commit([hit]);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const next = [...selectedFeatures.values()].filter((f) => f.id !== hit.id);
|
|
126
|
+
// ⌘-clicking an already-selected feature removes it, which is what every list in the product does.
|
|
127
|
+
commit(next.length === selectedFeatures.size ? [...next, hit] : next);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Throttled to one frame: pointermove fires far faster than the map paints, and each call is a
|
|
131
|
+
// tile-space query. rAF means at most one query per rendered frame, coalescing the rest.
|
|
132
|
+
function onMove(e: MapMouseEvent) {
|
|
133
|
+
if (!options().hover || frame) return;
|
|
134
|
+
frame = requestAnimationFrame(() => {
|
|
135
|
+
frame = 0;
|
|
136
|
+
const m = map.value;
|
|
137
|
+
if (!m) return;
|
|
138
|
+
const hit = pick(m, e.point);
|
|
139
|
+
if (hit?.id === hoverPainted?.id) return;
|
|
140
|
+
if (hoverPainted) m.setFeatureState(stateRef(hoverPainted), { hover: false });
|
|
141
|
+
hoverPainted = hit;
|
|
142
|
+
hovered.value = hit;
|
|
143
|
+
if (hit) m.setFeatureState(stateRef(hit), { hover: true });
|
|
144
|
+
m.getCanvas().style.cursor = hit ? 'pointer' : '';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function wire(m: MapboxMap) {
|
|
149
|
+
m.on('click', onClick);
|
|
150
|
+
m.on('mousemove', onMove);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
watch(map, (m) => m && wire(m), { immediate: true });
|
|
154
|
+
|
|
155
|
+
// The live-update intersection. A patched source moves and deletes features under the selection, so
|
|
156
|
+
// the picked coordinates are re-read whenever the source re-tiles: a moved bike's marker follows it,
|
|
157
|
+
// and a deleted one drops out of the set instead of leaving a marker over empty tarmac.
|
|
158
|
+
function resync() {
|
|
159
|
+
const m = map.value;
|
|
160
|
+
if (!m || !selectedFeatures.size) return;
|
|
161
|
+
const layers = present(m);
|
|
162
|
+
if (!layers.length) return;
|
|
163
|
+
const live = new Map<string | number, [number, number]>();
|
|
164
|
+
for (const f of m.queryRenderedFeatures({ layers })) {
|
|
165
|
+
if (f.id != null && selectedFeatures.has(f.id) && f.geometry.type === 'Point') {
|
|
166
|
+
live.set(f.id, f.geometry.coordinates as [number, number]);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
let changed = false;
|
|
170
|
+
for (const [id, f] of [...selectedFeatures]) {
|
|
171
|
+
const at = live.get(id);
|
|
172
|
+
// Absent from the rendered set is NOT proof of deletion — it may simply be off screen. Only a
|
|
173
|
+
// feature that is inside the viewport and gone counts, which is the best a client can know.
|
|
174
|
+
if (!at) continue;
|
|
175
|
+
if (at[0] !== f.at[0] || at[1] !== f.at[1]) {
|
|
176
|
+
selectedFeatures.set(id, { ...f, at });
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (changed) commit([...selectedFeatures.values()]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
onScopeDispose(() => {
|
|
184
|
+
if (frame) cancelAnimationFrame(frame);
|
|
185
|
+
const m = map.value;
|
|
186
|
+
if (!m) return;
|
|
187
|
+
m.off('click', onClick);
|
|
188
|
+
m.off('mousemove', onMove);
|
|
189
|
+
if (!m.isStyleLoaded()) return;
|
|
190
|
+
for (const f of painted) m.setFeatureState(stateRef(f), { selected: false });
|
|
191
|
+
if (hoverPainted) m.setFeatureState(stateRef(hoverPainted), { hover: false });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
selected: selected as Ref<Set<string | number>>,
|
|
196
|
+
focused,
|
|
197
|
+
hovered,
|
|
198
|
+
/** how many things a bulk action would act on */
|
|
199
|
+
count: computed(() => selected.value.size),
|
|
200
|
+
/** select by feature — from a list row, a lasso, a zone query */
|
|
201
|
+
set: (features: PickedFeature[]) => commit(features),
|
|
202
|
+
/**
|
|
203
|
+
* Union — shift-click, shift-lasso. Already-selected ids are skipped rather than re-appended, so
|
|
204
|
+
* dragging over the same bikes twice does not reorder the set or move the focus.
|
|
205
|
+
*/
|
|
206
|
+
add: (features: PickedFeature[]) =>
|
|
207
|
+
commit([
|
|
208
|
+
...selectedFeatures.values(),
|
|
209
|
+
...features.filter((f) => !selectedFeatures.has(f.id)),
|
|
210
|
+
]),
|
|
211
|
+
/**
|
|
212
|
+
* Difference — alt-lasso.
|
|
213
|
+
*
|
|
214
|
+
* ★ Focus is PRESERVED when it survives, rather than falling to whatever happens to be last.
|
|
215
|
+
* `commit` focuses the final feature, which is right for a click (the thing just picked) and wrong
|
|
216
|
+
* here: removing three bikes from a set of twenty would slide the open popup onto an unrelated
|
|
217
|
+
* bike the reader never touched. If the focused one was itself removed, nothing is focused.
|
|
218
|
+
*/
|
|
219
|
+
subtract: (ids: Iterable<string | number>) => {
|
|
220
|
+
const drop = new Set(ids);
|
|
221
|
+
const kept = [...selectedFeatures.values()].filter((f) => !drop.has(f.id));
|
|
222
|
+
const survivor = focused.value && !drop.has(focused.value.id) ? focused.value : null;
|
|
223
|
+
commit(kept);
|
|
224
|
+
focused.value = survivor;
|
|
225
|
+
},
|
|
226
|
+
clear: () => commit([]),
|
|
227
|
+
/** re-read the selection's coordinates after a live tick */
|
|
228
|
+
resync,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Tween what useMarkerSource publishes for the viewport, so a vehicle glides between fixes instead of
|
|
2
|
+
// teleporting.
|
|
3
|
+
//
|
|
4
|
+
// ★ DOM MARKERS ONLY, AND THAT IS THE DESIGN, NOT A LIMIT WE RAN INTO. The GL fleet tier keeps
|
|
5
|
+
// snapping: a full setData re-upload measured 222 ms per tick at 30,000 features, which a 2–5 s
|
|
6
|
+
// cadence absorbs and a 16 ms frame does not. Smoothness that costs 222 ms a frame is not smoothness.
|
|
7
|
+
// What this covers is the few dozen markers a viewport actually holds, which is where a glide reads
|
|
8
|
+
// as motion rather than as jitter.
|
|
9
|
+
import { computed, onScopeDispose, shallowRef, watch } from 'vue';
|
|
10
|
+
import { advanceLeg, legAt, type Leg } from './liveMarkers';
|
|
11
|
+
import type { MarkerSourceItem } from './useMarkerSource';
|
|
12
|
+
|
|
13
|
+
export interface LiveMarkerOptions {
|
|
14
|
+
/** the expected interval between fixes, ms — how long one glide lasts. Default 4000 */
|
|
15
|
+
ms?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `items` is what `useMarkerSource` publishes; the return is the same list with `at` interpolated.
|
|
20
|
+
*
|
|
21
|
+
* Feed it straight into the keyed `v-for` of `<FMarker>` that the source was already driving — the
|
|
22
|
+
* keys are unchanged, so nothing remounts and mapbox keeps the same elements.
|
|
23
|
+
*/
|
|
24
|
+
export function useLiveMarkers(
|
|
25
|
+
items: () => MarkerSourceItem[],
|
|
26
|
+
options: () => LiveMarkerOptions = () => ({}),
|
|
27
|
+
) {
|
|
28
|
+
const legs = new Map<string, Leg>();
|
|
29
|
+
const now = shallowRef(0);
|
|
30
|
+
let raf = 0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Someone who asked for less motion gets the position, not the journey. There is no information in
|
|
34
|
+
* the glide that the position does not already carry, which is what makes it safe to drop entirely
|
|
35
|
+
* rather than shorten.
|
|
36
|
+
*/
|
|
37
|
+
const still =
|
|
38
|
+
typeof matchMedia === 'undefined' ? false : matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
39
|
+
|
|
40
|
+
if (!still && typeof requestAnimationFrame !== 'undefined') {
|
|
41
|
+
const frame = (t: number) => {
|
|
42
|
+
now.value = t;
|
|
43
|
+
raf = requestAnimationFrame(frame);
|
|
44
|
+
};
|
|
45
|
+
raf = requestAnimationFrame(frame);
|
|
46
|
+
}
|
|
47
|
+
onScopeDispose(() => {
|
|
48
|
+
if (raf) cancelAnimationFrame(raf);
|
|
49
|
+
raf = 0;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ★ THE BOOKKEEPING LIVES IN A WATCHER, NOT IN THE COMPUTED. Writing to `legs` from inside the
|
|
53
|
+
// computed would make it impure: Vue is free to evaluate a computed more than once, and each extra
|
|
54
|
+
// pass would start another leg from a slightly later position — the marker would decelerate toward
|
|
55
|
+
// its target and never arrive. The watcher runs once per change to the list, which is exactly when
|
|
56
|
+
// a leg should be decided.
|
|
57
|
+
watch(
|
|
58
|
+
items,
|
|
59
|
+
(list) => {
|
|
60
|
+
const ms = options().ms ?? 4_000;
|
|
61
|
+
const t = now.value;
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const item of list) {
|
|
64
|
+
seen.add(item.key);
|
|
65
|
+
legs.set(item.key, advanceLeg(legs.get(item.key), item.at, t, ms));
|
|
66
|
+
}
|
|
67
|
+
// A vehicle that left the viewport must not keep its leg: the key can come back at a completely
|
|
68
|
+
// different place, and a stale leg would glide it across the city on re-entry.
|
|
69
|
+
for (const key of [...legs.keys()]) if (!seen.has(key)) legs.delete(key);
|
|
70
|
+
},
|
|
71
|
+
{ immediate: true },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return computed<MarkerSourceItem[]>(() => {
|
|
75
|
+
const list = items();
|
|
76
|
+
if (still) return list;
|
|
77
|
+
const ms = options().ms ?? 4_000;
|
|
78
|
+
const t = now.value;
|
|
79
|
+
return list.map((item) => {
|
|
80
|
+
const leg = legs.get(item.key);
|
|
81
|
+
return leg ? { ...item, at: legAt(leg, t, ms) } : item;
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|