@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,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>;
|