@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,70 @@
|
|
|
1
|
+
// What a legend row needs to draw itself, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// ★ THE SWATCH DESCRIBES THE PAINT, NOT THE CATEGORY. A legend earns its place by being checkable
|
|
4
|
+
// against the map: a reader looks at the key, looks at the map, and matches. That only works if the
|
|
5
|
+
// swatch carries how the thing is actually drawn — filled or bounded, solid or dashed — rather than
|
|
6
|
+
// reducing every category to a coloured dot. Five dots over a map that paints two of those five as
|
|
7
|
+
// bare outlines is a legend a reader has to learn to distrust.
|
|
8
|
+
//
|
|
9
|
+
// It is deliberately not zone-shaped. Zones are the first caller, not the only conceivable one, and
|
|
10
|
+
// nothing here knows what a zone is — `zoneLegendItems` in zone.ts is where that knowledge lives.
|
|
11
|
+
|
|
12
|
+
/** One row of a legend: a swatch and what it means. */
|
|
13
|
+
export interface MapLegendItem {
|
|
14
|
+
/** stable key for the row */
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/**
|
|
18
|
+
* A token NAME, e.g. `--forest-map-zone-event`, not a resolved colour.
|
|
19
|
+
*
|
|
20
|
+
* The swatch is DOM, so it can reference the token directly and re-resolve on a mode change the way
|
|
21
|
+
* the rest of the page does. Only the GL side has to resolve tokens to literal sRGB.
|
|
22
|
+
*/
|
|
23
|
+
colour: string;
|
|
24
|
+
/**
|
|
25
|
+
* `fill` is a wash inside its boundary; `line` is the boundary alone. Default `fill`.
|
|
26
|
+
*
|
|
27
|
+
* Not an opacity, deliberately. A layer's own fill opacity is around a tenth, which reproduced at
|
|
28
|
+
* swatch size is invisible against a card — and a tenth against an eighth is a difference nobody
|
|
29
|
+
* can see. Filled or bounded is the distinction a reader can actually check against the map; the
|
|
30
|
+
* two filled zones are told apart by hue.
|
|
31
|
+
*/
|
|
32
|
+
swatch?: 'fill' | 'line';
|
|
33
|
+
/**
|
|
34
|
+
* Dash pattern in line widths. A dashed boundary reads as conditional rather than standing.
|
|
35
|
+
*
|
|
36
|
+
* The swatch reads PRESENCE only — a CSS border cannot express one pattern differently from
|
|
37
|
+
* another, and at this size no reader could tell them apart if it could. The pattern itself is
|
|
38
|
+
* carried so a caller passing layer paint straight through does not have to flatten it first.
|
|
39
|
+
*/
|
|
40
|
+
dash?: readonly number[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How strong the wash is in a swatch — NOT the layer's own fill opacity.
|
|
45
|
+
*
|
|
46
|
+
* A fill layer sits at around a tenth so a city still reads through it. At 12px on a card that is
|
|
47
|
+
* nothing at all, and the row would be indistinguishable from an unfilled one. This is the smallest
|
|
48
|
+
* wash that still reads as a wash rather than as a tint of the card.
|
|
49
|
+
*/
|
|
50
|
+
const WASH = '28%';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The swatch's inline style, shared by the legend and the layers switch.
|
|
54
|
+
*
|
|
55
|
+
* ★ ONE FUNCTION, TWO LISTS. The switch is the legend with a control on each row, and a reader checks
|
|
56
|
+
* either the same way — look at the key, look at the map, match. Two copies of this would drift, and
|
|
57
|
+
* the drift would land exactly where it costs the most: a bounded zone drawn as a filled one in the
|
|
58
|
+
* list a reader is using to decide what to turn off.
|
|
59
|
+
*
|
|
60
|
+
* The boundary is a real border rather than a ring, because a dashed ring is not a thing CSS offers
|
|
61
|
+
* and the dash is half of what this swatch says.
|
|
62
|
+
*/
|
|
63
|
+
export function legendSwatchStyle(item: MapLegendItem): Record<string, string> {
|
|
64
|
+
return {
|
|
65
|
+
backgroundColor:
|
|
66
|
+
item.swatch === 'line' ? 'transparent' : `color-mix(in oklab, var(${item.colour}) ${WASH}, transparent)`,
|
|
67
|
+
borderColor: `var(${item.colour})`,
|
|
68
|
+
borderStyle: item.dash ? 'dashed' : 'solid',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Presentation for FMapSearch — the field, the panel, and one result row.
|
|
2
|
+
//
|
|
3
|
+
// ★ A RECIPE RATHER THAN CLASSES ON THE TEMPLATE, because this component composes reka's Listbox
|
|
4
|
+
// primitives directly and so has no Nuxt UI theme fragment behind it. Every other Forest component
|
|
5
|
+
// delegates: FCanvasControl renders a UButton and inherits the button ladder, which is why it carries
|
|
6
|
+
// no `coarse:` of its own. This one cannot — `UListbox` builds its OWN ListboxRoot, so an external
|
|
7
|
+
// field could never drive it — and the sizing had ended up as utilities scattered across the markup,
|
|
8
|
+
// where nothing could see it whole.
|
|
9
|
+
//
|
|
10
|
+
// ★ THE COARSE LADDER IS THE REASON IT EXISTS. `--forest-canvas-control` already grows the box
|
|
11
|
+
// (2.25rem → 2.75rem), so pinned type made the control 22% taller carrying the same 14px text: emptier
|
|
12
|
+
// rather than bigger, and no easier to read on the device where reading is hardest. Everything steps a
|
|
13
|
+
// rung together here — type, icon, padding, gap — the way `themes/listbox.ts` steps a stock row.
|
|
14
|
+
//
|
|
15
|
+
// It steps from ITS OWN base rather than adopting one of that fragment's rungs wholesale, because this
|
|
16
|
+
// row rests smaller than a stock listbox row. The coarse targets land split across two of them: the
|
|
17
|
+
// label and description reach `lg`'s (text-base, text-sm), while the icon and the group heading reach
|
|
18
|
+
// `sm`'s (size-5, text-xs) — a size-4 icon taking lg's size-6 would be two rungs, and would then
|
|
19
|
+
// outweigh a label that moved only one.
|
|
20
|
+
//
|
|
21
|
+
// ★ 16px ON THE FIELD IS LOAD-BEARING, not a rounding of "a bit bigger". Below it, iOS Safari zooms
|
|
22
|
+
// the viewport on focus, which throws the map camera away from whatever the reader was looking at.
|
|
23
|
+
import { tv } from 'tailwind-variants';
|
|
24
|
+
|
|
25
|
+
export const mapSearchTheme = tv({
|
|
26
|
+
slots: {
|
|
27
|
+
/**
|
|
28
|
+
* The morphing box: a canvas control at rest, a field once focused.
|
|
29
|
+
*
|
|
30
|
+
* ★ THE NEUTRAL RAMP DIRECTLY, not `ring-default` / `text-dimmed`, because this box has to be
|
|
31
|
+
* indistinguishable from the FCanvasControls beside it and those are UButtons: their edge is the
|
|
32
|
+
* button recipe's --btn-line (neutral-300 / 700) and their glyph is --btn-text (neutral-900 / 100).
|
|
33
|
+
* Measured against a live control — the semantic tokens are close but wrong in both cases:
|
|
34
|
+
* --ui-border is a visibly lighter edge, and text-dimmed a far paler glyph than any control on the
|
|
35
|
+
* canvas carries.
|
|
36
|
+
*/
|
|
37
|
+
field:
|
|
38
|
+
'forest-canvas-chrome relative flex h-(--forest-canvas-control) items-center overflow-hidden rounded-md ring ring-[var(--neutral-300)] ring-inset transition-[width] duration-(--motion-base) ease-(--motion-ease-out) motion-reduce:transition-none dark:ring-[var(--neutral-700)]',
|
|
39
|
+
/** The search glyph, centred in the collapsed square so it never moves as the box grows. */
|
|
40
|
+
icon: 'pointer-events-none absolute start-0 flex size-(--forest-canvas-control) shrink-0 items-center justify-center p-2.5 text-[var(--neutral-900)] dark:text-[var(--neutral-100)]',
|
|
41
|
+
input:
|
|
42
|
+
'h-full w-full cursor-pointer bg-transparent pe-2 ps-(--forest-canvas-control) text-sm text-[var(--neutral-900)] outline-none coarse:text-base focus:cursor-text disabled:cursor-not-allowed dark:text-[var(--neutral-100)]',
|
|
43
|
+
/**
|
|
44
|
+
* The scroll box. The cap follows the ROW, and the row grows on touch — 46px becomes ~57px once
|
|
45
|
+
* type, icon and padding each step a rung, so three rows plus a heading need ~13.5rem rather than
|
|
46
|
+
* the 11.5rem measured against a fine-pointer row.
|
|
47
|
+
*
|
|
48
|
+
* ★ Not a `coarse:` variant: on a tablet `coarse:` and `sm:` both match and neither obviously
|
|
49
|
+
* wins, and a layout number should not depend on variant ordering. One value, sized for the case
|
|
50
|
+
* that actually happens; a narrow window with a mouse gets 2rem more panel than it needs, which is
|
|
51
|
+
* the cheaper error.
|
|
52
|
+
*/
|
|
53
|
+
viewport: 'max-h-[13.5rem] overflow-y-auto focus:outline-none sm:max-h-[22rem]',
|
|
54
|
+
/**
|
|
55
|
+
* The popover surface, handed to UPopover's `content` slot.
|
|
56
|
+
*
|
|
57
|
+
* ★ `w-(--reka-popper-anchor-width)` is how the panel matches the field. A custom property set on
|
|
58
|
+
* the field cannot reach it — the content is TELEPORTED to <body>, and custom properties inherit
|
|
59
|
+
* down the DOM tree, so nothing of the field's is in scope. reka sets this one on the content
|
|
60
|
+
* element itself, which is the only reason it resolves. Without it the panel sized to its longest
|
|
61
|
+
* row and hung past the field.
|
|
62
|
+
*/
|
|
63
|
+
panel:
|
|
64
|
+
'w-(--reka-popper-anchor-width) rounded-md p-1 ring ring-[var(--neutral-300)] dark:ring-[var(--neutral-700)]',
|
|
65
|
+
group: 'px-2 pt-2 pb-1 text-[0.6875rem] font-bold tracking-wide text-dimmed uppercase coarse:px-2.5 coarse:text-xs',
|
|
66
|
+
item: 'flex cursor-pointer items-center gap-2 rounded-[var(--ui-radius)] px-2 py-1.5 outline-none coarse:gap-2.5 coarse:px-2.5 coarse:py-2 data-disabled:cursor-default data-highlighted:bg-elevated',
|
|
67
|
+
itemIcon: 'size-4 shrink-0 text-dimmed coarse:size-5',
|
|
68
|
+
/** Stacked, never side by side: two labels in one row let the longer win the truncation race. */
|
|
69
|
+
itemBody: 'min-w-0 flex-1',
|
|
70
|
+
itemLabel: 'block truncate text-sm coarse:text-base',
|
|
71
|
+
itemDescription: 'block truncate text-xs text-muted coarse:text-sm',
|
|
72
|
+
skeleton: 'h-3.5 w-40 coarse:h-4',
|
|
73
|
+
spinner: 'ms-auto size-4 shrink-0 animate-spin text-dimmed coarse:size-5',
|
|
74
|
+
},
|
|
75
|
+
variants: {
|
|
76
|
+
/** A row that is not a real answer — a skeleton, a failure, a reserved best-match with no hit. */
|
|
77
|
+
muted: { true: { itemLabel: 'text-muted' } },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The orchestration behind <FMapSearch>, as pure functions over plain data.
|
|
2
|
+
//
|
|
3
|
+
// It is out here rather than inside the SFC because packages/ui tests run in node with no DOM: state
|
|
4
|
+
// that lives in a component cannot be tested at all, and the awkward parts of this control are all
|
|
5
|
+
// state — answers arriving out of order, a stale query overwriting a fresh one, a group that has to
|
|
6
|
+
// hold its place while a slow source is still thinking.
|
|
7
|
+
//
|
|
8
|
+
// What is NOT here: the keyboard, the roving highlight and the ARIA. <UCommandPalette> owns those
|
|
9
|
+
// through reka's Listbox, and its `by` prop keys items by id so a highlight survives a slow source
|
|
10
|
+
// landing above it. An earlier cut hand-rolled all three; rebuilding a listbox that the design system
|
|
11
|
+
// already ships was the wrong call.
|
|
12
|
+
|
|
13
|
+
/** One row's worth of answer from a source. */
|
|
14
|
+
export interface MapSearchResult {
|
|
15
|
+
/** unique within its source */
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
subtitle?: string;
|
|
19
|
+
/** overrides the source's icon */
|
|
20
|
+
icon?: string;
|
|
21
|
+
/** the source asserts this result answers the query outright */
|
|
22
|
+
exact?: boolean;
|
|
23
|
+
/** present when the source already knows where the thing is */
|
|
24
|
+
geometry?: GeoJSON.Geometry;
|
|
25
|
+
/**
|
|
26
|
+
* Fetches the geometry on selection, for sources that cannot supply it up front.
|
|
27
|
+
*
|
|
28
|
+
* ★ Not a convenience — Mapbox forces it. `suggest()` returns names and addresses but NO
|
|
29
|
+
* coordinates; those come only from `retrieve()` on the suggestion the reader picked. Demanding
|
|
30
|
+
* geometry up front would mean retrieving every suggestion on every keystroke, which is slow and,
|
|
31
|
+
* since retrieve is a billed call, expensive.
|
|
32
|
+
*/
|
|
33
|
+
resolve?(signal: AbortSignal): Promise<GeoJSON.Geometry>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A place results come from. Registered by the consuming app; the design system ships one. */
|
|
37
|
+
export interface MapSearchSource {
|
|
38
|
+
/** stable — it keys the group and half the state */
|
|
39
|
+
id: string;
|
|
40
|
+
/** the group heading */
|
|
41
|
+
label: string;
|
|
42
|
+
/** default icon for rows in this group */
|
|
43
|
+
icon?: string;
|
|
44
|
+
/** rows shown before the rest are dropped */
|
|
45
|
+
limit?: number;
|
|
46
|
+
search(query: string, signal: AbortSignal): Promise<MapSearchResult[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Whether this query COULD produce an exact hit, judged from its shape alone — a regex on an ID
|
|
49
|
+
* format, not a lookup. Cheap and synchronous, because it decides whether the best-match row exists
|
|
50
|
+
* before anything is dispatched, which is what keeps the top of the list from moving later.
|
|
51
|
+
*/
|
|
52
|
+
mayMatchExactly?(query: string): boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type GroupStatus = 'pending' | 'ready' | 'failed';
|
|
56
|
+
|
|
57
|
+
export interface MapSearchGroup {
|
|
58
|
+
sourceId: string;
|
|
59
|
+
label: string;
|
|
60
|
+
icon?: string;
|
|
61
|
+
limit: number;
|
|
62
|
+
status: GroupStatus;
|
|
63
|
+
results: MapSearchResult[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MapSearchState {
|
|
67
|
+
/** the query generation this state belongs to; anything older is discarded on arrival */
|
|
68
|
+
seq: number;
|
|
69
|
+
query: string;
|
|
70
|
+
/** false with a single source, where a heading would label nothing */
|
|
71
|
+
headings: boolean;
|
|
72
|
+
bestReserved: boolean;
|
|
73
|
+
groups: MapSearchGroup[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The best-match group's id. Not a source — a promoted row that sits above every group. */
|
|
77
|
+
export const BEST = '__best__';
|
|
78
|
+
|
|
79
|
+
export const SKELETON_ROWS = 2;
|
|
80
|
+
export const DEFAULT_LIMIT = 5;
|
|
81
|
+
|
|
82
|
+
/** What a row is, so the component can render each kind and know what a select means. */
|
|
83
|
+
export type PaletteItemKind = 'result' | 'skeleton' | 'failed' | 'nomatch';
|
|
84
|
+
|
|
85
|
+
export interface PaletteItem {
|
|
86
|
+
/** keys reka's item identity, so a highlight survives the list changing underneath it */
|
|
87
|
+
id: string;
|
|
88
|
+
kind: PaletteItemKind;
|
|
89
|
+
sourceId: string;
|
|
90
|
+
label: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
icon?: string;
|
|
93
|
+
/** anything that is not a real answer is unpickable and skipped by the keyboard */
|
|
94
|
+
disabled?: boolean;
|
|
95
|
+
result?: MapSearchResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface PaletteGroup {
|
|
99
|
+
id: string;
|
|
100
|
+
label?: string;
|
|
101
|
+
/** always true: results come from the sources, never from the palette's own fuzzy search */
|
|
102
|
+
ignoreFilter: true;
|
|
103
|
+
items: PaletteItem[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function beginQuery(
|
|
107
|
+
sources: readonly MapSearchSource[],
|
|
108
|
+
query: string,
|
|
109
|
+
seq: number,
|
|
110
|
+
): MapSearchState {
|
|
111
|
+
return {
|
|
112
|
+
seq,
|
|
113
|
+
query,
|
|
114
|
+
headings: sources.length > 1,
|
|
115
|
+
bestReserved: sources.some((s) => s.mayMatchExactly?.(query) ?? false),
|
|
116
|
+
groups: sources.map((s) => ({
|
|
117
|
+
sourceId: s.id,
|
|
118
|
+
label: s.label,
|
|
119
|
+
icon: s.icon,
|
|
120
|
+
limit: s.limit ?? DEFAULT_LIMIT,
|
|
121
|
+
status: 'pending' as GroupStatus,
|
|
122
|
+
results: [],
|
|
123
|
+
})),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The promoted result: the first flagged one in SOURCE order, not arrival order. Recomputed from the
|
|
129
|
+
* groups on every read rather than latched, so a slow high-priority source still wins the row once it
|
|
130
|
+
* lands.
|
|
131
|
+
*
|
|
132
|
+
* A flagged result with no reserved row stays in its group. The row's existence is settled by
|
|
133
|
+
* `mayMatchExactly` before dispatch and never revised, which is what stops the top of the list moving.
|
|
134
|
+
*/
|
|
135
|
+
export function bestMatch(state: MapSearchState): MapSearchResult | null {
|
|
136
|
+
if (!state.bestReserved) return null;
|
|
137
|
+
for (const g of state.groups) {
|
|
138
|
+
const hit = g.results.find((r) => r.exact);
|
|
139
|
+
if (hit) return hit;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Every source has answered, one way or another. */
|
|
145
|
+
export function settled(state: MapSearchState): boolean {
|
|
146
|
+
return state.groups.every((g) => g.status !== 'pending');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const skeletons = (sourceId: string): PaletteItem[] =>
|
|
150
|
+
Array.from({ length: SKELETON_ROWS }, (_, i) => ({
|
|
151
|
+
id: `${sourceId}-skeleton-${i}`,
|
|
152
|
+
kind: 'skeleton' as const,
|
|
153
|
+
sourceId,
|
|
154
|
+
label: '',
|
|
155
|
+
disabled: true,
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The state, as groups <UCommandPalette> can render.
|
|
160
|
+
*
|
|
161
|
+
* ★ The rule for an absent group is split, and deliberately so. A group still THINKING holds its place
|
|
162
|
+
* with skeletons: it is about to have content, and letting its results appear from nothing would shove
|
|
163
|
+
* whatever a reader is already looking at down the list. A group that has ANSWERED WITH NOTHING is
|
|
164
|
+
* dropped entirely — an earlier cut kept its heading over a "No matches" line, and on a place query
|
|
165
|
+
* that left two dead blocks above the only real answer. So movement is confined to the one honest
|
|
166
|
+
* case: a pending group resolving to nothing.
|
|
167
|
+
*
|
|
168
|
+
* A FAILED group is not an absent one and keeps its row. "Could not load" is information; silence
|
|
169
|
+
* would let a broken source read as a source with nothing to say.
|
|
170
|
+
*/
|
|
171
|
+
export function paletteGroups(state: MapSearchState): PaletteGroup[] {
|
|
172
|
+
const out: PaletteGroup[] = [];
|
|
173
|
+
const best = bestMatch(state);
|
|
174
|
+
|
|
175
|
+
if (state.bestReserved) {
|
|
176
|
+
// ★ The reserved row keeps its place even when the lookup comes back empty. Someone who typed a
|
|
177
|
+
// full ID that does not exist needs telling that, and the row holding its ground is what keeps the
|
|
178
|
+
// top of the list from moving.
|
|
179
|
+
const item: PaletteItem = best
|
|
180
|
+
? {
|
|
181
|
+
id: `${BEST}-${best.id}`,
|
|
182
|
+
kind: 'result',
|
|
183
|
+
sourceId: BEST,
|
|
184
|
+
label: best.title,
|
|
185
|
+
description: best.subtitle,
|
|
186
|
+
icon: best.icon ?? 'i-lucide-target',
|
|
187
|
+
result: best,
|
|
188
|
+
}
|
|
189
|
+
: settled(state)
|
|
190
|
+
? {
|
|
191
|
+
id: `${BEST}-none`,
|
|
192
|
+
kind: 'nomatch',
|
|
193
|
+
sourceId: BEST,
|
|
194
|
+
label: `No match for “${state.query}”`,
|
|
195
|
+
icon: 'i-lucide-target',
|
|
196
|
+
disabled: true,
|
|
197
|
+
}
|
|
198
|
+
: { ...skeletons(BEST)[0], id: `${BEST}-skeleton` };
|
|
199
|
+
|
|
200
|
+
out.push({ id: BEST, ignoreFilter: true, items: [item] });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const g of state.groups) {
|
|
204
|
+
const label = state.headings ? g.label : undefined;
|
|
205
|
+
|
|
206
|
+
if (g.status === 'pending') {
|
|
207
|
+
out.push({ id: g.sourceId, label, ignoreFilter: true, items: skeletons(g.sourceId) });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (g.status === 'failed') {
|
|
212
|
+
out.push({
|
|
213
|
+
id: g.sourceId,
|
|
214
|
+
label,
|
|
215
|
+
ignoreFilter: true,
|
|
216
|
+
items: [
|
|
217
|
+
{
|
|
218
|
+
id: `${g.sourceId}-failed`,
|
|
219
|
+
kind: 'failed',
|
|
220
|
+
sourceId: g.sourceId,
|
|
221
|
+
label: 'Could not load',
|
|
222
|
+
icon: 'i-lucide-triangle-alert',
|
|
223
|
+
disabled: true,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const shown = g.results.filter((r) => r !== best).slice(0, g.limit);
|
|
231
|
+
if (!shown.length) continue;
|
|
232
|
+
|
|
233
|
+
out.push({
|
|
234
|
+
id: g.sourceId,
|
|
235
|
+
label,
|
|
236
|
+
ignoreFilter: true,
|
|
237
|
+
items: shown.map((r) => ({
|
|
238
|
+
id: `${g.sourceId}-${r.id}`,
|
|
239
|
+
kind: 'result' as const,
|
|
240
|
+
sourceId: g.sourceId,
|
|
241
|
+
label: r.title,
|
|
242
|
+
description: r.subtitle,
|
|
243
|
+
icon: r.icon ?? g.icon,
|
|
244
|
+
result: r,
|
|
245
|
+
})),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function withGroup(
|
|
253
|
+
state: MapSearchState,
|
|
254
|
+
seq: number,
|
|
255
|
+
sourceId: string,
|
|
256
|
+
patch: Partial<MapSearchGroup>,
|
|
257
|
+
): MapSearchState {
|
|
258
|
+
// ★ The stale guard is here rather than at the call site so every path gets it. An answer to a
|
|
259
|
+
// query the reader has already typed past is not late data, it is wrong data.
|
|
260
|
+
if (seq !== state.seq) return state;
|
|
261
|
+
const i = state.groups.findIndex((g) => g.sourceId === sourceId);
|
|
262
|
+
if (i < 0) return state;
|
|
263
|
+
const groups = state.groups.slice();
|
|
264
|
+
groups[i] = { ...groups[i], ...patch };
|
|
265
|
+
return { ...state, groups };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function receiveResults(
|
|
269
|
+
state: MapSearchState,
|
|
270
|
+
seq: number,
|
|
271
|
+
sourceId: string,
|
|
272
|
+
results: MapSearchResult[],
|
|
273
|
+
): MapSearchState {
|
|
274
|
+
return withGroup(state, seq, sourceId, { status: 'ready', results });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function receiveError(state: MapSearchState, seq: number, sourceId: string): MapSearchState {
|
|
278
|
+
return withGroup(state, seq, sourceId, { status: 'failed', results: [] });
|
|
279
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The camera as a string, and the query string it lives in.
|
|
2
|
+
//
|
|
3
|
+
// A console's link is the whole product: a dispatcher sees something, pastes it, and someone else
|
|
4
|
+
// has to see the same thing. That means the camera belongs in the URL — and belongs there in a form
|
|
5
|
+
// a person can read, an older build can still parse, and the back button survives.
|
|
6
|
+
//
|
|
7
|
+
// ★ THE STRING IS lat/lng. Every GL JS call in this codebase is lng-first, and this is the one place
|
|
8
|
+
// that reverses it, because the order is Mapbox's own `hash: true` order — so a Forest link and a link
|
|
9
|
+
// from any other Mapbox tool read the same way. A swap looks like nothing in review and puts London in
|
|
10
|
+
// the Indian Ocean, which is why the codec's tests use values no swap could survive.
|
|
11
|
+
|
|
12
|
+
export interface MapCamera {
|
|
13
|
+
/** GL order: [lng, lat] — reversed only when it becomes a string */
|
|
14
|
+
center: [number, number];
|
|
15
|
+
zoom: number;
|
|
16
|
+
bearing: number;
|
|
17
|
+
pitch: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decimals kept. 5 on a coordinate is ~1.1 m — finer than the GPS fix that produced it, and half the
|
|
22
|
+
* characters of the raw double.
|
|
23
|
+
*/
|
|
24
|
+
export const MAP_URL_PRECISION = { zoom: 2, coordinate: 5 } as const;
|
|
25
|
+
|
|
26
|
+
/** GL JS's own ceilings. A value past one of these is a broken link, not a camera. */
|
|
27
|
+
const LIMITS = { zoom: [0, 24], lat: [-90, 90], lng: [-180, 180], pitch: [0, 85] } as const;
|
|
28
|
+
|
|
29
|
+
/** Fixed decimals without the trailing zeros — `12.50` is two characters of nothing. */
|
|
30
|
+
const trim = (value: number, decimals: number): string => String(Number(value.toFixed(decimals)));
|
|
31
|
+
|
|
32
|
+
const within = (value: number, [min, max]: readonly [number, number]) =>
|
|
33
|
+
Number.isFinite(value) && value >= min && value <= max;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `12.5/51.5074/-0.1278` — and `/45/60` on the end once the camera is turned or tilted.
|
|
37
|
+
*
|
|
38
|
+
* Bearing and pitch are dropped when they are zero, which is the overwhelming case, and they are
|
|
39
|
+
* positional: a turned-but-flat camera still writes `/45`, and a tilted-but-north one writes `/0/60`
|
|
40
|
+
* because the pitch cannot be read without the slot before it.
|
|
41
|
+
*/
|
|
42
|
+
export function encodeCamera(camera: MapCamera): string {
|
|
43
|
+
const [lng, lat] = camera.center;
|
|
44
|
+
const bearing = Math.round(camera.bearing ?? 0);
|
|
45
|
+
const pitch = Math.round(camera.pitch ?? 0);
|
|
46
|
+
const parts = [
|
|
47
|
+
trim(camera.zoom, MAP_URL_PRECISION.zoom),
|
|
48
|
+
trim(lat, MAP_URL_PRECISION.coordinate),
|
|
49
|
+
trim(lng, MAP_URL_PRECISION.coordinate),
|
|
50
|
+
];
|
|
51
|
+
if (bearing || pitch) parts.push(String(((bearing % 360) + 360) % 360));
|
|
52
|
+
if (pitch) parts.push(String(pitch));
|
|
53
|
+
return parts.join('/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reverse. Null for anything that is not a camera — and null means IGNORE, not correct.
|
|
58
|
+
*
|
|
59
|
+
* A clamped link would quietly claim the sender meant a place they did not; the reader gets the
|
|
60
|
+
* surface's own default view instead, which is at least somewhere real. The one exception is bearing,
|
|
61
|
+
* which is cyclic: 370° and 10° are the same camera, so it is canonicalised rather than rejected.
|
|
62
|
+
*/
|
|
63
|
+
export function decodeCamera(text: string | null | undefined): MapCamera | null {
|
|
64
|
+
if (!text) return null;
|
|
65
|
+
const parts = text.split('/');
|
|
66
|
+
if (parts.length < 3 || parts.length > 5) return null;
|
|
67
|
+
|
|
68
|
+
const [zoom, lat, lng, bearing = '0', pitch = '0'] = parts.map((p) => p.trim());
|
|
69
|
+
// ★ Number('') is 0, so an empty field would decode as a valid north-up camera at zoom 0 rather
|
|
70
|
+
// than as the broken link it is. Every field is checked for content before it is read.
|
|
71
|
+
if ([zoom, lat, lng, bearing, pitch].some((p) => p === '')) return null;
|
|
72
|
+
|
|
73
|
+
const values = { zoom: Number(zoom), lat: Number(lat), lng: Number(lng), bearing: Number(bearing), pitch: Number(pitch) };
|
|
74
|
+
if (!within(values.zoom, LIMITS.zoom)) return null;
|
|
75
|
+
if (!within(values.lat, LIMITS.lat)) return null;
|
|
76
|
+
if (!within(values.lng, LIMITS.lng)) return null;
|
|
77
|
+
if (!within(values.pitch, LIMITS.pitch)) return null;
|
|
78
|
+
if (!Number.isFinite(values.bearing)) return null;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
center: [values.lng, values.lat],
|
|
82
|
+
zoom: values.zoom,
|
|
83
|
+
bearing: ((values.bearing % 360) + 360) % 360,
|
|
84
|
+
pitch: values.pitch,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether two cameras would write the same string — the test for "is this write worth making". */
|
|
89
|
+
export const sameCamera = (a: MapCamera | null, b: MapCamera | null): boolean =>
|
|
90
|
+
a === b || (!!a && !!b && encodeCamera(a) === encodeCamera(b));
|
|
91
|
+
|
|
92
|
+
/** Every parameter in a query string, as plain pairs. Accepts it with or without the leading `?`. */
|
|
93
|
+
export function readQuery(search: string): Record<string, string> {
|
|
94
|
+
const out: Record<string, string> = {};
|
|
95
|
+
for (const [key, value] of new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)) {
|
|
96
|
+
out[key] = value;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply a patch to a query string and return the new one, `?` included and empty when nothing is left.
|
|
103
|
+
*
|
|
104
|
+
* ★ IT PATCHES. A map is one thing in a console's URL — `?tab=fleet&depot=7&map=…` — and a writer that
|
|
105
|
+
* rebuilt the string from the camera alone would drop the rest of the page's state every time somebody
|
|
106
|
+
* panned. `null` or `undefined` removes a key; everything else is set in place, so a parameter keeps
|
|
107
|
+
* its position rather than jumping to the end on every write.
|
|
108
|
+
*/
|
|
109
|
+
export function patchQuery(search: string, patch: Record<string, string | null | undefined>): string {
|
|
110
|
+
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
|
111
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
112
|
+
if (value === null || value === undefined || value === '') params.delete(key);
|
|
113
|
+
else params.set(key, value);
|
|
114
|
+
}
|
|
115
|
+
const next = params.toString();
|
|
116
|
+
return next ? `?${next}` : '';
|
|
117
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Generated by scripts/mapMarker.ts — do not edit.
|
|
2
|
+
// The uploaded asset, inlined so a component can paint it with no fetch. See paintMarkerSvg().
|
|
3
|
+
export const MARKER_SVG = "<svg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n xmlns:m=\"https://www.mapbox.com\">\n <m:metadata>\n <m:parameters>\n <m:parameter m:type=\"color\" m:name=\"body\" m:value=\"#FF00FF\" />\n <m:parameter m:type=\"color\" m:name=\"edge\" m:value=\"#FF01FF\" />\n <m:parameter m:type=\"color\" m:name=\"battery\" m:value=\"#FF02FF\" />\n <m:parameter m:type=\"color\" m:name=\"batteryEdge\" m:value=\"#FF03FF\" />\n <m:parameter m:type=\"color\" m:name=\"dot\" m:value=\"#FF04FF\" />\n <m:parameter m:type=\"color\" m:name=\"dotEdge\" m:value=\"#FF05FF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeSquare\" m:value=\"#FF06FF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeSquareEdge\" m:value=\"#FF07FF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeTriangle\" m:value=\"#FF08FF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeTriangleEdge\" m:value=\"#FF09FF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeCircle\" m:value=\"#FF0AFF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeCircleEdge\" m:value=\"#FF0BFF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeStar\" m:value=\"#FF0CFF\" />\n <m:parameter m:type=\"color\" m:name=\"badgeStarEdge\" m:value=\"#FF0DFF\" />\n <m:parameter m:type=\"color\" m:name=\"inUseFrom\" m:value=\"#FF0EFF\" />\n <m:parameter m:type=\"color\" m:name=\"inUseTo\" m:value=\"#FF0FFF\" />\n <m:parameter m:type=\"color\" m:name=\"locked\" m:value=\"#FF10FF\" />\n <m:parameter m:type=\"color\" m:name=\"lockedEdge\" m:value=\"#FF11FF\" />\n <m:parameter m:type=\"color\" m:name=\"bike\" m:value=\"#FF12FF\" />\n <m:parameter m:type=\"color\" m:name=\"pine\" m:value=\"#FF13FF\" />\n <m:parameter m:type=\"color\" m:name=\"eye\" m:value=\"#FF14FF\" />\n <m:parameter m:type=\"color\" m:name=\"relocate\" m:value=\"#FF15FF\" />\n <m:parameter m:type=\"color\" m:name=\"wrench\" m:value=\"#FF16FF\" />\n <m:parameter m:type=\"color\" m:name=\"warehouse\" m:value=\"#FF17FF\" />\n <m:parameter m:type=\"color\" m:name=\"truck\" m:value=\"#FF18FF\" />\n <m:parameter m:type=\"color\" m:name=\"bandage\" m:value=\"#FF19FF\" />\n <m:parameter m:type=\"color\" m:name=\"question\" m:value=\"#FF1AFF\" />\n <m:parameter m:type=\"color\" m:name=\"hand\" m:value=\"#FF1BFF\" />\n <m:parameter m:type=\"color\" m:name=\"flag\" m:value=\"#FF1CFF\" />\n <m:parameter m:type=\"color\" m:name=\"ready\" m:value=\"#FF1DFF\" />\n <m:parameter m:type=\"color\" m:name=\"skull\" m:value=\"#FF1EFF\" />\n <m:parameter m:type=\"color\" m:name=\"container\" m:value=\"#FF1FFF\" />\n <m:parameter m:type=\"color\" m:name=\"staff\" m:value=\"#FF20FF\" />\n </m:parameters>\n </m:metadata>\n <defs>\n <radialGradient id=\"inUse\" cx=\"0.35\" cy=\"0.3\" r=\"0.85\">\n <stop offset=\"0\" stop-color=\"#FF0EFF\" />\n <stop offset=\"1\" stop-color=\"#FF0FFF\" />\n </radialGradient>\n </defs>\n <circle cx=\"20\" cy=\"20\" r=\"19.2\" fill=\"#FF02FF\" stroke=\"#FF03FF\" stroke-width=\"1.6\" />\n <circle cx=\"20\" cy=\"20\" r=\"15.2\" fill=\"#FF00FF\" stroke=\"#FF01FF\" stroke-width=\"1.6\" />\n <circle cx=\"20\" cy=\"20\" r=\"14.4\" fill=\"url(#inUse)\" />\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF12FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M18.5 19C20.433 19 22 17.433 22 15.5C22 13.567 20.433 12 18.5 12C16.567 12 15 13.567 15 15.5C15 17.433 16.567 19 18.5 19Z\" stroke-width=\"1.74\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /><path d=\"M5.5 19C7.433 19 9 17.433 9 15.5C9 13.567 7.433 12 5.5 12C3.567 12 2 13.567 2 15.5C2 17.433 3.567 19 5.5 19Z\" stroke-width=\"1.74\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /><path d=\"M6 15.5H11.3602C11.7502 15.5 12.1047 15.2732 12.2681 14.9191L15 9\" stroke-width=\"1.74\" stroke-linecap=\"round\" /><path d=\"M8 9H10\" stroke-width=\"1.74\" stroke-linecap=\"round\" /><path d=\"M9 9L12 15\" stroke-width=\"1.74\" stroke-linecap=\"round\" /><path d=\"M12 5H12.8686C13.2543 5 13.6056 5.22183 13.7715 5.57007L18.5 15.5\" stroke-width=\"1.74\" stroke-linecap=\"round\" /><path d=\"M15 6H18.6126C19.2951 6 19.7771 6.6687 19.5613 7.31623L19.2279 8.31623C19.0918 8.72457 18.7097 9 18.2792 9H16\" stroke-width=\"1.74\" stroke-linecap=\"round\" /><path d=\"M5.5 12H3\" stroke-width=\"1.74\" stroke-linecap=\"round\" /></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF13FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 2L17.2 12.4H15.4L17.2 16H6.8L8.6 12.4H6.8L12 2Z\" stroke-width=\"1.74\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /><path d=\"M10.2 16V22H8.6\" stroke-width=\"1.74\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /><path d=\"M13.8 16V22H15.4\" stroke-width=\"1.74\" stroke-linecap=\"round\" stroke-linejoin=\"round\" /></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF14FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF15FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\" d=\"m16 3l4 4l-4 4m4-4H4m4 14l-4-4l4-4m-4 4h16\"/></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF16FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\" d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z\"/></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF17FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M18 21V10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v11\"/><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 1.132-1.803l7.95-3.974a2 2 0 0 1 1.837 0l7.948 3.974A2 2 0 0 1 22 8zM6 13h12M6 17h12\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF18FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2m10 0H9m10 0h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14\"/><circle cx=\"17\" cy=\"18\" r=\"2\"/><circle cx=\"7\" cy=\"18\" r=\"2\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF19FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M10 10.01h.01m-.01 4h.01m3.99-4h.01m-.01 4h.01M18 6v12M6 6v12\"/><rect width=\"20\" height=\"12\" x=\"2\" y=\"6\" rx=\"2\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1AFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.08 4h.01\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1BFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2m0 4V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2m0 4.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8\"/><path d=\"M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1CFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\" d=\"M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528\"/></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1DFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M12 22V12m4 5l2 2l4-4\"/><path d=\"M21 11.127V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.729l7 4a2 2 0 0 0 2 .001l1.32-.753\"/><path d=\"M3.29 7L12 12l8.71-5M7.5 4.27l8.997 5.148\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1EFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"m12.5 17l-.5-1l-.5 1z\"/><path d=\"M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25a8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z\"/><circle cx=\"15\" cy=\"12\" r=\"1\"/><circle cx=\"9\" cy=\"12\" r=\"1\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF1FFF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z\"/><path d=\"M10 21.9V14L2.1 9.1M10 14l11.9-6.9M14 19.8v-8.1m4 5.8V9.4\"/></g></g>\n <g transform=\"translate(10.06 10.06) scale(0.828)\" fill=\"none\" stroke=\"#FF20FF\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><g stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.74\"><path d=\"M12 12h.01M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2m14 7a18.15 18.15 0 0 1-20 0\"/><rect width=\"20\" height=\"14\" x=\"2\" y=\"6\" rx=\"2\"/></g></g>\n <circle cx=\"32.16\" cy=\"7.84\" r=\"5.44\" fill=\"#FF04FF\" stroke=\"#FF05FF\" stroke-width=\"1.6\" />\n <rect x=\"3.71\" y=\"28.03\" width=\"8.26\" height=\"8.26\" rx=\"1.4\" fill=\"#FF06FF\" stroke=\"#FF07FF\" stroke-width=\"1.6\" />\n <path d=\"M 7.84,26.82 L 13.18,36 L 2.5,36 Z\" fill=\"#FF08FF\" stroke=\"#FF09FF\" stroke-width=\"1.6\" stroke-linejoin=\"round\" />\n <circle cx=\"7.84\" cy=\"32.16\" r=\"4.64\" fill=\"#FF0AFF\" stroke=\"#FF0BFF\" stroke-width=\"1.6\" />\n <path d=\"M 7.84,26.68 L 9.77,29.5 L 13.05,30.47 L 10.96,33.18 L 11.06,36.59 L 7.84,35.45 L 4.62,36.59 L 4.72,33.18 L 2.63,30.47 L 5.91,29.5 Z\" fill=\"#FF0CFF\" stroke=\"#FF0DFF\" stroke-width=\"1.6\" stroke-linejoin=\"round\" stroke-linecap=\"round\" />\n <circle cx=\"32.16\" cy=\"32.16\" r=\"5.44\" fill=\"#FF10FF\" stroke=\"#FF11FF\" stroke-width=\"1.6\" />\n <rect x=\"29.92\" y=\"29.49\" width=\"1.95\" height=\"5.34\" rx=\"0.58\" fill=\"#FF11FF\" />\n <rect x=\"32.45\" y=\"29.49\" width=\"1.95\" height=\"5.34\" rx=\"0.58\" fill=\"#FF11FF\" />\n</svg>\n";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The one place the two paddings compose.
|
|
2
|
+
//
|
|
3
|
+
// A map carries a TRANSFORM inset — `<FMap :padding>`, the strip a side panel or a bottom sheet covers —
|
|
4
|
+
// and a fit asks for room around the box on top of it. Mapbox composes neither for us, so both call
|
|
5
|
+
// sites (FMap's creation-time fit, useMapCamera.fit) sum here rather than each doing it their own way.
|
|
6
|
+
|
|
7
|
+
export type Inset = { top: number; right: number; bottom: number; left: number };
|
|
8
|
+
export type PartialInset = { top?: number; bottom?: number; left?: number; right?: number };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every side spelled out.
|
|
12
|
+
*
|
|
13
|
+
* ★ Mapbox MERGES a partial padding into whatever the transform already holds rather than replacing it,
|
|
14
|
+
* so `{ left: 400 }` handed straight to `setPadding` leaves the other three sides at whatever the last
|
|
15
|
+
* camera move wrote — a fit's leftovers, usually. `undefined` is also not 0 for arithmetic:
|
|
16
|
+
* `undefined + gap` is NaN, which fits nothing at any zoom.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveInset(p?: PartialInset | null): Inset {
|
|
19
|
+
return { top: p?.top ?? 0, right: p?.right ?? 0, bottom: p?.bottom ?? 0, left: p?.left ?? 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The inset plus a uniform gap on every side — what a fit has to be handed, because `fitBounds`'
|
|
24
|
+
* `padding` REPLACES the transform's rather than adding to it (measured on GL JS 3.24.1:
|
|
25
|
+
* `Camera._extendPadding` falls back to `transform.padding` only when the option is absent).
|
|
26
|
+
*
|
|
27
|
+
* A uniform gap does not move where the camera renders its centre — `centerPoint` tracks the DIFFERENCE
|
|
28
|
+
* between opposite sides, which adding the same number to both leaves untouched. So the sum frames
|
|
29
|
+
* tighter without shifting the view, and the transform can be set back to the inset alone afterwards.
|
|
30
|
+
*/
|
|
31
|
+
export function withGap(p: PartialInset | null | undefined, gap: number): Inset {
|
|
32
|
+
const inset = resolveInset(p);
|
|
33
|
+
return {
|
|
34
|
+
top: inset.top + gap,
|
|
35
|
+
right: inset.right + gap,
|
|
36
|
+
bottom: inset.bottom + gap,
|
|
37
|
+
left: inset.left + gap,
|
|
38
|
+
};
|
|
39
|
+
}
|