@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,192 @@
|
|
|
1
|
+
// How a PLACE reads on a map — the fixed things an operation works from.
|
|
2
|
+
//
|
|
3
|
+
// ★ A place is not a vehicle, so it does not use the vehicle's disc. It has no battery, it does not go
|
|
4
|
+
// offline, and an operator cannot repair it. What it has instead is a KIND, and sometimes a COUNT.
|
|
5
|
+
//
|
|
6
|
+
// kind what the place is (WAREHOUSE | PARKING)
|
|
7
|
+
// count how many vehicles optional, and it is a LABEL rather than artwork
|
|
8
|
+
//
|
|
9
|
+
// Shape carries the class: vehicles are round, places are square, a counted place is a pill. Shape is the
|
|
10
|
+
// channel a reader resolves first, and an operations map draws both classes at once — so a depot must
|
|
11
|
+
// never read as a vehicle at scanning distance.
|
|
12
|
+
//
|
|
13
|
+
// A ZONE is neither. A business area or a no-parking area is a region, so it is a fill and a line, not
|
|
14
|
+
// a mark: a marker on the centre of an area claims a point that does not exist. See `zone.ts`.
|
|
15
|
+
import placeManifest from '../../icons/forest/map/place-manifest.json';
|
|
16
|
+
import countManifest from '../../icons/forest/map/place-count-manifest.json';
|
|
17
|
+
|
|
18
|
+
export type PlaceKind = 'WAREHOUSE' | 'PARKING';
|
|
19
|
+
|
|
20
|
+
/** Which glyph each kind lights. One glyph for each kind, as with the vehicle states. */
|
|
21
|
+
export const PLACE_GLYPH: Record<PlaceKind, string> = {
|
|
22
|
+
WAREHOUSE: 'warehouse',
|
|
23
|
+
PARKING: 'parking',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ★ TWO AXES, AND THE COLOUR CARRIES THE SECOND ONE (José, 2026-07-26).
|
|
28
|
+
*
|
|
29
|
+
* KIND what the place is the GLYPH — the Mohr P, or a house
|
|
30
|
+
* CONDITION what state it is in the COLOUR — semantic, not categorical
|
|
31
|
+
*
|
|
32
|
+
* A bay's condition is a SEVERITY, not a category, so it takes semantic colours for the same reason the battery
|
|
33
|
+
* ring does. Colour used to carry the kind, which meant the colour and the glyph said the same thing twice and
|
|
34
|
+
* left nowhere to put "suspended".
|
|
35
|
+
*
|
|
36
|
+
* ★ It also un-double-books the palette. Vehicle discs are categorical and take the vivid set; place squares are
|
|
37
|
+
* severity and take the semantic roles. The two never overlap by construction — where before, sky was both a bay
|
|
38
|
+
* and a `parked` vehicle, and the inverted colour was both a warehouse and a `retired` one.
|
|
39
|
+
*
|
|
40
|
+
* Measured between the three: info↔warning 124 light / 130 dark, info↔error 96/95, warning↔error 48/60. A white
|
|
41
|
+
* glyph on `info` reads at 3.6:1 light and 6.7:1 dark.
|
|
42
|
+
*
|
|
43
|
+
* ⚠ Each does land near a VEHICLE disc — info↔parked 18/22, warning↔hold 9/15, error↔damage 35/7. Shape
|
|
44
|
+
* separates the classes, and the near-misses are semantically adjacent: an amber square and an amber disc both
|
|
45
|
+
* mean "wants attention". A confusion that costs nothing is not the same as one that inverts a meaning.
|
|
46
|
+
*/
|
|
47
|
+
export type PlaceState = 'OK' | 'SUSPENDED' | 'REMOVED';
|
|
48
|
+
|
|
49
|
+
export const PLACE_STATE_COLOUR: Record<PlaceState, string> = {
|
|
50
|
+
OK: '--forest-map-place-ok',
|
|
51
|
+
SUSPENDED: '--forest-map-place-suspended',
|
|
52
|
+
REMOVED: '--forest-map-place-removed',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ★ ONLY A BAY HAS A CONDITION (José). A warehouse cannot be suspended or removed — it is a building the
|
|
57
|
+
* operation works from, not a kerbside arrangement that a council can withdraw. So the condition belongs to the
|
|
58
|
+
* BAY rather than to places in general, and this union makes a suspended warehouse UNREPRESENTABLE rather than
|
|
59
|
+
* merely discouraged. A rule that lives in a comment is a rule somebody constructs their way around.
|
|
60
|
+
*
|
|
61
|
+
* A warehouse still takes a colour, and it takes the OK one: the colour channel means "condition", and a
|
|
62
|
+
* warehouse's condition is constant. Pinning it is what keeps the channel meaning ONE thing — giving warehouses
|
|
63
|
+
* a kind-colour of their own would make the same channel mean "condition" on one glyph and "kind" on another.
|
|
64
|
+
*/
|
|
65
|
+
export type Place = { kind: 'WAREHOUSE' } | { kind: 'PARKING'; state: PlaceState };
|
|
66
|
+
|
|
67
|
+
/** the condition a bay paints. A warehouse has none — see `placeColour`. */
|
|
68
|
+
export const placeState = (place: Place): PlaceState => (place.kind === 'PARKING' ? place.state : 'OK');
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The token a place paints.
|
|
72
|
+
*
|
|
73
|
+
* ★ A WAREHOUSE IS OFF THE SCALE, not at the good end of it. It takes `--forest-map-warehouse`, the inverted
|
|
74
|
+
* colour — which is not a semantic role, so it does not claim a condition a warehouse cannot have. That is the
|
|
75
|
+
* same move as `retired` on the vehicle disc: the value that is not a hue is how the set says "this one is not
|
|
76
|
+
* on the scale the others are on". A bay's colour IS its condition; a warehouse's colour says it has none.
|
|
77
|
+
*/
|
|
78
|
+
export const WAREHOUSE_COLOUR = '--forest-map-warehouse';
|
|
79
|
+
export const placeColour = (place: Place): string =>
|
|
80
|
+
place.kind === 'WAREHOUSE' ? WAREHOUSE_COLOUR : PLACE_STATE_COLOUR[place.state];
|
|
81
|
+
|
|
82
|
+
/** A colour with zero alpha switches a region off. Params are colours and nothing else. */
|
|
83
|
+
export const PARAM_OFF = 'rgba(0,0,0,0)';
|
|
84
|
+
|
|
85
|
+
export const PLACE_ICON_ID = placeManifest.id;
|
|
86
|
+
export const PLACE_COUNT_ICON_ID = countManifest.id;
|
|
87
|
+
export const PLACE_GLYPH_PARAMS: readonly string[] = placeManifest.params
|
|
88
|
+
.filter((p) => p.kind === 'glyph')
|
|
89
|
+
.map((p) => p.name);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The glyph a COUNTED place lights, whatever kind of place it is.
|
|
93
|
+
*
|
|
94
|
+
* The square's glyph says what the place is; the pill's says what is being counted, because a number
|
|
95
|
+
* on its own leaves its unit to be inferred. This is the one param the pill drives differently from
|
|
96
|
+
* the square, so it is named here rather than left as a string inside the expression.
|
|
97
|
+
*/
|
|
98
|
+
export const COUNT_GLYPH = 'bike';
|
|
99
|
+
|
|
100
|
+
export interface PlaceColours {
|
|
101
|
+
/** the square or the pill */
|
|
102
|
+
body: unknown;
|
|
103
|
+
/** the lit glyph */
|
|
104
|
+
glyph: unknown;
|
|
105
|
+
/** the border, which is also the halo */
|
|
106
|
+
ring: unknown;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The params for ONE place. Every region is set, because an omitted param keeps its magenta sentinel.
|
|
111
|
+
*/
|
|
112
|
+
export function placeParams(kind: PlaceKind, colours: PlaceColours): Record<string, unknown> {
|
|
113
|
+
const lit = PLACE_GLYPH[kind];
|
|
114
|
+
const params: Record<string, unknown> = { body: colours.body, ring: colours.ring };
|
|
115
|
+
for (const name of PLACE_GLYPH_PARAMS) params[name] = name === lit ? colours.glyph : PARAM_OFF;
|
|
116
|
+
return params;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The `icon-image` for a layer that draws every place from ONE symbol layer. Each param becomes a `match`
|
|
121
|
+
* on the feature, exactly as the vehicle marker does.
|
|
122
|
+
*/
|
|
123
|
+
export function placeImageExpression(opts: {
|
|
124
|
+
/** the CONDITION → colour map. `{ OK: token('--forest-map-place-ok'), … }` */
|
|
125
|
+
state: Record<PlaceState, unknown>;
|
|
126
|
+
/** the warehouse's own colour — it is off the condition scale. `token('--forest-map-warehouse')` */
|
|
127
|
+
warehouse: unknown;
|
|
128
|
+
glyph: unknown;
|
|
129
|
+
ring: unknown;
|
|
130
|
+
/** the feature properties to read. Defaults: `kind` for the glyph, `state` for the colour */
|
|
131
|
+
props?: { kind?: string; state?: string };
|
|
132
|
+
/** draw the stretchable pill instead of the square. Pair it with `icon-text-fit: 'width'` */
|
|
133
|
+
counted?: boolean;
|
|
134
|
+
}): unknown[] {
|
|
135
|
+
const kindProp = ['get', opts.props?.kind ?? 'kind'];
|
|
136
|
+
const stateProp = ['get', opts.props?.state ?? 'state'];
|
|
137
|
+
const kinds = Object.keys(PLACE_GLYPH) as PlaceKind[];
|
|
138
|
+
const states = Object.keys(PLACE_STATE_COLOUR) as PlaceState[];
|
|
139
|
+
const params: Record<string, unknown> = {
|
|
140
|
+
// ★ the body matches on the CONDITION; the glyph matches on the KIND. Two properties, two questions.
|
|
141
|
+
// ⚠ A WAREHOUSE TAKES ITS OWN COLOUR regardless of what the feature says about state. Only a bay has a
|
|
142
|
+
// condition, so a feed that puts `state: 'REMOVED'` on a warehouse is wrong rather than interesting — the
|
|
143
|
+
// expression renders what the model allows, not what the feed sent.
|
|
144
|
+
body: [
|
|
145
|
+
'case',
|
|
146
|
+
['==', kindProp, 'WAREHOUSE'],
|
|
147
|
+
opts.warehouse,
|
|
148
|
+
['match', stateProp, ...states.flatMap((st) => [st, opts.state[st]]), opts.state.OK],
|
|
149
|
+
],
|
|
150
|
+
ring: opts.ring,
|
|
151
|
+
};
|
|
152
|
+
// ★★ THE COUNTED PILL LIGHTS THE BIKE, NOT THE KIND (José). The square answers "what is this place",
|
|
153
|
+
// so its glyph is the kind. The pill answers "how many vehicles are here", and the glyph names the
|
|
154
|
+
// thing being counted — a bare number beside a P leaves the unit to be inferred, and the unit is the
|
|
155
|
+
// half of the message the number cannot carry.
|
|
156
|
+
//
|
|
157
|
+
// ⚠ It follows that a counted WAREHOUSE and a counted BAY draw the same glyph. That is accepted: the
|
|
158
|
+
// count is the subject, and the kind is still on the map next to it as an uncounted square.
|
|
159
|
+
if (opts.counted) {
|
|
160
|
+
for (const name of PLACE_GLYPH_PARAMS) params[name] = name === COUNT_GLYPH ? opts.glyph : PARAM_OFF;
|
|
161
|
+
} else {
|
|
162
|
+
for (const name of PLACE_GLYPH_PARAMS) {
|
|
163
|
+
const users = kinds.filter((k) => PLACE_GLYPH[k] === name);
|
|
164
|
+
params[name] = users.length
|
|
165
|
+
? ['match', kindProp, ...users.flatMap((k) => [k, opts.glyph]), PARAM_OFF]
|
|
166
|
+
: PARAM_OFF;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return ['image', opts.counted ? PLACE_COUNT_ICON_ID : PLACE_ICON_ID, { params }];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The layout a counted place needs. Without `icon-text-fit` the pill never stretches: the stretch
|
|
174
|
+
* metadata in the asset only takes effect when a layer asks the icon to fit its text.
|
|
175
|
+
*
|
|
176
|
+
* `text-field` is the caller's, because only the caller knows which property holds the count.
|
|
177
|
+
*/
|
|
178
|
+
export const PLACE_COUNT_LAYOUT = {
|
|
179
|
+
'icon-text-fit': 'width',
|
|
180
|
+
'icon-text-fit-padding': [0, 0, 0, 0],
|
|
181
|
+
'icon-allow-overlap': true,
|
|
182
|
+
'text-allow-overlap': true,
|
|
183
|
+
'text-size': 12,
|
|
184
|
+
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
|
185
|
+
} as const;
|
|
186
|
+
|
|
187
|
+
/** Where the label goes, and how wide the pill starts. Read from the generator's manifest. */
|
|
188
|
+
export const PLACE_COUNT_CONTENT = countManifest.content;
|
|
189
|
+
export const PLACE_COUNT_STRETCH = countManifest.stretchX;
|
|
190
|
+
|
|
191
|
+
// Zones live in `zone.ts`. They grew an audience axis and five kinds, which is more than a place file
|
|
192
|
+
// should carry — and a zone was never a place to begin with.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The engine's sequential ramps, sampled for a map layer — one place, for both mechanisms.
|
|
2
|
+
//
|
|
3
|
+
// ★ THE MAP DOES NOT ALIAS THE RAMPS, AND IT USED TO. There was a `--forest-map-density-*` block in
|
|
4
|
+
// forest.css restating five positions of the brand ramp, for one reason: the engine declares sequential
|
|
5
|
+
// ramps once, tuned for a light page, so something has to reverse them after dark. Putting that in
|
|
6
|
+
// tokens looked right — a GL layer cannot forget a token — until the field needed a RAMP PROP, which
|
|
7
|
+
// tokens cannot express without aliasing every family in both modes.
|
|
8
|
+
//
|
|
9
|
+
// So the reversal moved here, and the aliases stopped earning their place. What settled it was a bug:
|
|
10
|
+
// the alias block named five positions, a bin map asked for seven buckets, and `--forest-map-density-5`
|
|
11
|
+
// did not exist. An undefined var in a GL paint property does not fail — it falls back to the inherited
|
|
12
|
+
// colour — so the densest cells in London painted GREY while the legend went on showing green. Sampling
|
|
13
|
+
// the engine's own stops cannot land on a colour that does not exist.
|
|
14
|
+
//
|
|
15
|
+
// ★ THE INVERSION IS UNFORGETTABLE BECAUSE IT IS THE ONLY WAY IN. Both `densityLayers` and `heatLayers`
|
|
16
|
+
// build their colours through `rampStops`, so neither a bin map nor a field can ship a ramp that means
|
|
17
|
+
// the opposite thing after dark. That was the property the tokens were bought for, kept without them.
|
|
18
|
+
|
|
19
|
+
/** The engine's sequential families, by the name it gives them. */
|
|
20
|
+
export const SEQUENTIAL_RAMPS = ['forest', 'indigo', 'moss', 'cividis', 'viridis', 'magma'] as const;
|
|
21
|
+
export type SequentialRamp = (typeof SEQUENTIAL_RAMPS)[number];
|
|
22
|
+
|
|
23
|
+
/** How many stops the engine declares per family — the two scientific maps carry one more. */
|
|
24
|
+
export const RAMP_LENGTH: Record<SequentialRamp, number> = {
|
|
25
|
+
forest: 9,
|
|
26
|
+
indigo: 9,
|
|
27
|
+
moss: 9,
|
|
28
|
+
cividis: 10,
|
|
29
|
+
viridis: 10,
|
|
30
|
+
magma: 9,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The engine's diverging families. `redBlue` is the convention the fleet console already paints. */
|
|
34
|
+
export const DIVERGING_RAMPS = ['blueOrange', 'redBlue', 'purpleGreen', 'forestWarm'] as const;
|
|
35
|
+
export type DivergingRamp = (typeof DIVERGING_RAMPS)[number];
|
|
36
|
+
|
|
37
|
+
const kebab = (s: string) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ★ THE CENTRE IS A TOKEN, AND IT IS THE ONE COLOUR NOT ON ANY RAMP.
|
|
41
|
+
*
|
|
42
|
+
* Stop 4 means "no meaningful gap", so it has to vanish against the basemap in both modes. Every
|
|
43
|
+
* family's own centre is a pale neutral, which is right on a light ground and wrong on a dark one —
|
|
44
|
+
* it would make "nothing to report" the loudest thing on the map after dark. `--ui-bg-muted` is near
|
|
45
|
+
* white in light and a dark neutral in dark, so it disappears in both, which is the whole job.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ NOT `--ui-bg`: measured on the running app, that resolves to the same near-white in BOTH modes.
|
|
48
|
+
*/
|
|
49
|
+
export const BALANCE_CENTRE_TOKEN = '--forest-map-balance-centre';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A diverging family's stops, low → centre → high.
|
|
53
|
+
*
|
|
54
|
+
* ★ THE ARMS REVERSE WITHIN THEMSELVES IN DARK MODE; THE HUES NEVER SWAP SIDES. Reversing the whole
|
|
55
|
+
* ramp the way a sequential one reverses would exchange the two arms — deficit would paint as surplus,
|
|
56
|
+
* which is the single error this layer must never make. Each arm is reversed in place instead, so the
|
|
57
|
+
* strongest reading stays furthest from the ground in both modes and the direction stays put.
|
|
58
|
+
*/
|
|
59
|
+
export function divergingStops(ramp: DivergingRamp, invert = prefersInvertedRamp()): string[] {
|
|
60
|
+
const name = kebab(ramp);
|
|
61
|
+
const arm = (from: number, to: number) => {
|
|
62
|
+
const idx = Array.from({ length: 4 }, (_, i) => from + i * Math.sign(to - from));
|
|
63
|
+
return (invert ? [...idx].reverse() : idx).map((i) => `--dataviz-diverging-${name}-${i}`);
|
|
64
|
+
};
|
|
65
|
+
return [...arm(0, 3), BALANCE_CENTRE_TOKEN, ...arm(5, 8)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ★ READING THE DOM HERE IS CORRECT, not a leak. `useMapOverlay` re-invokes the whole spec on a
|
|
70
|
+
* colour-mode change (`onTokenModeChange(run)` → `run()` → `spec()`), so a builder that reads the mode
|
|
71
|
+
* at call time is re-run with the new one. In node there is no document and no dark mode; the light
|
|
72
|
+
* order is the right answer for a test.
|
|
73
|
+
*/
|
|
74
|
+
export const prefersInvertedRamp = (): boolean => {
|
|
75
|
+
if (typeof document === 'undefined') return false;
|
|
76
|
+
const root = document.documentElement;
|
|
77
|
+
return root.classList.contains('dark') || root.dataset.theme === 'dark';
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `n` token names sampled evenly across a family, low → high, reversed when the page is dark so that
|
|
82
|
+
* further from the ground always means more.
|
|
83
|
+
*
|
|
84
|
+
* Omit `n` for every stop the family declares — which is what a continuous gradient wants, and what a
|
|
85
|
+
* bucketed layer must not have.
|
|
86
|
+
*/
|
|
87
|
+
export function rampStops(ramp: SequentialRamp, n?: number, invert = prefersInvertedRamp()): string[] {
|
|
88
|
+
const len = RAMP_LENGTH[ramp];
|
|
89
|
+
const count = Math.max(2, Math.min(Math.round(n ?? len), len));
|
|
90
|
+
const names = Array.from(
|
|
91
|
+
{ length: count },
|
|
92
|
+
(_, i) => `--dataviz-sequential-${ramp}-${Math.round((i * (len - 1)) / (count - 1))}`,
|
|
93
|
+
);
|
|
94
|
+
return invert ? names.reverse() : names;
|
|
95
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The arithmetic behind region selection — lasso and box — kept out of the composable so it can be
|
|
2
|
+
// tested in node, where packages/ui's suite runs.
|
|
3
|
+
//
|
|
4
|
+
// What lives here: what a gesture MEANS (replace / add / subtract), what a drawn path becomes
|
|
5
|
+
// (a screen-space box to query, a polygon to trim against), and the set algebra that produces the new
|
|
6
|
+
// selection. What does not: pointers, GL, and turf's point-in-polygon — those need a browser and a map.
|
|
7
|
+
import { polygon } from '@turf/helpers';
|
|
8
|
+
|
|
9
|
+
/** What a region gesture does to the selection that already exists. */
|
|
10
|
+
export type RegionMode = 'replace' | 'add' | 'subtract';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which tool the canvas is holding. `off` is a state, not the absence of one — a drag pans, and only
|
|
14
|
+
* the modifier still draws.
|
|
15
|
+
*
|
|
16
|
+
* ★ It exists because a modifier is not reachable on a phone. Gating region selection behind ⌘ makes
|
|
17
|
+
* it a desktop-only feature, so an armed tool is what makes a plain drag select — and once a plain
|
|
18
|
+
* drag can select, `off` has to be something a reader can see and choose rather than an implied
|
|
19
|
+
* default.
|
|
20
|
+
*/
|
|
21
|
+
export type RegionTool = 'off' | 'lasso' | 'box' | 'circle';
|
|
22
|
+
|
|
23
|
+
/** The shape a gesture draws. The modifier path, which has no tool, draws the more general one. */
|
|
24
|
+
export type RegionShape = 'lasso' | 'box' | 'circle';
|
|
25
|
+
|
|
26
|
+
export function shapeForTool(tool: RegionTool): RegionShape {
|
|
27
|
+
return tool === 'off' ? 'lasso' : tool;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A circle from its drag: the press is the CENTRE and the pointer is on the rim.
|
|
32
|
+
*
|
|
33
|
+
* ★ Centre-out rather than corner-to-corner, because a circle has no corners to drag between. Bounding
|
|
34
|
+
* a circle by its box would put the centre halfway along the drag, so the shape would slide away from
|
|
35
|
+
* the point the reader pressed — which for "everything within N of this bay" is exactly the point they
|
|
36
|
+
* were aiming at.
|
|
37
|
+
*/
|
|
38
|
+
export function circleFromPath(path: readonly ScreenPoint[]): { centre: ScreenPoint; radius: number } | null {
|
|
39
|
+
const centre = path[0];
|
|
40
|
+
const rim = path[path.length - 1];
|
|
41
|
+
if (!centre || !rim) return null;
|
|
42
|
+
const radius = Math.hypot(rim[0] - centre[0], rim[1] - centre[1]);
|
|
43
|
+
return radius > 0 ? { centre, radius } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Screen-space hit test for the circle tool — a distance compare, no polygon needed. */
|
|
47
|
+
export function inCircle(p: ScreenPoint, centre: ScreenPoint, radius: number): boolean {
|
|
48
|
+
return Math.hypot(p[0] - centre[0], p[1] - centre[1]) <= radius;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a plain drag — no modifier — starts a region rather than panning the map. */
|
|
52
|
+
export function armsOnPlainDrag(tool: RegionTool): boolean {
|
|
53
|
+
return tool !== 'off';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A point in SCREEN space (px from the map canvas's top-left), which is what queries take. */
|
|
57
|
+
export type ScreenPoint = [number, number];
|
|
58
|
+
|
|
59
|
+
/** [[minX, minY], [maxX, maxY]] — the shape `queryRenderedFeatures` wants. */
|
|
60
|
+
export type ScreenBox = [ScreenPoint, ScreenPoint];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Smaller than this on both axes and the gesture is a click, not a region.
|
|
64
|
+
*
|
|
65
|
+
* ★ Without a floor, every click on the map runs a box query and replaces the selection — which makes
|
|
66
|
+
* single-select unusable, because <useFeatureSelect>'s click handler and this would both fire on the
|
|
67
|
+
* same press and the last one would win. 4px is under the smallest deliberate drag and above the
|
|
68
|
+
* jitter of a click on a trackpad.
|
|
69
|
+
*/
|
|
70
|
+
export const MIN_REGION_PX = 4;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The modifiers a gesture STARTED with decide what it means.
|
|
74
|
+
*
|
|
75
|
+
* ★ Read once at pointerdown, never at pointerup. Someone who presses shift, drags a lasso and lets
|
|
76
|
+
* go of shift before releasing the button still meant "add to the selection" — reading the end state
|
|
77
|
+
* would silently turn their addition into a replacement and throw the rest away.
|
|
78
|
+
*/
|
|
79
|
+
export function regionMode(e: { shiftKey: boolean; altKey: boolean }): RegionMode {
|
|
80
|
+
// Subtract wins when both are held: of the two it is the destructive reading, and a reader holding
|
|
81
|
+
// an accidental extra modifier is better served by removing too little than replacing everything.
|
|
82
|
+
if (e.altKey) return 'subtract';
|
|
83
|
+
if (e.shiftKey) return 'add';
|
|
84
|
+
return 'replace';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The selection a region produces, given what was already selected.
|
|
89
|
+
*
|
|
90
|
+
* ★ Returns a new Set. The caller's set is live state painted onto GL features, and mutating it here
|
|
91
|
+
* would repaint through a reference nobody expected to change.
|
|
92
|
+
*/
|
|
93
|
+
export function applyMode(
|
|
94
|
+
current: ReadonlySet<string | number>,
|
|
95
|
+
caught: readonly (string | number)[],
|
|
96
|
+
mode: RegionMode,
|
|
97
|
+
): Set<string | number> {
|
|
98
|
+
if (mode === 'replace') return new Set(caught);
|
|
99
|
+
|
|
100
|
+
const next = new Set(current);
|
|
101
|
+
// An empty region is a no-op for both, rather than a clear: lassoing empty water while adding must
|
|
102
|
+
// not throw the selection away, and neither must a subtract that caught nothing.
|
|
103
|
+
if (mode === 'add') for (const id of caught) next.add(id);
|
|
104
|
+
else for (const id of caught) next.delete(id);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The screen-space box enclosing a path — the cheap first pass before any polygon test.
|
|
110
|
+
*
|
|
111
|
+
* ★ For a circle the path is only centre and rim, so its own bounds would be a box from the centre
|
|
112
|
+
* outward — a QUARTER of the circle. `circleBounds` squares it around the centre instead.
|
|
113
|
+
*/
|
|
114
|
+
export function pathBounds(path: readonly ScreenPoint[]): ScreenBox {
|
|
115
|
+
let minX = Infinity;
|
|
116
|
+
let minY = Infinity;
|
|
117
|
+
let maxX = -Infinity;
|
|
118
|
+
let maxY = -Infinity;
|
|
119
|
+
for (const [x, y] of path) {
|
|
120
|
+
if (x < minX) minX = x;
|
|
121
|
+
if (x > maxX) maxX = x;
|
|
122
|
+
if (y < minY) minY = y;
|
|
123
|
+
if (y > maxY) maxY = y;
|
|
124
|
+
}
|
|
125
|
+
return [
|
|
126
|
+
[minX, minY],
|
|
127
|
+
[maxX, maxY],
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The query box for a circle: squared around the centre, not the drag's own bounds. */
|
|
132
|
+
export function circleBounds(centre: ScreenPoint, radius: number): ScreenBox {
|
|
133
|
+
return [
|
|
134
|
+
[centre[0] - radius, centre[1] - radius],
|
|
135
|
+
[centre[0] + radius, centre[1] + radius],
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether a gesture is big enough to have been meant. */
|
|
140
|
+
export function worthQuerying(box: ScreenBox): boolean {
|
|
141
|
+
const [[minX, minY], [maxX, maxY]] = box;
|
|
142
|
+
return maxX - minX > MIN_REGION_PX || maxY - minY > MIN_REGION_PX;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A drawn path as a closed polygon, for trimming the box query down to the shape actually drawn.
|
|
147
|
+
*
|
|
148
|
+
* Null when the path cannot be a ring. Two points is a line, and asking whether a marker is inside a
|
|
149
|
+
* line is not a question with an answer — turf would either throw or quietly say no, and a lasso that
|
|
150
|
+
* silently selects nothing is worse than one that declines to run.
|
|
151
|
+
*
|
|
152
|
+
* ★ The ring is in SCREEN space, not lng/lat, and the markers are tested in screen space too. That is
|
|
153
|
+
* deliberate: a lasso is drawn on the picture, so it should catch what it visually encloses. Doing it
|
|
154
|
+
* in geographic space would disagree with the drawn shape wherever the map is rotated or pitched.
|
|
155
|
+
*/
|
|
156
|
+
export function pathPolygon(path: readonly ScreenPoint[]): GeoJSON.Feature<GeoJSON.Polygon> | null {
|
|
157
|
+
if (path.length < 3) return null;
|
|
158
|
+
const ring: ScreenPoint[] = [...path];
|
|
159
|
+
const first = ring[0];
|
|
160
|
+
const last = ring[ring.length - 1];
|
|
161
|
+
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
|
|
162
|
+
return polygon([ring]);
|
|
163
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// A path, painted. Layer specs as data for useMapOverlay, exactly as zoneLayers() is.
|
|
2
|
+
//
|
|
3
|
+
// There is no FRoute.vue for the same reason there is no FZone.vue: a route is GL-world, and mapbox's
|
|
4
|
+
// style spec is already declarative. Wrapping it in a component per layer would add a lossy second
|
|
5
|
+
// dialect over one that works.
|
|
6
|
+
import { MAP_SLOT, token, type OverlayLayer, type OverlaySource } from './useMapOverlay';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Width ramps, in CSS pixels.
|
|
10
|
+
*
|
|
11
|
+
* ★ Each is the WHOLE value of its paint property. A zoom `interpolate` nested inside arithmetic is
|
|
12
|
+
* rejected by mapbox without an error: `getLayer()` returns undefined and the route simply is not
|
|
13
|
+
* there.
|
|
14
|
+
*/
|
|
15
|
+
export const ROUTE_WIDTH = ['interpolate', ['linear'], ['zoom'], 10, 2, 14, 4, 18, 7] as const;
|
|
16
|
+
export const ROUTE_CASING_WIDTH = ['interpolate', ['linear'], ['zoom'], 10, 4, 14, 7, 18, 11] as const;
|
|
17
|
+
export const TRAIL_WIDTH = ['interpolate', ['linear'], ['zoom'], 10, 1.5, 14, 3, 18, 5] as const;
|
|
18
|
+
|
|
19
|
+
/** GL has no `transparent` keyword for a token to resolve to, so the fade's far end is stated outright. */
|
|
20
|
+
const CLEAR = 'rgba(0, 0, 0, 0)';
|
|
21
|
+
|
|
22
|
+
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The source a route is drawn from.
|
|
26
|
+
*
|
|
27
|
+
* ★ `lineMetrics: true` is not optional. `line-gradient` — which paints both the played/remaining
|
|
28
|
+
* split and the trail's fade — is only valid on a source that carries it, and a layer asking for one
|
|
29
|
+
* without it is rejected in silence.
|
|
30
|
+
*/
|
|
31
|
+
export function routeSource(line: GeoJSON.Feature | GeoJSON.FeatureCollection): OverlaySource {
|
|
32
|
+
return { type: 'geojson', data: line, lineMetrics: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RouteLayerOptions {
|
|
36
|
+
/** the source id holding the route feature */
|
|
37
|
+
source: string;
|
|
38
|
+
/** id prefix, so two routes can share a map. Default `route` */
|
|
39
|
+
prefix?: string;
|
|
40
|
+
/** the line. Default `--ui-primary` — pass a dimmed token when `travelledLayers` paints over it */
|
|
41
|
+
colour?: string;
|
|
42
|
+
/** the outline that lifts the line off the basemap. Default `--ui-bg` */
|
|
43
|
+
casingColour?: string;
|
|
44
|
+
visible?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A casing and a line, in that order.
|
|
49
|
+
*
|
|
50
|
+
* ★ THE ARRAY IS THE PAINT ORDER. `useMapOverlay` inserts by array position rather than by mount
|
|
51
|
+
* sequence for exactly this case: an 8px casing that lands above its 4px line swallows the line.
|
|
52
|
+
*
|
|
53
|
+
* Both sit in `MAP_SLOT.route` — the road surface. A route belongs with the roads and under the marks:
|
|
54
|
+
* it cannot be selected, and nothing about it is the live state of a vehicle.
|
|
55
|
+
*
|
|
56
|
+
* The casing takes the page background because the line has to read as lifted off the basemap in both
|
|
57
|
+
* colour modes, and the background is the one value that flips with the mode by definition. Emissive
|
|
58
|
+
* strength arrives from `useMapOverlay`, which matters here — an unlit line paints at about a fifth of
|
|
59
|
+
* its luminance under the night preset.
|
|
60
|
+
*/
|
|
61
|
+
export function routeLayers(opts: RouteLayerOptions): OverlayLayer[] {
|
|
62
|
+
const {
|
|
63
|
+
source,
|
|
64
|
+
prefix = 'route',
|
|
65
|
+
colour = '--ui-primary',
|
|
66
|
+
casingColour = '--ui-bg',
|
|
67
|
+
visible = true,
|
|
68
|
+
} = opts;
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
id: `${prefix}-casing`,
|
|
73
|
+
type: 'line',
|
|
74
|
+
source,
|
|
75
|
+
slot: MAP_SLOT.route,
|
|
76
|
+
visible,
|
|
77
|
+
layout: { ...ROUND },
|
|
78
|
+
paint: { 'line-color': token(casingColour), 'line-width': ROUTE_CASING_WIDTH },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: `${prefix}-line`,
|
|
82
|
+
type: 'line',
|
|
83
|
+
source,
|
|
84
|
+
slot: MAP_SLOT.route,
|
|
85
|
+
visible,
|
|
86
|
+
layout: { ...ROUND },
|
|
87
|
+
paint: {
|
|
88
|
+
'line-color': token(colour),
|
|
89
|
+
'line-width': ROUTE_WIDTH,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TravelledLayerOptions {
|
|
96
|
+
/** the source id holding the travelled slice — `trackSlice(track, span.start, at)` through `trackLine` */
|
|
97
|
+
source: string;
|
|
98
|
+
prefix?: string;
|
|
99
|
+
/** the travelled line. Default `--ui-primary` */
|
|
100
|
+
colour?: string;
|
|
101
|
+
visible?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What has been played, as its own geometry drawn over the route.
|
|
106
|
+
*
|
|
107
|
+
* ★ THIS IS NOT A GRADIENT, AND THAT IS THE WHOLE POINT. The obvious implementation is one
|
|
108
|
+
* `line-gradient` on the route with a `step` at the playhead — and it is WRONG, because
|
|
109
|
+
* `['line-progress']` indexes DISTANCE along the line while a Forest playhead is a fraction of TIME.
|
|
110
|
+
* The two only agree at constant speed. Feed a time fraction into a distance parameter and every halt
|
|
111
|
+
* and every signal gap pushes the fill behind the mark by exactly the time they consumed: on the
|
|
112
|
+
* reference trip the fill trailed the vehicle by most of a kilometre.
|
|
113
|
+
*
|
|
114
|
+
* Slicing the track instead makes the painted line the travelled path BY CONSTRUCTION. It costs one
|
|
115
|
+
* `setData` per frame on a short feature, which is the same cost the trail already pays, and it drops
|
|
116
|
+
* the `lineMetrics` requirement for this layer entirely.
|
|
117
|
+
*
|
|
118
|
+
* No casing: it is drawn over a route that already has one.
|
|
119
|
+
*/
|
|
120
|
+
export function travelledLayers(opts: TravelledLayerOptions): OverlayLayer[] {
|
|
121
|
+
const { source, prefix = 'travelled', colour = '--ui-primary', visible = true } = opts;
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
id: `${prefix}-line`,
|
|
125
|
+
type: 'line',
|
|
126
|
+
source,
|
|
127
|
+
slot: MAP_SLOT.route,
|
|
128
|
+
visible,
|
|
129
|
+
layout: { ...ROUND },
|
|
130
|
+
paint: { 'line-color': token(colour), 'line-width': ROUTE_WIDTH },
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface TrailLayerOptions {
|
|
136
|
+
source: string;
|
|
137
|
+
prefix?: string;
|
|
138
|
+
/** the solid end, at the mark. Default `--ui-primary` */
|
|
139
|
+
colour?: string;
|
|
140
|
+
visible?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The fading tail behind a moving mark.
|
|
145
|
+
*
|
|
146
|
+
* One layer, no casing: a trail is a hint about where something just was, and giving it a route's
|
|
147
|
+
* weight would make the two compete. It fades to nothing at its oldest end, which is what says
|
|
148
|
+
* "recent" without a legend — and `trackTail` hands over fixes oldest-first, so `line-progress` 0 is
|
|
149
|
+
* the old end.
|
|
150
|
+
*/
|
|
151
|
+
export function trailLayers(opts: TrailLayerOptions): OverlayLayer[] {
|
|
152
|
+
const { source, prefix = 'trail', colour = '--ui-primary', visible = true } = opts;
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
id: `${prefix}-line`,
|
|
156
|
+
type: 'line',
|
|
157
|
+
source,
|
|
158
|
+
slot: MAP_SLOT.route,
|
|
159
|
+
visible,
|
|
160
|
+
layout: { ...ROUND },
|
|
161
|
+
paint: {
|
|
162
|
+
'line-width': TRAIL_WIDTH,
|
|
163
|
+
'line-gradient': ['interpolate', ['linear'], ['line-progress'], 0, CLEAR, 1, token(colour)],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
}
|