@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,421 @@
|
|
|
1
|
+
// Sources, layers and basemap config as data.
|
|
2
|
+
//
|
|
3
|
+
// Mapbox's style spec is already declarative JSON — wrapping it in a component per layer would only
|
|
4
|
+
// add a lossy second dialect. What actually needed owning was three things, and this owns exactly
|
|
5
|
+
// those, passing everything else through untouched:
|
|
6
|
+
//
|
|
7
|
+
// • PAINT ORDER IS THE ARRAY, not the mount sequence. Layer order in mapbox is insertion order, so
|
|
8
|
+
// anything that adds and removes layers over their lifetime reshuffles them silently (an 8px
|
|
9
|
+
// casing landing above its 4px line swallows the line). A layer that leaves and comes back is
|
|
10
|
+
// re-inserted before the next declared layer present on the map, so the array always wins.
|
|
11
|
+
// • TOKENS. `token('--ui-primary')` marks a value as token-derived; it is resolved to sRGB on every
|
|
12
|
+
// apply and re-applied when the colour mode changes. GL rejects the display-p3 that
|
|
13
|
+
// getComputedStyle returns for a Forest token, and a hand-rolled re-resolve is where this
|
|
14
|
+
// subsystem has already grown a bug once.
|
|
15
|
+
// • HIDING USES `visibility`, never remove/add — so nothing reshuffles and tiles stay warm.
|
|
16
|
+
//
|
|
17
|
+
// Out of scope on purpose: sprites/addImage, terrain, fog, lights. Those stay on the raw instance
|
|
18
|
+
// until a second consumer asks, so this does not quietly become a second Mapbox API.
|
|
19
|
+
import { onScopeDispose, watch, type ShallowRef } from 'vue';
|
|
20
|
+
import type { GeoJSONSource, Map } from 'mapbox-gl';
|
|
21
|
+
import { resolveMapColor } from './mapColor';
|
|
22
|
+
import { onTokenModeChange } from './useTokenMode';
|
|
23
|
+
|
|
24
|
+
const TOKEN = Symbol('forest-map-token');
|
|
25
|
+
type TokenValue = { [TOKEN]: true; expr: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mark a paint/layout/config value as token-derived, e.g. `token('--ui-primary')`.
|
|
29
|
+
*
|
|
30
|
+
* A bare custom property is wrapped in `var()`: the resolver sets the value as a CSS `color`, and
|
|
31
|
+
* `color: --ui-primary` is invalid — it silently falls back to the inherited text colour, so every
|
|
32
|
+
* layer would come out the same grey.
|
|
33
|
+
*/
|
|
34
|
+
export const token = (expr: string): TokenValue => ({
|
|
35
|
+
[TOKEN]: true,
|
|
36
|
+
expr: expr.startsWith('--') ? `var(${expr})` : expr,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const isToken = (v: unknown): v is TokenValue =>
|
|
40
|
+
!!v && typeof v === 'object' && TOKEN in (v as Record<PropertyKey, unknown>);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WHERE AN OVERLAY LAYER BELONGS, BY WHAT THE LAYER IS.
|
|
44
|
+
*
|
|
45
|
+
* Standard's three slots are fixed positions in the imported stack, and Mapbox documents them as:
|
|
46
|
+
*
|
|
47
|
+
* bottom — above the polygons (land, landuse, water)
|
|
48
|
+
* middle — above the lines (roads) and BEHIND the 3D buildings
|
|
49
|
+
* top — above the POI labels, behind the place and transit labels
|
|
50
|
+
*
|
|
51
|
+
* ★ `middle` IS BEHIND THE 3D BUILDINGS, WHICH IS WHY A MARK MUST NOT LIVE THERE. Standard extrudes
|
|
52
|
+
* buildings, landmarks and trees, so a mark in `middle` is swallowed by scenery the moment an extrusion
|
|
53
|
+
* covers its coordinate — worse as the camera pitches, and worst on the dense streets an operator is
|
|
54
|
+
* most likely to be looking at. A mark is the one thing on the map that can never be hidden: it IS the
|
|
55
|
+
* data. Ground context can be, and should be, so a zone stays in `bottom`.
|
|
56
|
+
*
|
|
57
|
+
* A DOM `<FMarker>` is not affected: it is an element over the canvas, not a GL layer, so no basemap
|
|
58
|
+
* layer can cover it. Mapbox only fades a DOM marker for 3D TERRAIN (`occludedOpacity`, default 0.2),
|
|
59
|
+
* which Forest does not enable — buildings never fade one.
|
|
60
|
+
*
|
|
61
|
+
* ★ `top` STILL SITS UNDER THE PLACE AND TRANSIT LABELS, so a street name prints over a mark there. That
|
|
62
|
+
* is a second problem with its own three answers — see `MARK_CLAIMS_SPACE` below.
|
|
63
|
+
*
|
|
64
|
+
* A pitched ops map that wants no scenery at all sets `config: { show3dObjects: false }` instead.
|
|
65
|
+
*/
|
|
66
|
+
export const MAP_SLOT = {
|
|
67
|
+
/** zones and other ground context — under the basemap's labels and under every mark */
|
|
68
|
+
zone: 'bottom',
|
|
69
|
+
/** routes and anything else that belongs to the road surface */
|
|
70
|
+
route: 'middle',
|
|
71
|
+
/** every mark: vehicles, places, counted bays, clusters, selection rings */
|
|
72
|
+
mark: 'top',
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A MARK THAT A BASEMAP LABEL MUST NOT BE PRINTED OVER.
|
|
77
|
+
*
|
|
78
|
+
* Three levers exist, and they are not alternatives to each other — they answer different questions:
|
|
79
|
+
*
|
|
80
|
+
* 1. MAKE THE LABEL YIELD (this constant). `icon-allow-overlap: true` keeps marks from hiding each other;
|
|
81
|
+
* `icon-ignore-placement: FALSE` is the part that matters — it puts each mark's box in the collision
|
|
82
|
+
* index, so a basemap label that collides with a mark is DROPPED rather than drawn on top of it. The
|
|
83
|
+
* layer must also be UNSLOTTED — omit `slot` — for two reasons: an unslotted layer paints above every
|
|
84
|
+
* layer in the style, and placement runs TOP-DOWN, so the topmost layer claims its space first and
|
|
85
|
+
* wins the collision (measured in mapbox-gl 3.24.1: `PauseablePlacement.continuePlacement` starts at
|
|
86
|
+
* the last index of the layer order and decrements). In `top` the label layers are above the marks,
|
|
87
|
+
* so they are placed first and the mark loses — the flag alone changes nothing.
|
|
88
|
+
* ⚠ Unslotted is only sound in mercator. Under the globe projection an unslotted layer lands BELOW
|
|
89
|
+
* the labels, so the order swaps silently at world zooms; a city map never reaches them.
|
|
90
|
+
* ⚠ NOT MEASURED AT FLEET SCALE. Entering the collision index costs per symbol, so the 30k tier keeps
|
|
91
|
+
* `FLEET_SYMBOL_LAYOUT` (which ignores placement) until this is measured on a real fleet.
|
|
92
|
+
* ⚠⚠ THE DROP ITSELF IS NOT CONFIRMED ACROSS A STYLE IMPORT. Verified live: the layer does land
|
|
93
|
+
* unslotted with `icon-ignore-placement: false`. What is NOT verified is that a colliding basemap
|
|
94
|
+
* label then disappears — one probe on the demo map showed the same POI rendering with the flag
|
|
95
|
+
* either way, which would mean placement does not collide across the import boundary. The paint-order
|
|
96
|
+
* half is certain (unslotted paints above the labels, so nothing prints THROUGH a mark); the yield
|
|
97
|
+
* half needs a clean A/B on a mark sitting on a label. Levers 2 and 3 do not depend on it.
|
|
98
|
+
*
|
|
99
|
+
* 2. TURN THE FAMILY OFF — `config: { showPlaceLabels: false, showPointOfInterestLabels: false }` on
|
|
100
|
+
* `<FMap>`. Free, and the right answer for a dense console where a label would lose every collision
|
|
101
|
+
* anyway. It costs the reader their wayfinding, so it is a surface-level decision, not a default.
|
|
102
|
+
*
|
|
103
|
+
* 3. HIDE ONE LABEL — Standard's `poi` and `place-labels` featuresets support a `hide` feature state,
|
|
104
|
+
* documented for exactly this ("when displaying a custom annotation in place of the hidden symbol"):
|
|
105
|
+
* const [f] = map.queryRenderedFeatures({ target: { featuresetId: 'place-labels', importId: 'basemap' } })
|
|
106
|
+
* map.setFeatureState(f, { hide: true })
|
|
107
|
+
* A scalpel for a surface that replaces a specific basemap symbol with its own mark — a depot standing
|
|
108
|
+
* where the basemap draws a POI — not a way to keep marks clear in general.
|
|
109
|
+
*/
|
|
110
|
+
export const MARK_CLAIMS_SPACE = {
|
|
111
|
+
'icon-allow-overlap': true,
|
|
112
|
+
'icon-ignore-placement': false,
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
export interface OverlayLayer {
|
|
116
|
+
id: string;
|
|
117
|
+
type: string;
|
|
118
|
+
source?: string;
|
|
119
|
+
/** `MAP_SLOT.zone` / `.route` / `.mark` — see MAP_SLOT for what each slot sits above and below */
|
|
120
|
+
slot?: 'bottom' | 'middle' | 'top';
|
|
121
|
+
/**
|
|
122
|
+
* Insert before this existing layer id. For styles with no Standard import, which have no slots:
|
|
123
|
+
* ordering there has to name a layer. Within the overlay's own layers the ARRAY still decides
|
|
124
|
+
* order, so this is only for placing the overlay relative to the style underneath it. Mapbox
|
|
125
|
+
* rejects a beforeId in a different slot, so do not mix the two on one layer.
|
|
126
|
+
*/
|
|
127
|
+
beforeId?: string;
|
|
128
|
+
/** false hides via `visibility` — the layer stays in place */
|
|
129
|
+
visible?: boolean;
|
|
130
|
+
paint?: Record<string, unknown>;
|
|
131
|
+
layout?: Record<string, unknown>;
|
|
132
|
+
/** filter, minzoom, source-layer, … straight through to the style spec */
|
|
133
|
+
[key: string]: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A style-spec source object, plus the two keys the live-fleet path needs.
|
|
138
|
+
*
|
|
139
|
+
* The whole-collection path (`data`) re-uploads and re-tiles everything on every change, which is the
|
|
140
|
+
* right thing for a route or a zone and the wrong thing for 30,000 vehicles ticking every few seconds.
|
|
141
|
+
* `dynamic: true` plus a `patch` diff upserts BY FEATURE ID instead: mapbox touches only the tiles the
|
|
142
|
+
* moved features live in. A `null` geometry in the diff's `update` list deletes that feature.
|
|
143
|
+
*
|
|
144
|
+
* The id contract is `promoteId`: without it a GeoJSON feature has no stable id and there is nothing
|
|
145
|
+
* for a patch to address, so a dynamic source without `promoteId` (or feature-level `id`s) silently
|
|
146
|
+
* does nothing.
|
|
147
|
+
*/
|
|
148
|
+
export interface OverlaySource extends Record<string, unknown> {
|
|
149
|
+
/** the whole collection. The initial upload, and the only path for a non-dynamic source */
|
|
150
|
+
data?: unknown;
|
|
151
|
+
/** opt into patch-by-id. Required for `patch` to have any effect */
|
|
152
|
+
dynamic?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* a GeoJSONSourceDiff — `{ add, update, remove }`. Applied when its identity changes, so hand over a
|
|
155
|
+
* NEW object per tick (the opposite of `data`, which must stay stable).
|
|
156
|
+
*/
|
|
157
|
+
patch?: unknown;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface OverlaySpec {
|
|
161
|
+
/**
|
|
162
|
+
* style-spec source objects, keyed by id. Keep `data` referentially stable (a const or a
|
|
163
|
+
* computed) — a fresh object every read re-uploads the data on every apply.
|
|
164
|
+
*
|
|
165
|
+
* A source that changes on a tick rather than on a user action takes two extra keys instead:
|
|
166
|
+
* `dynamic: true` and an `update` diff. See the note on `patch` below.
|
|
167
|
+
*/
|
|
168
|
+
sources?: Record<string, OverlaySource>;
|
|
169
|
+
layers?: OverlayLayer[];
|
|
170
|
+
/** basemap config, e.g. { basemap: { colorBuildingSelect: token('--ui-primary') } } */
|
|
171
|
+
config?: Record<string, Record<string, unknown>>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Tokens can sit anywhere, including inside an expression: ['case', c, token('--a'), token('--b')].
|
|
175
|
+
function resolve(value: unknown): unknown {
|
|
176
|
+
if (isToken(value)) return resolveMapColor(value.expr);
|
|
177
|
+
if (Array.isArray(value)) return value.map(resolve);
|
|
178
|
+
if (value && typeof value === 'object') {
|
|
179
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolve(v)]));
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hasToken(value: unknown): boolean {
|
|
185
|
+
if (isToken(value)) return true;
|
|
186
|
+
if (Array.isArray(value)) return value.some(hasToken);
|
|
187
|
+
if (value && typeof value === 'object') return Object.values(value).some(hasToken);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* ★ EMISSIVE STRENGTH, APPLIED FOR EVERY OVERLAY THAT NEEDS IT.
|
|
193
|
+
*
|
|
194
|
+
* The Forest style is Standard-based and carries 3D lights. `circle`, `fill` and `line` default to
|
|
195
|
+
* `*-emissive-strength: 0`, which means the renderer SHADES them with the scene lighting — and the night
|
|
196
|
+
* preset's ambient light is `hsl(217,100%,11%)` at intensity 0.5, a near-black blue. Measured on the published
|
|
197
|
+
* style at gl 3.24.1:
|
|
198
|
+
*
|
|
199
|
+
* a circle told to paint rgb(0,164,71) day rgb(0,163,71) ΔE 1
|
|
200
|
+
* dusk rgb(0, 44,25) ΔE 129
|
|
201
|
+
* night rgb(0, 32,20) ΔE 142
|
|
202
|
+
*
|
|
203
|
+
* At night an unlit overlay paints at about a fifth of its intended luminance. Every ΔE the palette work spent
|
|
204
|
+
* on separating one state from another is thrown away by the renderer, silently, and only after dark.
|
|
205
|
+
*
|
|
206
|
+
* ⚠ SYMBOL LAYERS ARE NOT AFFECTED. Measured: `icon` and `text` paint their exact colour at night with the
|
|
207
|
+
* property unset. So the vehicle marker was never the problem, and only the three shaded types need this.
|
|
208
|
+
*
|
|
209
|
+
* It is injected here rather than published as a constant because a constant is a thing to remember. Every
|
|
210
|
+
* Forest overlay goes through this seam, and a caller that sets the property itself keeps its own value.
|
|
211
|
+
*/
|
|
212
|
+
const SHADED: Record<string, string> = {
|
|
213
|
+
circle: 'circle-emissive-strength',
|
|
214
|
+
fill: 'fill-emissive-strength',
|
|
215
|
+
line: 'line-emissive-strength',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** the value: paint the colour that was asked for, and let the basemap be the thing that is lit */
|
|
219
|
+
export const OVERLAY_EMISSIVE = 1;
|
|
220
|
+
|
|
221
|
+
function withEmissive<T extends { type?: string; paint?: Record<string, unknown> }>(layer: T): T {
|
|
222
|
+
const key = layer.type ? SHADED[layer.type] : undefined;
|
|
223
|
+
if (!key) return layer;
|
|
224
|
+
const paint = layer.paint ?? {};
|
|
225
|
+
if (key in paint) return layer; // a caller that set it deliberately wins
|
|
226
|
+
return { ...layer, paint: { ...paint, [key]: OVERLAY_EMISSIVE } };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Snapshot for diffing. Values are kept by reference on purpose: that is how `data` changes and
|
|
230
|
+
// changed paint values are detected.
|
|
231
|
+
function snapshot(spec: OverlaySpec): OverlaySpec {
|
|
232
|
+
return {
|
|
233
|
+
sources: { ...(spec.sources ?? {}) },
|
|
234
|
+
// ★ Emissive strength is added HERE, in the snapshot, so the diff sees it too — added after the diff it
|
|
235
|
+
// would look like an external change on every tick and re-set the paint property forever.
|
|
236
|
+
layers: (spec.layers ?? []).map((l) => {
|
|
237
|
+
const withEm = withEmissive(l as { type?: string; paint?: Record<string, unknown> }) as typeof l;
|
|
238
|
+
return { ...withEm, paint: { ...(withEm.paint ?? {}) }, layout: { ...(withEm.layout ?? {}) } };
|
|
239
|
+
}),
|
|
240
|
+
config: spec.config,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function useMapOverlay(map: ShallowRef<Map | null>, spec: () => OverlaySpec) {
|
|
245
|
+
let applied: OverlaySpec | null = null;
|
|
246
|
+
const ours = { sources: new Set<string>(), layers: new Set<string>() };
|
|
247
|
+
const wired = new WeakSet<Map>();
|
|
248
|
+
|
|
249
|
+
function apply(m: Map, next: OverlaySpec) {
|
|
250
|
+
// Mapbox types paint/layout keys as a union of every known property name. Spec objects here are
|
|
251
|
+
// authored freely, so the style parser validates them at runtime — these aliases keep the call
|
|
252
|
+
// sites readable instead of casting at each one.
|
|
253
|
+
const setPaint = m.setPaintProperty.bind(m) as (id: string, key: string, value: unknown) => void;
|
|
254
|
+
const setLayout = m.setLayoutProperty.bind(m) as (id: string, key: string, value: unknown) => void;
|
|
255
|
+
|
|
256
|
+
// ── sources ──
|
|
257
|
+
for (const [id, src] of Object.entries(next.sources ?? {})) {
|
|
258
|
+
const existing = m.getSource(id);
|
|
259
|
+
if (!existing) {
|
|
260
|
+
// `patch` is ours, not the style spec's — mapbox would reject the unknown key.
|
|
261
|
+
const { patch: _patch, ...definition } = src;
|
|
262
|
+
m.addSource(id, resolve(definition) as never);
|
|
263
|
+
ours.sources.add(id);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const prev = applied?.sources?.[id];
|
|
267
|
+
const gj = existing as GeoJSONSource;
|
|
268
|
+
// A patch beats a whole-collection upload: it is the cheaper of the two and the one a tick wants.
|
|
269
|
+
if (src.dynamic && src.patch && src.patch !== prev?.patch && 'updateData' in gj) {
|
|
270
|
+
gj.updateData(src.patch as never);
|
|
271
|
+
} else if (src.data !== prev?.data && 'setData' in gj) {
|
|
272
|
+
gj.setData(src.data as never);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── layers, in declared order ──
|
|
277
|
+
// ★ Emissive strength is injected HERE as well as in `snapshot()`, and it has to be both: this is the
|
|
278
|
+
// path that reaches addLayer, and the snapshot is the path the diff compares against. In one only, either
|
|
279
|
+
// the property never lands or it is re-set on every tick forever.
|
|
280
|
+
const declared = (next.layers ?? []).map((l) =>
|
|
281
|
+
withEmissive(l as { type?: string; paint?: Record<string, unknown> }),
|
|
282
|
+
) as typeof next.layers & object[];
|
|
283
|
+
declared.forEach((layer, i) => {
|
|
284
|
+
const { visible = true, paint, layout, beforeId, ...rest } = layer;
|
|
285
|
+
if (!m.getLayer(layer.id)) {
|
|
286
|
+
// Insert before the next declared layer already on the map, IN THE SAME SLOT: a re-added
|
|
287
|
+
// layer lands where the array says it belongs, and mapbox rejects a beforeId from a
|
|
288
|
+
// different slot. `beforeId` is the fallback for a style with no slots — the array still
|
|
289
|
+
// orders the overlay against itself, so it only decides where the whole overlay sits.
|
|
290
|
+
const before =
|
|
291
|
+
declared.slice(i + 1).find((l) => (l.slot ?? null) === (layer.slot ?? null) && m.getLayer(l.id))?.id ??
|
|
292
|
+
(beforeId && m.getLayer(beforeId) ? beforeId : undefined);
|
|
293
|
+
// `paint`/`layout` are only passed when present — the style validator rejects the keys set to
|
|
294
|
+
// undefined ("object expected, undefined found"), which silently drops the whole layer.
|
|
295
|
+
const definition: Record<string, unknown> = { ...rest };
|
|
296
|
+
if (paint) definition.paint = resolve(paint);
|
|
297
|
+
if (layout) definition.layout = resolve(layout);
|
|
298
|
+
m.addLayer(definition as never, before);
|
|
299
|
+
// ★ addLayer does NOT throw on an invalid paint expression — mapbox validates, rejects, and
|
|
300
|
+
// says nothing. The layer is simply absent and the map looks subtly wrong forever. The classic
|
|
301
|
+
// cause is a zoom `interpolate` nested inside arithmetic, which is only legal at the top level
|
|
302
|
+
// of a property. Cheap to check, and it turns hours of confusion into one line.
|
|
303
|
+
if (import.meta.env?.DEV && !m.getLayer(layer.id)) {
|
|
304
|
+
console.warn(
|
|
305
|
+
`[useMapOverlay] mapbox rejected layer "${layer.id}" without raising. Usual cause: a zoom ` +
|
|
306
|
+
'expression nested inside another expression — `interpolate`/`step` on ["zoom"] is only ' +
|
|
307
|
+
'valid as the whole value of a paint or layout property.',
|
|
308
|
+
definition,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
ours.layers.add(layer.id);
|
|
312
|
+
} else {
|
|
313
|
+
const prev = applied?.layers?.find((l) => l.id === layer.id);
|
|
314
|
+
// Token values are re-set every apply (the mode may have flipped); plain values only change.
|
|
315
|
+
for (const [k, v] of Object.entries(paint ?? {})) {
|
|
316
|
+
if (hasToken(v) || v !== prev?.paint?.[k]) setPaint(layer.id, k, resolve(v));
|
|
317
|
+
}
|
|
318
|
+
for (const [k, v] of Object.entries(layout ?? {})) {
|
|
319
|
+
if (hasToken(v) || v !== prev?.layout?.[k]) setLayout(layer.id, k, resolve(v));
|
|
320
|
+
}
|
|
321
|
+
// ★ THE FILTER IS NOT PAINT, and it was silently frozen. A layer's filter is read once at
|
|
322
|
+
// addLayer and never again, so an overlay whose spec narrowed — a filter chip, an audience
|
|
323
|
+
// switch — kept drawing the whole source while every count beside it reported the filtered
|
|
324
|
+
// number. Measured on /maps/scale: the expression said three damage states, the header said
|
|
325
|
+
// 1,496, and both layers still carried `['literal', true]`.
|
|
326
|
+
//
|
|
327
|
+
// Compared by VALUE, not identity: the expression is rebuilt into a new array on every filter
|
|
328
|
+
// edit, so an identity check re-sets it on every apply — and setFilter is the expensive call
|
|
329
|
+
// here (measured 325 ms to re-bucket 30,000 features), which is exactly what must not run on
|
|
330
|
+
// a tick that changed nothing.
|
|
331
|
+
const nextFilter = (rest as { filter?: unknown }).filter ?? null;
|
|
332
|
+
if (JSON.stringify(nextFilter) !== JSON.stringify(prev?.filter ?? null)) {
|
|
333
|
+
m.setFilter(layer.id, nextFilter as never);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Guarded: if a layer failed to add, mapbox throws on any property call against it.
|
|
337
|
+
if (m.getLayer(layer.id)) setLayout(layer.id, 'visibility', visible ? 'visible' : 'none');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── drop what the spec no longer declares (layers first: a source with layers cannot go) ──
|
|
341
|
+
for (const id of [...ours.layers]) {
|
|
342
|
+
if (declared.some((l) => l.id === id)) continue;
|
|
343
|
+
if (m.getLayer(id)) m.removeLayer(id);
|
|
344
|
+
ours.layers.delete(id);
|
|
345
|
+
}
|
|
346
|
+
for (const id of [...ours.sources]) {
|
|
347
|
+
if (id in (next.sources ?? {})) continue;
|
|
348
|
+
if (m.getSource(id)) m.removeSource(id);
|
|
349
|
+
ours.sources.delete(id);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── basemap config (token-aware too: colorBuildingSelect and friends) ──
|
|
353
|
+
for (const [importId, props] of Object.entries(next.config ?? {})) {
|
|
354
|
+
for (const [k, v] of Object.entries(props)) m.setConfigProperty(importId, k, resolve(v));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
applied = snapshot(next);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let waiting = false;
|
|
361
|
+
|
|
362
|
+
function run() {
|
|
363
|
+
const m = map.value;
|
|
364
|
+
if (!m) return;
|
|
365
|
+
// ★ Never wait on `style.load` to catch up. A surface typically hands over the map INSIDE its own
|
|
366
|
+
// style.load handler (that is when <FMap> emits `ready`), and one microtask later
|
|
367
|
+
// isStyleLoaded() can still be false because the Standard style is an import that has not
|
|
368
|
+
// settled — so waiting for style.load again waits for an event that has already fired, forever.
|
|
369
|
+
// `idle` always comes.
|
|
370
|
+
if (!m.isStyleLoaded()) {
|
|
371
|
+
if (waiting) return;
|
|
372
|
+
waiting = true;
|
|
373
|
+
// Two triggers on purpose: `styledata` fires as the style settles without needing a frame (so
|
|
374
|
+
// this still applies in a hidden tab, which never paints), `idle` is the backstop once the map
|
|
375
|
+
// is actually rendering. Whichever lands first re-runs, and the guard keeps them from stacking.
|
|
376
|
+
const retry = () => {
|
|
377
|
+
if (!waiting) return;
|
|
378
|
+
waiting = false;
|
|
379
|
+
run();
|
|
380
|
+
};
|
|
381
|
+
m.once('styledata', retry);
|
|
382
|
+
m.once('idle', retry);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
apply(m, spec());
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
watch(
|
|
389
|
+
map,
|
|
390
|
+
(m) => {
|
|
391
|
+
if (!m) return;
|
|
392
|
+
applied = null;
|
|
393
|
+
run();
|
|
394
|
+
// A later setStyle() wipes our layers — re-apply from scratch when a new style lands.
|
|
395
|
+
if (!wired.has(m)) {
|
|
396
|
+
wired.add(m);
|
|
397
|
+
m.on('style.load', () => {
|
|
398
|
+
applied = null;
|
|
399
|
+
run();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{ immediate: true },
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
watch(spec, run, { deep: true });
|
|
407
|
+
|
|
408
|
+
// Colour mode: observe the class LANDING on <html> rather than guessing a frame after a Vue watcher.
|
|
409
|
+
// See useTokenMode for why a watcher goes one mode behind.
|
|
410
|
+
const stopModeWatch = onTokenModeChange(run);
|
|
411
|
+
|
|
412
|
+
onScopeDispose(() => {
|
|
413
|
+
stopModeWatch();
|
|
414
|
+
const m = map.value;
|
|
415
|
+
if (!m || !m.isStyleLoaded()) return;
|
|
416
|
+
for (const id of ours.layers) if (m.getLayer(id)) m.removeLayer(id);
|
|
417
|
+
for (const id of ours.sources) if (m.getSource(id)) m.removeSource(id);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return { refresh: run };
|
|
421
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The map's selection and the table's, as one thing.
|
|
2
|
+
//
|
|
3
|
+
// Bind `rowSelection` to `<UTable v-model:row-selection>` and clicking a bike ticks its row; ticking a
|
|
4
|
+
// row lights the bike. Neither side owns the selection — `useFeatureSelect` does, and this is a view
|
|
5
|
+
// onto it.
|
|
6
|
+
//
|
|
7
|
+
// ★ A ROW CANNOT BECOME A MAP SELECTION ON ITS OWN. `setFeatureState` addresses a feature by (source,
|
|
8
|
+
// sourceLayer?, id), and a table row carries none of that. So the caller supplies `toFeature(row)`.
|
|
9
|
+
// That is honest rather than awkward — the page has the data — and it is why this is a bridge rather
|
|
10
|
+
// than a two-line computed.
|
|
11
|
+
import { computed, type ComputedRef, type Ref, type ShallowRef, type WritableComputedRef } from 'vue';
|
|
12
|
+
import type { PickedFeature } from './useFeatureSelect';
|
|
13
|
+
import { rowKey, sameSelection, selectedKeys, toRowSelection, type RowSelection } from './selectionModel';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The part of `useFeatureSelect` this needs. Structural, so the real return value satisfies it — and
|
|
17
|
+
* so a test can drive the bridge without a map, a canvas or WebGL.
|
|
18
|
+
*/
|
|
19
|
+
export interface FeatureSelectionApi {
|
|
20
|
+
selected: Ref<Set<string | number>>;
|
|
21
|
+
focused: ShallowRef<PickedFeature | null>;
|
|
22
|
+
set: (features: PickedFeature[]) => void;
|
|
23
|
+
clear: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MapTableSelectionOptions<Row> {
|
|
27
|
+
/** the rows the table is showing — a `useVisibleFeatures` list, usually */
|
|
28
|
+
rows: () => readonly Row[];
|
|
29
|
+
/** ★ must return exactly what the table's `get-row-id` returns, or the two key by different things */
|
|
30
|
+
rowId: (row: Row) => string | number;
|
|
31
|
+
/** how to address this row's feature on the map: id, source, and where it is */
|
|
32
|
+
toFeature: (row: Row) => PickedFeature;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useMapTableSelection<Row>(
|
|
36
|
+
selection: FeatureSelectionApi,
|
|
37
|
+
options: MapTableSelectionOptions<Row>,
|
|
38
|
+
): {
|
|
39
|
+
rowSelection: WritableComputedRef<RowSelection>;
|
|
40
|
+
focusedRow: ComputedRef<Row | null>;
|
|
41
|
+
isSelected: (row: Row) => boolean;
|
|
42
|
+
selectRow: (row: Row) => void;
|
|
43
|
+
} {
|
|
44
|
+
const byKey = () => {
|
|
45
|
+
const index = new Map<string, Row>();
|
|
46
|
+
for (const row of options.rows()) index.set(rowKey(options.rowId(row)), row);
|
|
47
|
+
return index;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Normalised once per selection change rather than per row: `isSelected` is called inside a render
|
|
51
|
+
// loop, and re-deriving the keys inside it would make painting a list quadratic in its own length.
|
|
52
|
+
const selectedKeySet = computed(() => {
|
|
53
|
+
const out = new Set<string>();
|
|
54
|
+
for (const id of selection.selected.value) out.add(rowKey(id));
|
|
55
|
+
return out;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const rowSelection = computed<RowSelection>({
|
|
59
|
+
get: () => toRowSelection(selection.selected.value),
|
|
60
|
+
set(next) {
|
|
61
|
+
// The guard. Writing back a record that already matches would re-enter through `selected`, and
|
|
62
|
+
// the two would push each other around for as long as Vue kept flushing.
|
|
63
|
+
const current = selection.selected.value;
|
|
64
|
+
if (sameSelection(current, next)) return;
|
|
65
|
+
// ★ THE ORDER DECIDES THE FOCUS, and the obvious order is the wrong one. `useFeatureSelect.set`
|
|
66
|
+
// focuses the LAST feature it is handed, and `Object.keys` on a record whose keys look like
|
|
67
|
+
// integers comes back in ascending NUMERIC order rather than insertion order — so with 4211
|
|
68
|
+
// selected, ticking row 87 left the callout on 4211, and unticking 4211 slid it onto 87, a row
|
|
69
|
+
// the reader never touched. Keys already selected keep the order they had; whatever the table
|
|
70
|
+
// just added goes last, so the callout lands on the row the reader actually ticked.
|
|
71
|
+
const wanted = new Set(selectedKeys(next));
|
|
72
|
+
const kept = [...current].map(rowKey).filter((key) => wanted.has(key));
|
|
73
|
+
const keptSet = new Set(kept);
|
|
74
|
+
const added = [...wanted].filter((key) => !keptSet.has(key));
|
|
75
|
+
const index = byKey();
|
|
76
|
+
const features: PickedFeature[] = [];
|
|
77
|
+
for (const key of [...kept, ...added]) {
|
|
78
|
+
// A key with no row is a row the table has since dropped — a filter change, a page turn. It is
|
|
79
|
+
// skipped rather than treated as an error, because the alternative is a selection that cannot
|
|
80
|
+
// be cleared.
|
|
81
|
+
const row = index.get(key);
|
|
82
|
+
if (row) features.push(options.toFeature(row));
|
|
83
|
+
}
|
|
84
|
+
selection.set(features);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
rowSelection,
|
|
90
|
+
/** the row behind the one open callout, when it is on this page of rows */
|
|
91
|
+
focusedRow: computed(() => {
|
|
92
|
+
const id = selection.focused.value?.id;
|
|
93
|
+
if (id == null) return null;
|
|
94
|
+
return byKey().get(rowKey(id)) ?? null;
|
|
95
|
+
}),
|
|
96
|
+
/** for a list that is not a UTable. Normalised, so a numeric feature id matches a string row id */
|
|
97
|
+
isSelected: (row: Row) => selectedKeySet.value.has(rowKey(options.rowId(row))),
|
|
98
|
+
/** a plain click on a row: this one, and nothing else */
|
|
99
|
+
selectRow: (row: Row) => selection.set([options.toFeature(row)]),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The camera in the address bar — read once, written on every settle, and never pushed.
|
|
2
|
+
//
|
|
3
|
+
// ★ NOT `hash: true`. GL JS ships a hash sync and it cannot be used in a Vue app: it writes
|
|
4
|
+
// `location.hash` directly, which the router also owns, and it carries the camera and nothing else, so
|
|
5
|
+
// a filter cannot travel with the link. This writes ONE query parameter through `replaceState`,
|
|
6
|
+
// alongside whatever else the page keeps in the URL.
|
|
7
|
+
import { nextTick, onScopeDispose, ref, shallowRef, watch, type ShallowRef } from 'vue';
|
|
8
|
+
import type { Map } from 'mapbox-gl';
|
|
9
|
+
import { decodeCamera, encodeCamera, patchQuery, readQuery, type MapCamera } from './mapUrl';
|
|
10
|
+
|
|
11
|
+
/** Where the URL lives. Swapped for a router in an app that has one, and for a fake in a test. */
|
|
12
|
+
export interface MapUrlAdapter {
|
|
13
|
+
/** the current query string, `?` and all */
|
|
14
|
+
read(): string;
|
|
15
|
+
/** put this query string in place of it — WITHOUT adding a history entry */
|
|
16
|
+
replace(search: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MapUrlStateOptions {
|
|
20
|
+
/** the query key the camera takes. Give each map on a page its own */
|
|
21
|
+
param?: string;
|
|
22
|
+
/** anything else that belongs in the link — a filter, a selected id. `null` removes the key */
|
|
23
|
+
state?: () => Record<string, string | null | undefined>;
|
|
24
|
+
/**
|
|
25
|
+
* The other parameters, handed back once, when the map arrives. The camera has already been applied
|
|
26
|
+
* by then; this is where a surface restores its own filter from the link.
|
|
27
|
+
*
|
|
28
|
+
* ★ RETURN THE PROMISE if the restore is asynchronous — a fetch, a store action, anything that lands
|
|
29
|
+
* on a later tick. Writing is suppressed until it settles, and without it the first write happens
|
|
30
|
+
* while the surface is still restoring: `state()` still answers `null` for the key it is about to
|
|
31
|
+
* fill, and the parameter is erased from the very link it came from.
|
|
32
|
+
*/
|
|
33
|
+
onRestore?: (params: Record<string, string>, camera: MapCamera | null) => void | Promise<unknown>;
|
|
34
|
+
adapter?: MapUrlAdapter;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The default: the real address bar.
|
|
39
|
+
*
|
|
40
|
+
* ★ `history.state` is passed straight back through. vue-router keeps its own object there — scroll
|
|
41
|
+
* position, the navigation's place in the stack — and replacing it with `null` breaks the router's
|
|
42
|
+
* back button in a way that shows up two pages later and looks like anything but a map bug.
|
|
43
|
+
*/
|
|
44
|
+
function browserUrl(): MapUrlAdapter {
|
|
45
|
+
return {
|
|
46
|
+
read: () => (typeof window === 'undefined' ? '' : window.location.search),
|
|
47
|
+
replace(search: string) {
|
|
48
|
+
if (typeof window === 'undefined') return;
|
|
49
|
+
const { pathname, hash } = window.location;
|
|
50
|
+
window.history.replaceState(window.history.state, '', `${pathname}${search}${hash}`);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useMapUrlState(map: ShallowRef<Map | null>, options: MapUrlStateOptions = {}) {
|
|
56
|
+
const key = options.param ?? 'map';
|
|
57
|
+
const adapter = options.adapter ?? browserUrl();
|
|
58
|
+
/** the full link, for a copy button. Tracks the camera, so it is always what the reader sees */
|
|
59
|
+
const href = ref('');
|
|
60
|
+
/** the camera that came out of the link, or null if there was none. For a "shared view" hint */
|
|
61
|
+
const restored = shallowRef<MapCamera | null>(null);
|
|
62
|
+
|
|
63
|
+
// Writes are suppressed until the restore has settled. Without this a surface that applies the
|
|
64
|
+
// link's filter asynchronously — a watcher, a fetch — gets one `moveend` from the restoring jump
|
|
65
|
+
// FIRST, and that write erases the very filter it is still applying.
|
|
66
|
+
let live = false;
|
|
67
|
+
let wired: Map | null = null;
|
|
68
|
+
|
|
69
|
+
const cameraOf = (m: Map): MapCamera => {
|
|
70
|
+
const c = m.getCenter();
|
|
71
|
+
return { center: [c.lng, c.lat], zoom: m.getZoom(), bearing: m.getBearing(), pitch: m.getPitch() };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function write() {
|
|
75
|
+
const m = map.value;
|
|
76
|
+
if (!m || !live) return;
|
|
77
|
+
const search = patchQuery(adapter.read(), {
|
|
78
|
+
...(options.state?.() ?? {}),
|
|
79
|
+
[key]: encodeCamera(cameraOf(m)),
|
|
80
|
+
});
|
|
81
|
+
adapter.replace(search);
|
|
82
|
+
href.value =
|
|
83
|
+
typeof window === 'undefined'
|
|
84
|
+
? search
|
|
85
|
+
: `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ★ NOT `useMapReady`. That one INJECTS the enclosing map and takes no instance, which is right for
|
|
89
|
+
// a component nested inside <FMap> and wrong here — this takes the ref, like every other composable
|
|
90
|
+
// in this directory, so it works from a page that holds the map itself.
|
|
91
|
+
//
|
|
92
|
+
// ★ And not `style.load` either. The camera needs the instance, not the style, and restoring before
|
|
93
|
+
// the first tiles arrive is what makes a shared link open ON the shared view rather than panning to
|
|
94
|
+
// it in front of the reader.
|
|
95
|
+
function restore(m: Map) {
|
|
96
|
+
const params = readQuery(adapter.read());
|
|
97
|
+
const camera = decodeCamera(params[key]);
|
|
98
|
+
restored.value = camera;
|
|
99
|
+
// ★ `jumpTo`, never `flyTo`. An opening flight from the default view to the shared one is
|
|
100
|
+
// animation nobody asked for, and each frame of it would report a camera to `moveend` — the link
|
|
101
|
+
// being restored would be overwritten by the journey towards it.
|
|
102
|
+
if (camera) m.jumpTo(camera);
|
|
103
|
+
|
|
104
|
+
const rest = { ...params };
|
|
105
|
+
delete rest[key];
|
|
106
|
+
const restoring = options.onRestore?.(rest, camera);
|
|
107
|
+
|
|
108
|
+
m.on('moveend', write);
|
|
109
|
+
wired = m;
|
|
110
|
+
// Settle, THEN write. `wired === m` because the map can be swapped out while an asynchronous
|
|
111
|
+
// restore is still in flight, and enabling writes for an instance nobody is looking at would put
|
|
112
|
+
// its stale camera in the address bar.
|
|
113
|
+
const enable = () => {
|
|
114
|
+
if (wired !== m) return;
|
|
115
|
+
live = true;
|
|
116
|
+
write();
|
|
117
|
+
};
|
|
118
|
+
// A synchronous restore keeps the one-tick timing it always had; only a caller that returns a
|
|
119
|
+
// promise pays for the extra hops. Adding them unconditionally would move the first write for
|
|
120
|
+
// every surface, which is the kind of change that breaks something two pages away.
|
|
121
|
+
if (typeof (restoring as Promise<unknown> | undefined)?.then === 'function') {
|
|
122
|
+
void (restoring as Promise<unknown>).then(() => nextTick()).then(enable);
|
|
123
|
+
} else {
|
|
124
|
+
void nextTick(enable);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
watch(
|
|
129
|
+
map,
|
|
130
|
+
(m) => {
|
|
131
|
+
// ★ A map is not for life. <FMap> creates a new instance on remount — a route change, a `v-if`,
|
|
132
|
+
// a style swap — and the old guard (`if (m && !wired)`) latched on the FIRST one for ever: the
|
|
133
|
+
// second instance was never restored, never wired, and the URL froze on the dead map's last
|
|
134
|
+
// camera while the copy-link button went on advertising it.
|
|
135
|
+
if (wired && wired !== m) {
|
|
136
|
+
wired.off('moveend', write);
|
|
137
|
+
wired = null;
|
|
138
|
+
live = false;
|
|
139
|
+
}
|
|
140
|
+
if (m && !wired) restore(m);
|
|
141
|
+
},
|
|
142
|
+
{ immediate: true },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// The camera is not the only thing in the link, and the other things change without the map moving.
|
|
146
|
+
// Stringified because `state()` returns a fresh object every call, which no shallow watcher can tell
|
|
147
|
+
// from a changed one; the objects are a handful of short strings, so this costs nothing.
|
|
148
|
+
watch(() => JSON.stringify(options.state?.() ?? {}), write);
|
|
149
|
+
|
|
150
|
+
onScopeDispose(() => {
|
|
151
|
+
wired?.off('moveend', write);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { href, restored, /** force a write — for state that changes outside `state()` */ write };
|
|
155
|
+
}
|