@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,807 @@
|
|
|
1
|
+
// How a vehicle reads on a map.
|
|
2
|
+
//
|
|
3
|
+
// ★ THREE INDEPENDENT AXES, not one status enum. This is the thing every surface gets wrong first:
|
|
4
|
+
//
|
|
5
|
+
// state what the operation thinks of the bike (FUNCTIONAL, LOST, SERVICE_WORKSHOP, …)
|
|
6
|
+
// connectivity whether it is talking to us right now (offline: boolean)
|
|
7
|
+
// battery how much charge is left (HIGH | MEDIUM | LOW | CRITICAL)
|
|
8
|
+
//
|
|
9
|
+
// They vary independently, and AVAILABILITY IS DERIVED FROM ALL THREE — never stored, never a state.
|
|
10
|
+
// Verified against a 31,602-vehicle production snapshot (2026-07-24): `isAvailable()` below reproduces
|
|
11
|
+
// the platform's own `available` flag on 31,602 of 31,602 vehicles. So a bike can be perfectly
|
|
12
|
+
// FUNCTIONAL and still not rentable because it is offline or flat, which is exactly the case a single
|
|
13
|
+
// status enum cannot express — and exactly why the marker encodes the three separately.
|
|
14
|
+
//
|
|
15
|
+
// The encoding that falls out of that:
|
|
16
|
+
//
|
|
17
|
+
// disc colour state GROUP — VIVID, because the disc is categorical: which kind of thing is this
|
|
18
|
+
// glyph lost or not — colour finds them, the glyph confirms
|
|
19
|
+
// battery ring SEMANTIC — the OUTER CIRCLE, and a layer can switch it off entirely
|
|
20
|
+
// the mark's edge FIXED — the inner circle's own stroke, always the background colour
|
|
21
|
+
// offline dot the EXCEPTION — nothing at all when the bike is talking
|
|
22
|
+
//
|
|
23
|
+
// Encoding only the exceptions is not minimalism for its own sake: 88.7% of the fleet has fine charge
|
|
24
|
+
// and 80% is online, so signalling the normal case would ink ~25,000 marks to say "nothing happening"
|
|
25
|
+
// and blow the overplotting budget the mass tier runs to.
|
|
26
|
+
|
|
27
|
+
import markerManifest from '../../icons/forest/map/manifest.json';
|
|
28
|
+
// The LOD pair turns an asset threshold into a zoom. `scale.ts` imports nothing, so there is no cycle.
|
|
29
|
+
import { MARKER_MIN_ICON_SIZE, zoomAtIconSize } from './scale';
|
|
30
|
+
|
|
31
|
+
/** Wunder v2 vocabulary. The set the platform actually emits, plus the two it emits but has not adopted. */
|
|
32
|
+
export type VehicleState =
|
|
33
|
+
| 'FUNCTIONAL'
|
|
34
|
+
| 'INSPECT'
|
|
35
|
+
| 'RELOCATE'
|
|
36
|
+
| 'SERVICE_MECHANICS'
|
|
37
|
+
| 'MAINTENANCE'
|
|
38
|
+
| 'SERVICE_WORKSHOP'
|
|
39
|
+
| 'COLLECT'
|
|
40
|
+
| 'SERVICE_ON_SITE'
|
|
41
|
+
| 'LOST'
|
|
42
|
+
| 'OPERATIONAL_HOLD'
|
|
43
|
+
| 'AWAITING_ACTION'
|
|
44
|
+
| 'READY_FOR_DEPLOYMENT'
|
|
45
|
+
| 'RETIRED'
|
|
46
|
+
| 'IMPOUNDED'
|
|
47
|
+
| 'OTHER';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* RENTAL — the fourth axis, and it is NOT a state.
|
|
51
|
+
*
|
|
52
|
+
* ★ THE PLATFORM'S OWN VOCABULARY, not a set invented here. `rentalState` is a field on the vehicle, and
|
|
53
|
+
* these are its values verbatim — so a surface passes the API's string through untranslated. An invented
|
|
54
|
+
* enum needs a mapping table on every caller, and a mapping table is a place for the meaning to drift.
|
|
55
|
+
*
|
|
56
|
+
* AVAILABLE no rental. The vehicle is rentable.
|
|
57
|
+
* RESERVATION a rental in state RESERVATION is attached — held for someone, not yet moving
|
|
58
|
+
* DRIVING a rental is ACTIVE and the vehicle is in DRIVING mode
|
|
59
|
+
* PARKING a rental is ACTIVE and the vehicle is in PARKING mode — the rider paused the trip
|
|
60
|
+
* DELIVERED attached to a booking in state DELIVERED, waiting to become a rental
|
|
61
|
+
*
|
|
62
|
+
* ★ A vehicle with a rental attached is still `FUNCTIONAL`. The state axis says what the OPERATION thinks of
|
|
63
|
+
* the vehicle; rental says what a RIDER is doing with it. They vary independently, which is why this is not
|
|
64
|
+
* another `VehicleState`: adding one there would have made "somebody has it" and "it needs a repair" mutually
|
|
65
|
+
* exclusive, and both are true at once often enough to matter.
|
|
66
|
+
*
|
|
67
|
+
* What the mark draws — THREE cases, because that is what an operator can act on differently:
|
|
68
|
+
*
|
|
69
|
+
* AVAILABLE nothing at all the usual case, and the exception rule says draw nothing
|
|
70
|
+
* taken the disc becomes a GRADIENT, and the glyph becomes the pine
|
|
71
|
+
* PARKING the same, plus the PAUSE MARK in the bottom-right corner
|
|
72
|
+
*
|
|
73
|
+
* ⚠ `DRIVING` and `DELIVERED` draw the same as `RESERVATION`. José scoped this to AVAILABLE, RESERVATION and
|
|
74
|
+
* PARKING, and the other two still arrive from the API — so they take the nearest correct treatment rather
|
|
75
|
+
* than falling through to "available", which would draw a rented vehicle as rentable. If a surface ever needs
|
|
76
|
+
* to tell driving from reserved, that is a new mark, not a new colour.
|
|
77
|
+
*/
|
|
78
|
+
export type RentalState = 'AVAILABLE' | 'RESERVATION' | 'DRIVING' | 'PARKING' | 'DELIVERED';
|
|
79
|
+
|
|
80
|
+
/** the rental states in which somebody already has the vehicle — everything except AVAILABLE */
|
|
81
|
+
export const RENTAL_TAKEN: readonly RentalState[] = ['RESERVATION', 'DRIVING', 'PARKING', 'DELIVERED'];
|
|
82
|
+
|
|
83
|
+
/** the one rental state that adds a corner mark: the rider paused the trip and the vehicle is locked */
|
|
84
|
+
export const RENTAL_PAUSED: RentalState = 'PARKING';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ★ TWO VOCABULARIES, AND ONLY ONE OF THEM IS A WIRE FORMAT (José, 2026-07-26).
|
|
88
|
+
*
|
|
89
|
+
* the VALUE Wunder v2 — `FUNCTIONAL`, `SERVICE_ON_SITE`, … the platform's own enum. Code uses this
|
|
90
|
+
* everywhere: props, expressions, feature properties, `STATE_GROUP`.
|
|
91
|
+
* the LABEL ForestFleet — "Active", "Damaged", … what an operator reads on a Forest surface.
|
|
92
|
+
*
|
|
93
|
+
* A surface NEVER derives the label from the value. `labelOf('SERVICE_ON_SITE')` gives "Service on site",
|
|
94
|
+
* which is the v2 wording and is not what ForestFleet calls it. Use `stateLabel()`.
|
|
95
|
+
*
|
|
96
|
+
* ⚠ SEVERAL LABELS MEAN SOMETHING DIFFERENT FROM THEIR VALUE, which is why this is a table and not a
|
|
97
|
+
* transformation — and why it changes the glyph each state should light:
|
|
98
|
+
*
|
|
99
|
+
* OPERATIONAL_HOLD → "Support & community" not "held back"
|
|
100
|
+
* AWAITING_ACTION → "Investigation" not "waiting"
|
|
101
|
+
* IMPOUNDED → "Storage" not "held by someone else"
|
|
102
|
+
* RETIRED → "Terminated" harder than "retired"
|
|
103
|
+
*/
|
|
104
|
+
export const STATE_LABEL: Record<VehicleState, string> = {
|
|
105
|
+
FUNCTIONAL: 'Active',
|
|
106
|
+
INSPECT: 'Inspect',
|
|
107
|
+
RELOCATE: 'Relocate',
|
|
108
|
+
SERVICE_MECHANICS: 'Staff',
|
|
109
|
+
MAINTENANCE: 'Task in progress',
|
|
110
|
+
SERVICE_WORKSHOP: 'Warehouse',
|
|
111
|
+
COLLECT: 'Damage collect',
|
|
112
|
+
SERVICE_ON_SITE: 'Damaged',
|
|
113
|
+
LOST: 'Missing',
|
|
114
|
+
OPERATIONAL_HOLD: 'Support & community',
|
|
115
|
+
AWAITING_ACTION: 'Investigation',
|
|
116
|
+
READY_FOR_DEPLOYMENT: 'Ready for deployment',
|
|
117
|
+
RETIRED: 'Terminated',
|
|
118
|
+
IMPOUNDED: 'Storage',
|
|
119
|
+
OTHER: 'Other',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** the ForestFleet label for a state. Use this on any surface a person reads. */
|
|
123
|
+
export const stateLabel = (state: VehicleState): string => STATE_LABEL[state] ?? labelOf(state);
|
|
124
|
+
|
|
125
|
+
/** Battery bands. Derived by the platform from state-of-charge against the category's own thresholds. */
|
|
126
|
+
export type BatteryLevel = 'HIGH' | 'MEDIUM' | 'LOW' | 'CRITICAL';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What the RING says about charge — three bands, not four.
|
|
130
|
+
*
|
|
131
|
+
* ★ HIGH and MEDIUM paint the same colour, so the ring collapses them into one `OK` band (José,
|
|
132
|
+
* 2026-07-26). The platform keeps four levels because a rental rule and a table need the difference; a
|
|
133
|
+
* ring 2.4 units wide cannot show it. Two names for one colour invited a reader to look for a distinction
|
|
134
|
+
* that was never drawn, and cost an extra atlas variant per marker for nothing.
|
|
135
|
+
*/
|
|
136
|
+
export type BatteryRingBand = 'OK' | 'LOW' | 'CRITICAL';
|
|
137
|
+
|
|
138
|
+
/** Which ring band each platform level falls in. The only place the four collapse into three. */
|
|
139
|
+
export const BATTERY_RING_BAND: Record<BatteryLevel, BatteryRingBand> = {
|
|
140
|
+
HIGH: 'OK',
|
|
141
|
+
MEDIUM: 'OK',
|
|
142
|
+
LOW: 'LOW',
|
|
143
|
+
CRITICAL: 'CRITICAL',
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The category thresholds in production for `Forest Bike`, read from
|
|
148
|
+
* `category.coreProperties.socLevels`. They live per-category on the platform, so treat these as the
|
|
149
|
+
* current values rather than as constants — and prefer the platform's `batteryLevel` enum over
|
|
150
|
+
* recomputing from `stateOfCharge`, which lags it on ~2% of vehicles.
|
|
151
|
+
*/
|
|
152
|
+
export const SOC_THRESHOLDS = { critical: 6, low: 20, medium: 50 } as const;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Five disc groups, because the disc is the only channel a reader can scan in parallel and five is
|
|
156
|
+
* where a categorical palette stops separating at 3px under colour-vision deficiency.
|
|
157
|
+
*
|
|
158
|
+
* `hold` deliberately takes blue rather than amber, and `damage` red: amber and red are spoken for by
|
|
159
|
+
* the battery ring, and the ring is concentric with the disc. Two rings of the same hue touching each
|
|
160
|
+
* other is one ring. The halo sits OUTSIDE the battery ring, so the mark keeps a background-coloured band
|
|
161
|
+
* against the basemap whatever the charge is.
|
|
162
|
+
*/
|
|
163
|
+
export type StateGroup = 'functional' | 'damage' | 'parked' | 'lost' | 'hold' | 'retired';
|
|
164
|
+
|
|
165
|
+
export const STATE_GROUP: Record<VehicleState, StateGroup> = {
|
|
166
|
+
// Green — on the street and nothing is wrong. The only group a rider can rent from.
|
|
167
|
+
FUNCTIONAL: 'functional',
|
|
168
|
+
INSPECT: 'functional',
|
|
169
|
+
RELOCATE: 'functional',
|
|
170
|
+
// Red — something is wrong, or somebody is finding out what is wrong.
|
|
171
|
+
SERVICE_ON_SITE: 'damage',
|
|
172
|
+
COLLECT: 'damage',
|
|
173
|
+
AWAITING_ACTION: 'damage',
|
|
174
|
+
// Sky — off the street and accounted for. Inventory rather than a problem.
|
|
175
|
+
SERVICE_WORKSHOP: 'parked',
|
|
176
|
+
IMPOUNDED: 'parked',
|
|
177
|
+
READY_FOR_DEPLOYMENT: 'parked',
|
|
178
|
+
// Purple — nobody knows where it is.
|
|
179
|
+
LOST: 'lost',
|
|
180
|
+
// Amber — a person has it on purpose: an engineer, the community team, an internal team.
|
|
181
|
+
MAINTENANCE: 'hold',
|
|
182
|
+
OPERATIONAL_HOLD: 'hold',
|
|
183
|
+
SERVICE_MECHANICS: 'hold',
|
|
184
|
+
OTHER: 'hold',
|
|
185
|
+
// Ink — out of the fleet. Its own group because "dead" is not "parked": one is inventory, the other is gone.
|
|
186
|
+
RETIRED: 'retired',
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** The token each group paints its disc from. Resolved through `token()` like any other GL colour. */
|
|
190
|
+
/**
|
|
191
|
+
* The token each group paints its disc from.
|
|
192
|
+
*
|
|
193
|
+
* ★ Three of them are aliases onto the CVD-safe categorical set, not semantic roles, because the disc
|
|
194
|
+
* has to stay clear of the RING — which owns amber and red, and is concentric with it. Chosen by
|
|
195
|
+
* measuring ΔE against the resolved ring colours in both modes (see forest.css): crimson separates from
|
|
196
|
+
* the critical red at ΔE 33 light / 28 dark, where the obvious choice of `--ui-error` for damage scored
|
|
197
|
+
* ΔE 0 — the same colour as the ring it sits inside.
|
|
198
|
+
*
|
|
199
|
+
* `functional` keeps the brand green: it is 74% of the fleet and the one group that should look like
|
|
200
|
+
* Forest. `parked` takes a NEUTRAL, because "inert" wants grey and a categorical set has none — but it takes
|
|
201
|
+
* its own `--forest-map-parked`, not a text role. A disc is a surface that carries a glyph; `--ui-text-dimmed`
|
|
202
|
+
* is tuned for type on a page, so borrowing it tied the disc to a decision about paragraph contrast.
|
|
203
|
+
*/
|
|
204
|
+
export const STATE_GROUP_COLOUR: Record<StateGroup, string> = {
|
|
205
|
+
functional: '--forest-map-functional',
|
|
206
|
+
damage: '--forest-map-damage',
|
|
207
|
+
hold: '--forest-map-hold',
|
|
208
|
+
parked: '--forest-map-parked',
|
|
209
|
+
lost: '--forest-map-lost',
|
|
210
|
+
retired: '--forest-map-retired',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Which glyph each STATE lights. One per state, not one per group.
|
|
215
|
+
*
|
|
216
|
+
* The disc carries SIX groups. Five are hues, which is where a categorical palette stops separating at
|
|
217
|
+
* marker size. The glyph has no such cap: it is only legible at the street tier, where a reader is
|
|
218
|
+
* looking at one marker rather than scanning a city — so ten glyphs are as easy to tell apart as two.
|
|
219
|
+
* They used to cost ten uploads; now they are ten regions in one file.
|
|
220
|
+
*
|
|
221
|
+
* ★ The Forest bike is reserved for FUNCTIONAL. It is the brand mark and should mean "a working bike
|
|
222
|
+
* you can take", not "a bike-shaped thing in a workshop".
|
|
223
|
+
*/
|
|
224
|
+
export const STATE_GLYPH: Record<VehicleState, string> = {
|
|
225
|
+
FUNCTIONAL: 'bike',
|
|
226
|
+
INSPECT: 'eye',
|
|
227
|
+
RELOCATE: 'relocate',
|
|
228
|
+
MAINTENANCE: 'wrench',
|
|
229
|
+
SERVICE_WORKSHOP: 'warehouse',
|
|
230
|
+
COLLECT: 'truck',
|
|
231
|
+
SERVICE_ON_SITE: 'bandage',
|
|
232
|
+
LOST: 'question',
|
|
233
|
+
OPERATIONAL_HOLD: 'hand',
|
|
234
|
+
AWAITING_ACTION: 'flag',
|
|
235
|
+
READY_FOR_DEPLOYMENT: 'ready',
|
|
236
|
+
RETIRED: 'skull',
|
|
237
|
+
IMPOUNDED: 'container',
|
|
238
|
+
SERVICE_MECHANICS: 'staff',
|
|
239
|
+
OTHER: 'bike', // the fallback rather than its own artwork
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** A colour with zero alpha is how a region is switched off. Params are colours and nothing else. */
|
|
243
|
+
export const PARAM_OFF = 'rgba(0,0,0,0)';
|
|
244
|
+
|
|
245
|
+
/** The icon id and the param names, read from the generator's manifest so they cannot drift. */
|
|
246
|
+
export const MARKER_ICON_ID = markerManifest.id;
|
|
247
|
+
export const MARKER_GLYPH_PARAMS: readonly string[] = markerManifest.params
|
|
248
|
+
.filter((p) => p.kind === 'glyph')
|
|
249
|
+
.map((p) => p.name);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The badge — a fourth region, for a fact the three axes do not carry.
|
|
253
|
+
*
|
|
254
|
+
* ★ SHAPE AND COLOUR, never a glyph. Measured on the real asset: at the 25px crossover the badge is
|
|
255
|
+
* 6.8px across with 4.8px of fill inside a 1.00px outline, and a glyph in that reads as a lighter patch
|
|
256
|
+
* rather than a mark.
|
|
257
|
+
* A square still reads square at that size and a triangle still reads triangle, so the two shapes are the
|
|
258
|
+
* channel that survives, and colour is the second.
|
|
259
|
+
*
|
|
260
|
+
* FOUR shapes, so a surface can carry four classes of fact and tell them apart. They all inscribe in the same
|
|
261
|
+
* circle, so the corner keeps its clearance whichever one paints, and only one is ever lit. Nothing paints one
|
|
262
|
+
* yet: `claimed` (somebody is already going to this vehicle) is the first intended use, and an open task on the
|
|
263
|
+
* vehicle is the second. See `BADGE_LEGIBILITY` — the star is not a scan-tier shape.
|
|
264
|
+
*
|
|
265
|
+
* ★ The badge takes VIVID colours, like the disc — it is categorical, not a severity. The two defaults are
|
|
266
|
+
* measured as the vivid hues furthest from everything already on the mark: sky is ΔE 46 light / 49 dark from
|
|
267
|
+
* the nearest disc and 92/98 from the ring; pink is 37/38 and 37/42.
|
|
268
|
+
*
|
|
269
|
+
* ⚠ Do NOT paint a badge amber or red. Those belong to the RING (charge) and the DOT (connectivity), which
|
|
270
|
+
* are semantic — a third warm mark on a 25px marker cannot be told from either.
|
|
271
|
+
*/
|
|
272
|
+
export type BadgeShape = 'square' | 'triangle' | 'circle' | 'star';
|
|
273
|
+
|
|
274
|
+
export const BADGE_SHAPES: readonly BadgeShape[] = markerManifest.badge.shapes as readonly BadgeShape[];
|
|
275
|
+
|
|
276
|
+
/** the two params each shape owns: its fill, and the outline that separates it from the disc */
|
|
277
|
+
export const BADGE_PARAMS: Record<BadgeShape, { fill: string; edge: string }> = {
|
|
278
|
+
square: { fill: 'badgeSquare', edge: 'badgeSquareEdge' },
|
|
279
|
+
triangle: { fill: 'badgeTriangle', edge: 'badgeTriangleEdge' },
|
|
280
|
+
circle: { fill: 'badgeCircle', edge: 'badgeCircleEdge' },
|
|
281
|
+
star: { fill: 'badgeStar', edge: 'badgeStarEdge' },
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* How well each shape survives the smallest tier, so a surface can pick one on more than taste.
|
|
286
|
+
*
|
|
287
|
+
* ★ At the crossover a badge is about 8px. A SQUARE and a CIRCLE are solid at that size and tell each other
|
|
288
|
+
* apart by their corners. A TRIANGLE still reads triangular. A STAR does not: each point is under a pixel wide
|
|
289
|
+
* and antialiasing rounds it off, so it becomes a blob — legible when a reader is looking at one vehicle, not
|
|
290
|
+
* when they are scanning. Use the star for a fact that only matters up close.
|
|
291
|
+
*/
|
|
292
|
+
export const BADGE_LEGIBILITY: Record<BadgeShape, 'scan' | 'street'> = {
|
|
293
|
+
square: 'scan',
|
|
294
|
+
circle: 'scan',
|
|
295
|
+
triangle: 'scan',
|
|
296
|
+
star: 'street',
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The badge colours: the VIVID SET, plus the inverted colour. Nothing else.
|
|
301
|
+
*
|
|
302
|
+
* ★ Named as the palette names them (José). An earlier version invented `lime`, `deep` and `paper` for
|
|
303
|
+
* `--acid-300`, `--forest-950` and `--neutral-0` — three colours from outside the categorical set, under three
|
|
304
|
+
* words that exist nowhere else in the system. It measured well and read as a different palette, which is worse
|
|
305
|
+
* than measuring badly: a reader cannot tell whether `deep` is a Forest colour or something a badge invented.
|
|
306
|
+
* The badge draws from the same set as the disc, and `inverted` is the one non-hue, exactly as it is for the
|
|
307
|
+
* `retired` disc.
|
|
308
|
+
*
|
|
309
|
+
* ⚠ MOST OF THESE COLLIDE, and the list does not hide it — see `BADGE_SAFE`. A badge sits on a disc, beside a
|
|
310
|
+
* ring and a dot, and the mark already spends five hues plus amber and red on those. Measured worst-case ΔE
|
|
311
|
+
* against everything on the mark: sky 44, inverted 41, pink 37, then a cliff — blue 24, orange 23, rose 23,
|
|
312
|
+
* purple 21, emerald 10, amber 9, and green / indigo / fuchsia / red at 0 because they ARE disc or ring colours.
|
|
313
|
+
*/
|
|
314
|
+
export const BADGE_COLOURS = {
|
|
315
|
+
green: '--dataviz-categorical-vivid-green',
|
|
316
|
+
emerald: '--dataviz-categorical-vivid-emerald',
|
|
317
|
+
amber: '--dataviz-categorical-vivid-amber',
|
|
318
|
+
orange: '--dataviz-categorical-vivid-orange',
|
|
319
|
+
red: '--dataviz-categorical-vivid-red',
|
|
320
|
+
rose: '--dataviz-categorical-vivid-rose',
|
|
321
|
+
pink: '--dataviz-categorical-vivid-pink',
|
|
322
|
+
fuchsia: '--dataviz-categorical-vivid-fuchsia',
|
|
323
|
+
purple: '--dataviz-categorical-vivid-purple',
|
|
324
|
+
indigo: '--dataviz-categorical-vivid-indigo',
|
|
325
|
+
blue: '--dataviz-categorical-vivid-blue',
|
|
326
|
+
sky: '--dataviz-categorical-vivid-sky',
|
|
327
|
+
inverted: '--ui-bg-inverted',
|
|
328
|
+
} as const;
|
|
329
|
+
|
|
330
|
+
export type BadgeColour = keyof typeof BADGE_COLOURS;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The three that clear the ΔE 33 floor against everything else on the mark. A surface picking outside this list
|
|
334
|
+
* is choosing a badge a reader cannot separate from the disc under it or the ring around it.
|
|
335
|
+
*/
|
|
336
|
+
export const BADGE_SAFE: readonly BadgeColour[] = ['sky', 'inverted', 'pink'];
|
|
337
|
+
|
|
338
|
+
/** the first badge hue — vivid sky, the furthest colour from everything else on the mark */
|
|
339
|
+
export const BADGE_COLOUR = BADGE_COLOURS.sky;
|
|
340
|
+
/** the second, for a second class of fact — vivid pink */
|
|
341
|
+
export const BADGE_COLOUR_ALT = BADGE_COLOURS.pink;
|
|
342
|
+
|
|
343
|
+
/** The badge's outline: the background colour, like every other edge in the asset. */
|
|
344
|
+
export const BADGE_EDGE = '--ui-bg';
|
|
345
|
+
|
|
346
|
+
export interface MarkerBadge {
|
|
347
|
+
shape: BadgeShape;
|
|
348
|
+
/** the fill. Ink by default; never `--ui-error` or `--ui-warning` */
|
|
349
|
+
fill: unknown;
|
|
350
|
+
/** the outline, so the badge separates from the disc behind it. Defaults to the mark's `edge`. */
|
|
351
|
+
edge?: unknown;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** every badge param switched off — the state of the region when a vehicle has nothing to add */
|
|
355
|
+
export const BADGE_OFF: Record<string, unknown> = Object.fromEntries(
|
|
356
|
+
Object.values(BADGE_PARAMS).flatMap((p) => [
|
|
357
|
+
[p.fill, PARAM_OFF],
|
|
358
|
+
[p.edge, PARAM_OFF],
|
|
359
|
+
]),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
export interface MarkerColours {
|
|
363
|
+
/** the disc — the state group */
|
|
364
|
+
body: unknown;
|
|
365
|
+
/** the lit glyph */
|
|
366
|
+
glyph: unknown;
|
|
367
|
+
/**
|
|
368
|
+
* The stroke of the INNER circle — the mark's own edge, and its separation from the basemap. Always the
|
|
369
|
+
* background token. It belongs to the disc, so it survives the battery being switched off.
|
|
370
|
+
*/
|
|
371
|
+
/**
|
|
372
|
+
* The stroke of the INNER circle — the mark's own edge, and its separation from the basemap. Always the
|
|
373
|
+
* background token.
|
|
374
|
+
*
|
|
375
|
+
* ★ THIS IS THE ONLY EDGE A CALLER PASSES. Every other outline in the asset — the battery ring's, the
|
|
376
|
+
* offline dot's, the badge's, the locked mark's — defaults to this value, because they are all the
|
|
377
|
+
* background colour and always will be.
|
|
378
|
+
*
|
|
379
|
+
* ⚠ They are still SEPARATE PARAMS IN THE FILE, and they have to be. An outline belongs to a region that
|
|
380
|
+
* can be switched off, and an SVG stroke does not disappear when its fill goes transparent: a `dotEdge`
|
|
381
|
+
* sharing the always-painted `edge` sentinel would paint a background-coloured ghost ring on the disc of
|
|
382
|
+
* every ONLINE vehicle. Measured — the ghost is real. What CAN share a sentinel is one region's several
|
|
383
|
+
* shapes: `lockedEdge` paints the pause mark's outline and both of its bars.
|
|
384
|
+
*/
|
|
385
|
+
edge: unknown;
|
|
386
|
+
/**
|
|
387
|
+
* The OUTER circle: `fill` is the battery colour a reader sees as a ring.
|
|
388
|
+
*
|
|
389
|
+
* ★ OMIT IT and the whole outer circle disappears — both of its params go transparent together. That is the
|
|
390
|
+
* toggle: a surface that does not care about charge draws no ring at all. The mark then ends at 16 of 20
|
|
391
|
+
* units, so it is simply smaller — do NOT rescale to compensate, because one `icon-size` ramp shared by
|
|
392
|
+
* every surface is worth more than a constant footprint.
|
|
393
|
+
*/
|
|
394
|
+
battery?: { fill: unknown; edge?: unknown };
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The offline dot. OMIT IT for a vehicle that is reporting — the fill and the outline go transparent
|
|
398
|
+
* together, which is the only correct way to switch the region off.
|
|
399
|
+
*
|
|
400
|
+
* Same shape as `battery` and `locked` on purpose: all three are optional regions, and all three take a
|
|
401
|
+
* fill plus an outline that defaults to `edge`.
|
|
402
|
+
*/
|
|
403
|
+
dot?: { fill: unknown; edge?: unknown };
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The IN-USE gradient's two stops. Omit and the disc keeps `body`: both stops go transparent together and
|
|
407
|
+
* the gradient circle paints nothing.
|
|
408
|
+
*
|
|
409
|
+
* ⚠ A gradient cannot be a param — a param is one colour — so the gradient's GEOMETRY is baked into the
|
|
410
|
+
* asset and only its stops are addressable. Pass the same colour twice for a flat in-use disc.
|
|
411
|
+
*/
|
|
412
|
+
inUse?: { from: unknown; to: unknown };
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The pause mark, bottom-right, when the rider has LOCKED the vehicle. `edge` paints its outline AND its
|
|
416
|
+
* two bars — one region, three shapes.
|
|
417
|
+
*/
|
|
418
|
+
locked?: { fill: unknown; edge?: unknown };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The params for ONE vehicle. Every region is set explicitly — an omitted param falls back to its
|
|
423
|
+
* magenta sentinel, so a complete object is the only safe kind, and there are fifteen of them.
|
|
424
|
+
* Hand-writing that is the thing this exists to prevent.
|
|
425
|
+
*/
|
|
426
|
+
export function markerParams(
|
|
427
|
+
state: VehicleState,
|
|
428
|
+
colours: MarkerColours,
|
|
429
|
+
badge?: MarkerBadge,
|
|
430
|
+
rental: RentalState = 'AVAILABLE',
|
|
431
|
+
): Record<string, unknown> {
|
|
432
|
+
// ★ RENTAL WINS THE GLYPH. A vehicle a rider is on is FUNCTIONAL, so the state would light the bike — but
|
|
433
|
+
// "a rider has this" is the more useful fact at the moment somebody is reading one marker, and the disc has
|
|
434
|
+
// already changed underneath it. The state's own glyph comes back the instant the rental ends.
|
|
435
|
+
const lit = RENTAL_TAKEN.includes(rental) ? RENTAL_GLYPH : STATE_GLYPH[state];
|
|
436
|
+
const params: Record<string, unknown> = {
|
|
437
|
+
body: colours.body,
|
|
438
|
+
edge: colours.edge,
|
|
439
|
+
// the in-use gradient, off unless the caller passes both stops
|
|
440
|
+
inUseFrom: colours.inUse?.from ?? PARAM_OFF,
|
|
441
|
+
inUseTo: colours.inUse?.to ?? PARAM_OFF,
|
|
442
|
+
// the locked pause mark, off unless the caller asks for it
|
|
443
|
+
locked: colours.locked?.fill ?? PARAM_OFF,
|
|
444
|
+
lockedEdge: colours.locked ? (colours.locked.edge ?? colours.edge) : PARAM_OFF,
|
|
445
|
+
// the outer circle, off unless a caller asks for it
|
|
446
|
+
battery: colours.battery?.fill ?? PARAM_OFF,
|
|
447
|
+
// each optional region's outline defaults to the mark's own edge — one value, passed once
|
|
448
|
+
batteryEdge: colours.battery ? (colours.battery.edge ?? colours.edge) : PARAM_OFF,
|
|
449
|
+
// ⚠ The outline goes transparent WITH the fill. An SVG stroke does not disappear when its fill does, so a
|
|
450
|
+
// dot switched off by its fill alone leaves a background-coloured ghost ring on the disc.
|
|
451
|
+
dot: colours.dot?.fill ?? PARAM_OFF,
|
|
452
|
+
dotEdge: colours.dot ? (colours.dot.edge ?? colours.edge) : PARAM_OFF,
|
|
453
|
+
// Both shapes off by default. A caller that wants one turns exactly that one on, so a badge can never
|
|
454
|
+
// paint two shapes over each other.
|
|
455
|
+
...BADGE_OFF,
|
|
456
|
+
};
|
|
457
|
+
if (badge) {
|
|
458
|
+
const region = BADGE_PARAMS[badge.shape];
|
|
459
|
+
params[region.fill] = badge.fill;
|
|
460
|
+
params[region.edge] = badge.edge ?? colours.edge;
|
|
461
|
+
}
|
|
462
|
+
for (const name of MARKER_GLYPH_PARAMS) params[name] = name === lit ? colours.glyph : PARAM_OFF;
|
|
463
|
+
return params;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The whole `icon-image` for a layer that draws EVERY vehicle from ONE symbol layer.
|
|
468
|
+
*
|
|
469
|
+
* This is what the marker collapses to now that expression-valued params are verified working
|
|
470
|
+
* (MAPS-SPEC M10): each param becomes a `match` on the feature, so there are no separate ring or dot
|
|
471
|
+
* layers, no radius ramps to keep in step between them, and nothing to get out of paint order.
|
|
472
|
+
*
|
|
473
|
+
* Callers pass resolved colours — `token()` values through `useMapOverlay`, or `resolveMapColor`
|
|
474
|
+
* output when driving the instance directly.
|
|
475
|
+
*/
|
|
476
|
+
export function markerImageExpression(opts: {
|
|
477
|
+
/** group → colour, from the VIVID set: { functional: token('--forest-map-functional'), … } */
|
|
478
|
+
group: Record<StateGroup, unknown>;
|
|
479
|
+
/** the glyph colour, usually the background token */
|
|
480
|
+
glyph: unknown;
|
|
481
|
+
/** the inner circle's stroke — the mark's own edge. `token('--ui-bg')` */
|
|
482
|
+
edge: unknown;
|
|
483
|
+
/**
|
|
484
|
+
* The outer circle, per battery band. Omit it (or pass `false`) and no vehicle gets a ring: both of its
|
|
485
|
+
* params go transparent, and the mark is the inner circle alone.
|
|
486
|
+
*/
|
|
487
|
+
battery?: { fill: Record<BatteryRingBand, unknown>; edge: unknown } | false;
|
|
488
|
+
/** the offline dot. `edge` defaults to the mark's own `edge`, like every other optional region */
|
|
489
|
+
dot: { fill: unknown; edge?: unknown };
|
|
490
|
+
/**
|
|
491
|
+
* The badge, per feature. `when` is any expression that evaluates truthy for the vehicles that should
|
|
492
|
+
* carry it — `['get', 'claimed']`, or `['>', ['get', 'openTasks'], 0]`. Omit it and both shapes stay off.
|
|
493
|
+
*/
|
|
494
|
+
badge?: { shape: BadgeShape; fill: unknown; edge: unknown; when: unknown };
|
|
495
|
+
/**
|
|
496
|
+
* The RENTAL axis. Omit it and every vehicle draws as free: no gradient, no pause mark, and the glyph
|
|
497
|
+
* stays the one the state asked for.
|
|
498
|
+
*
|
|
499
|
+
* The feature property is read as a `RentalState` string — the platform's value, untranslated. `inUse` is
|
|
500
|
+
* the gradient's two stops, `locked` is the pause mark. Pass both: PARKING draws the gradient as well.
|
|
501
|
+
*/
|
|
502
|
+
rental?: { inUse: { from: unknown; to: unknown }; locked: { fill: unknown; edge: unknown } };
|
|
503
|
+
/** feature properties to read. Defaults match the fleet fixture */
|
|
504
|
+
props?: { state?: string; battery?: string; offline?: string; rental?: string };
|
|
505
|
+
/**
|
|
506
|
+
* Emit the LOD PAIR instead of one image: the same icon twice, glyphs off in the first. Pair it with
|
|
507
|
+
* `icon-image-cross-fade: markerGlyphCrossFade()`, or the layer pins itself to the glyph-less variant.
|
|
508
|
+
*/
|
|
509
|
+
lod?: boolean;
|
|
510
|
+
}): unknown[] {
|
|
511
|
+
const stateProp = ['get', opts.props?.state ?? 'state'];
|
|
512
|
+
const batteryProp = ['get', opts.props?.battery ?? 'battery'];
|
|
513
|
+
const offlineProp = ['get', opts.props?.offline ?? 'offline'];
|
|
514
|
+
const rentalProp = ['get', opts.props?.rental ?? 'rental'];
|
|
515
|
+
const states = Object.keys(STATE_GLYPH) as VehicleState[];
|
|
516
|
+
// Every taken state paints the gradient; only PARKING paints the corner mark.
|
|
517
|
+
const onRental = ['match', rentalProp, [...RENTAL_TAKEN], true, false];
|
|
518
|
+
const onLocked = ['==', rentalProp, RENTAL_PAUSED];
|
|
519
|
+
|
|
520
|
+
const params: Record<string, unknown> = {
|
|
521
|
+
// disc: state → its group's colour
|
|
522
|
+
body: [
|
|
523
|
+
'match',
|
|
524
|
+
stateProp,
|
|
525
|
+
...states.flatMap((st) => [st, opts.group[STATE_GROUP[st]]]),
|
|
526
|
+
opts.group.hold,
|
|
527
|
+
],
|
|
528
|
+
// the inner circle's stroke. One value, no expression: the mark's edge never varies by feature.
|
|
529
|
+
edge: opts.edge,
|
|
530
|
+
// the outer circle. Both params off together when a surface does not draw battery at all.
|
|
531
|
+
battery: opts.battery
|
|
532
|
+
? ['match', batteryProp, 'CRITICAL', opts.battery.fill.CRITICAL, 'LOW', opts.battery.fill.LOW, opts.battery.fill.OK]
|
|
533
|
+
: PARAM_OFF,
|
|
534
|
+
batteryEdge: opts.battery ? opts.battery.edge : PARAM_OFF,
|
|
535
|
+
// dot: drawn only when the vehicle has stopped reporting
|
|
536
|
+
dot: ['case', ['to-boolean', offlineProp], opts.dot.fill, PARAM_OFF],
|
|
537
|
+
dotEdge: ['case', ['to-boolean', offlineProp], opts.dot.edge ?? opts.edge, PARAM_OFF],
|
|
538
|
+
// the in-use gradient: both stops together, on for a rental of any kind
|
|
539
|
+
inUseFrom: opts.rental ? ['case', onRental, opts.rental.inUse.from, PARAM_OFF] : PARAM_OFF,
|
|
540
|
+
inUseTo: opts.rental ? ['case', onRental, opts.rental.inUse.to, PARAM_OFF] : PARAM_OFF,
|
|
541
|
+
// the pause mark: only when the rider has locked it
|
|
542
|
+
locked: opts.rental ? ['case', onLocked, opts.rental.locked.fill, PARAM_OFF] : PARAM_OFF,
|
|
543
|
+
lockedEdge: opts.rental ? ['case', onLocked, opts.rental.locked.edge, PARAM_OFF] : PARAM_OFF,
|
|
544
|
+
// badge: both shapes off, then the chosen one switched on for the features `when` selects
|
|
545
|
+
...BADGE_OFF,
|
|
546
|
+
};
|
|
547
|
+
if (opts.badge) {
|
|
548
|
+
const region = BADGE_PARAMS[opts.badge.shape];
|
|
549
|
+
const on = ['to-boolean', opts.badge.when];
|
|
550
|
+
params[region.fill] = ['case', on, opts.badge.fill, PARAM_OFF];
|
|
551
|
+
params[region.edge] = ['case', on, opts.badge.edge, PARAM_OFF];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Each glyph lights for the states that use it, and is transparent for the rest.
|
|
555
|
+
//
|
|
556
|
+
// ★ With a rental layer the pine OVERRIDES the state's glyph, so every state glyph has to be suppressed
|
|
557
|
+
// while a rider has the vehicle — otherwise the bike and the pine paint on top of each other, which is
|
|
558
|
+
// exactly the tangle the one-glyph-at-a-time rule exists to prevent.
|
|
559
|
+
const rentalPine = opts.rental ? 'pine' : null;
|
|
560
|
+
for (const name of MARKER_GLYPH_PARAMS) {
|
|
561
|
+
if (name === rentalPine) {
|
|
562
|
+
params[name] = ['case', onRental, opts.glyph, PARAM_OFF];
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const users = states.filter((st) => STATE_GLYPH[st] === name);
|
|
566
|
+
const byState: unknown = users.length
|
|
567
|
+
? ['match', stateProp, ...users.flatMap((st) => [st, opts.glyph]), PARAM_OFF]
|
|
568
|
+
: PARAM_OFF;
|
|
569
|
+
params[name] = rentalPine ? ['case', onRental, PARAM_OFF, byState] : byState;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ★★ THE LOD PAIR. The mark is drawn from `icon-size` 0.625, but the glyph — which paints
|
|
573
|
+
// strokeWidth × scale, not strokeWidth — only reaches a full pixel at 0.694. Between those two the
|
|
574
|
+
// disc, the edge and the ring are all fine and the glyph is a smudge.
|
|
575
|
+
//
|
|
576
|
+
// So the layer carries the SAME icon twice, once with every glyph switched off, and blends between
|
|
577
|
+
// them. A region is switched off by a transparent param, so the "coarse" variant needs no artwork of
|
|
578
|
+
// its own — which is what makes the two variants register perfectly: the disc, the edge and the ring
|
|
579
|
+
// are literally the same pixels, so the blend moves the GLYPH and nothing else.
|
|
580
|
+
//
|
|
581
|
+
// ⚠ `icon-image-cross-fade` takes only `measure-light` and `zoom` — no `feature`. The pair is a
|
|
582
|
+
// camera-driven LOD and can never vary per vehicle. Pair this with markerGlyphCrossFade().
|
|
583
|
+
if (!opts.lod) return ['image', MARKER_ICON_ID, { params }];
|
|
584
|
+
const coarse: Record<string, unknown> = { ...params };
|
|
585
|
+
for (const name of MARKER_GLYPH_PARAMS) coarse[name] = PARAM_OFF;
|
|
586
|
+
return ['image', MARKER_ICON_ID, { params: coarse }, MARKER_ICON_ID, { params }];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The cross-fade that brings the glyph in, for a layer using `markerImageExpression({ lod: true })`.
|
|
591
|
+
*
|
|
592
|
+
* ★ DERIVED END TO END. The asset says at which `icon-size` its glyph reaches the floor
|
|
593
|
+
* (`manifest.glyph.floorIconSize`); the ramp says which zoom that is. Nobody types a zoom, so moving
|
|
594
|
+
* the ramp or re-drawing the glyph moves this with them.
|
|
595
|
+
*
|
|
596
|
+
* ⚠ Drive it with THIS expression, never a constant — the property is camera-parameterised, and a
|
|
597
|
+
* bare number pins every vehicle to one variant for good.
|
|
598
|
+
*/
|
|
599
|
+
export function markerGlyphCrossFade(): unknown[] {
|
|
600
|
+
return [
|
|
601
|
+
'interpolate',
|
|
602
|
+
['linear'],
|
|
603
|
+
['zoom'],
|
|
604
|
+
zoomAtIconSize(MARKER_MIN_ICON_SIZE),
|
|
605
|
+
0,
|
|
606
|
+
zoomAtIconSize(markerManifest.glyph.floorIconSize),
|
|
607
|
+
1,
|
|
608
|
+
];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Paint the real asset for the browser: substitute each sentinel with the colour its param carries, and
|
|
613
|
+
* hand back the artwork.
|
|
614
|
+
*
|
|
615
|
+
* ★ This is how a docs surface shows the marker WITHOUT redrawing it. A redrawing has to duplicate the
|
|
616
|
+
* glyph paths and the geometry, and then it drifts — silently, because a diagram cannot fail a build.
|
|
617
|
+
* Fetch `/map-icons/<manifest.file>`, run it through here, and what is on screen is the file, painted
|
|
618
|
+
* exactly the way Mapbox will paint it.
|
|
619
|
+
*
|
|
620
|
+
* `inner: true` strips the root `<svg>` and the `<m:metadata>` block, leaving artwork that can be
|
|
621
|
+
* dropped inside a `<g>` in some other coordinate space — which is what lets a diagram scale it up and
|
|
622
|
+
* annotate it. The metadata has to go: it declares the sentinels, so leaving it in would put unpainted
|
|
623
|
+
* magenta values back into the markup.
|
|
624
|
+
*
|
|
625
|
+
* ★ `idPrefix` IS REQUIRED WHEN A PAGE INLINES MORE THAN ONE COPY. The asset carries a `<radialGradient>`
|
|
626
|
+
* with an id, and an id is unique per DOCUMENT — which is fine in Mapbox, where each icon is its own
|
|
627
|
+
* document, and broken in a browser, where sixty inlined markers all resolve `url(#inUse)` to the FIRST
|
|
628
|
+
* one's stops. The symptom is subtle and uniform: every gradient paints whatever the first marker on the
|
|
629
|
+
* page happens to be, so a panel of variants silently shows one variant. Pass a unique prefix per instance.
|
|
630
|
+
*/
|
|
631
|
+
export function paintMarkerSvg(
|
|
632
|
+
raw: string,
|
|
633
|
+
params: Record<string, string>,
|
|
634
|
+
opts: { inner?: boolean; idPrefix?: string } = {},
|
|
635
|
+
): string {
|
|
636
|
+
let svg = raw;
|
|
637
|
+
// ★ The name→sentinel table comes from the FILE, not from a manifest this module imports. Every asset
|
|
638
|
+
// declares its own params in `<m:metadata>`, and the sentinels are positional — so the vehicle's fourth
|
|
639
|
+
// sentinel and a place asset's fourth sentinel are the same hex with different names. Reading the
|
|
640
|
+
// vehicle's manifest here painted a place's glyphs with the vehicle's param names, which resolved to
|
|
641
|
+
// nothing and left every glyph transparent.
|
|
642
|
+
const declared = [...raw.matchAll(/<m:parameter[^>]*m:name="([^"]+)"[^>]*m:value="([^"]+)"/g)].map((m) => ({
|
|
643
|
+
name: m[1],
|
|
644
|
+
sentinel: m[2],
|
|
645
|
+
}));
|
|
646
|
+
const table = declared.length ? declared : markerManifest.params;
|
|
647
|
+
for (const p of table) {
|
|
648
|
+
svg = svg.replaceAll(p.sentinel, params[p.name] ?? PARAM_OFF);
|
|
649
|
+
}
|
|
650
|
+
if (opts.idPrefix) {
|
|
651
|
+
// Rewrite every declared id and every reference to it. Both halves have to move together, so this is one
|
|
652
|
+
// pass over the two forms the asset can use: `id="x"` and `url(#x)`.
|
|
653
|
+
svg = svg
|
|
654
|
+
.replace(/\sid="([^"]+)"/g, (_m, id) => ` id="${opts.idPrefix}-${id}"`)
|
|
655
|
+
.replace(/url\(#([^)]+)\)/g, (_m, id) => `url(#${opts.idPrefix}-${id})`);
|
|
656
|
+
}
|
|
657
|
+
if (!opts.inner) return svg;
|
|
658
|
+
return svg
|
|
659
|
+
.replace(/<m:metadata>[\s\S]*?<\/m:metadata>/, '')
|
|
660
|
+
.replace(/^[\s\S]*?<svg[^>]*>/, '')
|
|
661
|
+
.replace(/<\/svg>\s*$/, '')
|
|
662
|
+
.trim();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** the asset's own viewBox size, so a caller scaling it does not hard-code 40 */
|
|
666
|
+
export const MARKER_CANVAS = markerManifest.size;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* The battery ring — the outer circle's fill, in THREE bands.
|
|
670
|
+
*
|
|
671
|
+
* ★ SEMANTIC, because the ring says how bad the charge is: green when there is nothing to do, amber when
|
|
672
|
+
* the bike wants collecting soon, red when it wants collecting now. A surface that does not dispatch on
|
|
673
|
+
* charge switches the whole outer circle off instead of painting it a quiet colour.
|
|
674
|
+
*/
|
|
675
|
+
export const BATTERY_RING: Record<BatteryRingBand, string> = {
|
|
676
|
+
OK: '--ui-success',
|
|
677
|
+
LOW: '--ui-warning',
|
|
678
|
+
CRITICAL: '--ui-error',
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/** the ring colour for a platform level, through the three bands */
|
|
682
|
+
export const batteryRingToken = (level: BatteryLevel): string => BATTERY_RING[BATTERY_RING_BAND[level]];
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The IN-USE disc — a two-stop radial gradient, vivid amber into vivid green.
|
|
686
|
+
*
|
|
687
|
+
* ★ Chosen by search, not by taste: 824 ramp pairs, each scored on the WORST distance to everything else on
|
|
688
|
+
* the mark (five discs and all three ring bands, because the ring is concentric with the disc). This pair
|
|
689
|
+
* scores ΔE 42.7 at its worst, against a floor of 33 — and the two collisions already shipped are 17 and 7.
|
|
690
|
+
*
|
|
691
|
+
* Why a gradient at all, when a flat `acid-700` measures BETTER (44.7): the flat colour wins on separation
|
|
692
|
+
* and the gradient wins on being unmistakable. It is the one mark on the map that says "somebody is riding
|
|
693
|
+
* this", and the sweep reads as alive where a flat chip does not.
|
|
694
|
+
*
|
|
695
|
+
* ⚠ The brightness is capped by the GLYPH, not by the palette. The pine is painted in the background colour,
|
|
696
|
+
* so a brighter disc loses it: above roughly L 60 the mean drops under 3:1 and the mark reads as a blank chip
|
|
697
|
+
* with a ghost in it. This pair sits at L 54.7 with 3.70 — bright, with margin.
|
|
698
|
+
*
|
|
699
|
+
* ★ VERIFIED ON THE PUBLISHED STYLE (2026-07-26, mapbox-gl 3.24.1). The gradient renders AND param
|
|
700
|
+
* substitution reaches a `stop-color`: the disc reads as a monotonic ramp, 231,190,8 → 121,206,60 across nine
|
|
701
|
+
* samples, with the sentinel nowhere. `IN_USE_FLAT` therefore stops being a pending risk and becomes an
|
|
702
|
+
* option — a surface that wants a flat in-use disc points both stops at it.
|
|
703
|
+
*/
|
|
704
|
+
export const IN_USE_GRADIENT = { from: '--forest-map-inuse-from', to: '--forest-map-inuse-to' } as const;
|
|
705
|
+
|
|
706
|
+
/** the fallback if the renderer will not draw a gradient. Measured best-in-class among flat colours. */
|
|
707
|
+
export const IN_USE_FLAT = '--forest-map-inuse-flat';
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The pause mark a locked rental puts in the bottom-right corner.
|
|
711
|
+
*
|
|
712
|
+
* INK, not vivid and not semantic. It is the strongest value against every disc, and the rental axis is
|
|
713
|
+
* neither a category among the state groups nor a severity — so it takes neither palette.
|
|
714
|
+
*/
|
|
715
|
+
export const LOCKED_MARK = '--forest-map-locked';
|
|
716
|
+
|
|
717
|
+
/** the glyph a taken vehicle lights, whatever its state asked for. AVAILABLE keeps the state's own. */
|
|
718
|
+
export const RENTAL_GLYPH = 'pine';
|
|
719
|
+
|
|
720
|
+
/** the charge levels whose ring is a warning rather than a healthy green */
|
|
721
|
+
export const BATTERY_ALERT: readonly BatteryLevel[] = ['LOW', 'CRITICAL'];
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The offline dot. Red: a vehicle that has stopped reporting is a fault, not a status. It sits on the
|
|
725
|
+
* marker's shoulder rather than on its edge, so it never competes with the red a critical battery puts
|
|
726
|
+
* on the border — different position, different shape, same urgency vocabulary.
|
|
727
|
+
*/
|
|
728
|
+
export const OFFLINE_DOT = '--ui-error';
|
|
729
|
+
|
|
730
|
+
/** The states a bike can be rentable in. Only FUNCTIONAL today; the others are here because the
|
|
731
|
+
* platform has them in its vocabulary and any of them could be adopted without changing this code. */
|
|
732
|
+
export const AVAILABLE_STATES: readonly VehicleState[] = ['FUNCTIONAL'];
|
|
733
|
+
|
|
734
|
+
/** Battery bands that still allow a rental. LOW is deliberately not one — a rider should not start a
|
|
735
|
+
* trip on a bike that is about to need collecting. */
|
|
736
|
+
export const AVAILABLE_BATTERY: readonly BatteryLevel[] = ['HIGH', 'MEDIUM'];
|
|
737
|
+
|
|
738
|
+
export interface VehicleFacts {
|
|
739
|
+
state: VehicleState;
|
|
740
|
+
battery: BatteryLevel;
|
|
741
|
+
offline: boolean;
|
|
742
|
+
/**
|
|
743
|
+
* ⚠ OPTIONAL, and it defaults to AVAILABLE deliberately. The 31,602/31,602 agreement with the platform's own
|
|
744
|
+
* `available` flag was measured BEFORE this axis existed, on a snapshot that carried no rental data — so a
|
|
745
|
+
* caller that passes nothing gets exactly the behaviour that was verified. Pass it and a rented vehicle is
|
|
746
|
+
* correctly unavailable.
|
|
747
|
+
*/
|
|
748
|
+
rental?: RentalState;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Availability, derived. Reproduces the platform's `available` flag on 31,602/31,602 production
|
|
753
|
+
* vehicles — so this is the rule, not an approximation of it.
|
|
754
|
+
*/
|
|
755
|
+
export const isAvailable = (v: VehicleFacts): boolean =>
|
|
756
|
+
!v.offline &&
|
|
757
|
+
(v.rental ?? 'AVAILABLE') === 'AVAILABLE' &&
|
|
758
|
+
AVAILABLE_STATES.includes(v.state) &&
|
|
759
|
+
AVAILABLE_BATTERY.includes(v.battery);
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* EVERY reason a bike is not available, not just the first one. Null when it is available.
|
|
763
|
+
*
|
|
764
|
+
* All three axes can fail at once, and a list that says only "offline" about a bike that is also flat
|
|
765
|
+
* sends someone to do half a job. The marker shows all three simultaneously — the words beside it
|
|
766
|
+
* should too.
|
|
767
|
+
*/
|
|
768
|
+
export function unavailableReasons(v: VehicleFacts): string[] {
|
|
769
|
+
const reasons: string[] = [];
|
|
770
|
+
if (!AVAILABLE_STATES.includes(v.state)) reasons.push(v.state.toLowerCase().replace(/_/g, ' '));
|
|
771
|
+
// A rented vehicle is not "unavailable" in the same sense as a broken one, and the wording says so: the
|
|
772
|
+
// operation does not have to do anything about it.
|
|
773
|
+
if (v.rental && v.rental !== 'AVAILABLE') reasons.push(v.rental.toLowerCase());
|
|
774
|
+
if (v.offline) reasons.push('offline');
|
|
775
|
+
if (!AVAILABLE_BATTERY.includes(v.battery)) reasons.push(`battery ${v.battery.toLowerCase()}`);
|
|
776
|
+
return reasons;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The same, as one phrase for a callout. Null when the bike is available. */
|
|
780
|
+
export function unavailableReason(v: VehicleFacts): string | null {
|
|
781
|
+
const reasons = unavailableReasons(v);
|
|
782
|
+
if (!reasons.length) return null;
|
|
783
|
+
if (reasons.length === 1) return reasons[0];
|
|
784
|
+
return `${reasons.slice(0, -1).join(', ')} and ${reasons.at(-1)}`;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* An enum value as prose: `SERVICE_ON_SITE` → "Service on site", `OK` → "OK".
|
|
789
|
+
*
|
|
790
|
+
* ★ ONE helper, because every surface that shows a state, a group, a band or a rental state was lowercasing it
|
|
791
|
+
* by hand — and lowercasing everything reads as a stylistic tic rather than as a vocabulary. Sentence case:
|
|
792
|
+
* the value is a proper noun in this system's vocabulary, not a label in a form.
|
|
793
|
+
*
|
|
794
|
+
* Short all-caps values are left alone (`OK`), because they are initialisms rather than words.
|
|
795
|
+
*/
|
|
796
|
+
export const labelOf = (value: string): string => {
|
|
797
|
+
if (value.length <= 2) return value;
|
|
798
|
+
const words = value.toLowerCase().replace(/_/g, ' ');
|
|
799
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
/** A style-spec `match` from a feature property to each group's token expression input. */
|
|
803
|
+
export const groupMatchInput = (property = 'state'): unknown[] => {
|
|
804
|
+
const pairs: unknown[] = [];
|
|
805
|
+
for (const [state, group] of Object.entries(STATE_GROUP)) pairs.push(state, group);
|
|
806
|
+
return ['match', ['get', property], ...pairs, 'hold'];
|
|
807
|
+
};
|