@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,284 @@
|
|
|
1
|
+
// One filter, three renderings — and the reason they cannot be written separately.
|
|
2
|
+
//
|
|
3
|
+
// A console filters a fleet in three places at once: the GL layer (30,000 features, filtered on the
|
|
4
|
+
// GPU), the list beside it (JavaScript, over the same features), and the link somebody pastes into
|
|
5
|
+
// Slack (a string). Write those by hand and they drift — the map hides a bike the count still counts,
|
|
6
|
+
// and nobody can see why. This file is the single rule; the three are renderings of it.
|
|
7
|
+
//
|
|
8
|
+
// ★ AND ACROSS AXES, OR WITHIN ONE. `{ groups: ['functional'], battery: ['LOW', 'CRITICAL'] }` is
|
|
9
|
+
// "functional bikes that are flat", which is what a dispatcher means by setting two filters. An absent
|
|
10
|
+
// or empty axis is not a constraint, so `{}` matches the whole fleet.
|
|
11
|
+
//
|
|
12
|
+
// The axes are `vehicle.ts`'s own — state, battery, offline, rental — plus two conveniences: `groups`
|
|
13
|
+
// expands through STATE_GROUP, and `available` is the derived rule.
|
|
14
|
+
import {
|
|
15
|
+
AVAILABLE_BATTERY,
|
|
16
|
+
AVAILABLE_STATES,
|
|
17
|
+
STATE_GROUP,
|
|
18
|
+
type BatteryLevel,
|
|
19
|
+
type RentalState,
|
|
20
|
+
type StateGroup,
|
|
21
|
+
type VehicleFacts,
|
|
22
|
+
type VehicleState,
|
|
23
|
+
} from './vehicle';
|
|
24
|
+
|
|
25
|
+
export interface VehicleFilter {
|
|
26
|
+
/** exact states. Unions with `groups` — both narrow the same axis */
|
|
27
|
+
states?: readonly VehicleState[];
|
|
28
|
+
/** state groups, expanded to their states. The vocabulary the group toggles speak */
|
|
29
|
+
groups?: readonly StateGroup[];
|
|
30
|
+
battery?: readonly BatteryLevel[];
|
|
31
|
+
rental?: readonly RentalState[];
|
|
32
|
+
/** true = only offline, false = only online. Absent = either */
|
|
33
|
+
offline?: boolean;
|
|
34
|
+
/** the derived rule across all four axes. Absent = either */
|
|
35
|
+
available?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Which feature property carries each axis. Defaults are what the fleet fixture writes. */
|
|
39
|
+
export interface VehicleFilterProps {
|
|
40
|
+
state?: string;
|
|
41
|
+
battery?: string;
|
|
42
|
+
offline?: string;
|
|
43
|
+
rental?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_PROPS: Required<VehicleFilterProps> = {
|
|
47
|
+
state: 'state',
|
|
48
|
+
battery: 'battery',
|
|
49
|
+
offline: 'offline',
|
|
50
|
+
rental: 'rental',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Every axis, in the order a filter reads. Exported so a control can iterate without a second list. */
|
|
54
|
+
export const VEHICLE_FILTER_AXES = ['states', 'groups', 'battery', 'rental', 'offline', 'available'] as const;
|
|
55
|
+
export type VehicleFilterAxis = (typeof VEHICLE_FILTER_AXES)[number];
|
|
56
|
+
|
|
57
|
+
/** The axes that hold a list of values — the ones a chip row toggles into. */
|
|
58
|
+
export const VEHICLE_FILTER_LIST_AXES = ['states', 'groups', 'battery', 'rental'] as const;
|
|
59
|
+
export type VehicleFilterListAxis = (typeof VEHICLE_FILTER_LIST_AXES)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The state axis, resolved: exact states unioned with every state in the chosen groups.
|
|
63
|
+
*
|
|
64
|
+
* ★ Groups resolve to STATES rather than filtering a `group` property, even though the fixture writes
|
|
65
|
+
* one. A precomputed property is an assumption about the caller's data, and a wrong assumption here
|
|
66
|
+
* fails silently — the layer filters on a property that is not there and hides the whole fleet. Fifteen
|
|
67
|
+
* strings in an `in` list cost nothing next to the re-tile the filter change triggers anyway.
|
|
68
|
+
*/
|
|
69
|
+
export function filterStates(filter: VehicleFilter): VehicleState[] {
|
|
70
|
+
const out = new Set<VehicleState>(filter.states ?? []);
|
|
71
|
+
if (filter.groups?.length) {
|
|
72
|
+
const wanted = new Set<StateGroup>(filter.groups);
|
|
73
|
+
for (const [state, group] of Object.entries(STATE_GROUP) as [VehicleState, StateGroup][]) {
|
|
74
|
+
if (wanted.has(group)) out.add(state);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...out];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How many axes actually bite — the number on a "Clear" button. */
|
|
81
|
+
export function activeAxisCount(filter: VehicleFilter): number {
|
|
82
|
+
let n = 0;
|
|
83
|
+
if (filter.states?.length || filter.groups?.length) n++;
|
|
84
|
+
if (filter.battery?.length) n++;
|
|
85
|
+
if (filter.rental?.length) n++;
|
|
86
|
+
if (filter.offline !== undefined) n++;
|
|
87
|
+
if (filter.available !== undefined) n++;
|
|
88
|
+
return n;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const isEmptyVehicleFilter = (filter: VehicleFilter): boolean => activeAxisCount(filter) === 0;
|
|
92
|
+
|
|
93
|
+
const oneOf = (property: string, values: readonly string[]): unknown[] => [
|
|
94
|
+
'in',
|
|
95
|
+
['get', property],
|
|
96
|
+
['literal', [...values]],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `offline` as a boolean, with a missing property meaning ONLINE.
|
|
101
|
+
*
|
|
102
|
+
* ★ `['!', ['get', 'offline']]` is not this. `get` on a property a feature does not carry returns
|
|
103
|
+
* null, and `!` on null is an evaluation error rather than `true` — the layer then drops the feature
|
|
104
|
+
* with a console warning nobody reads. Coalesce first, always.
|
|
105
|
+
*/
|
|
106
|
+
const offlineFlag = (property: string): unknown[] => ['coalesce', ['get', property], false];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `rental`, with a missing property meaning AVAILABLE — matching `isAvailable`'s `v.rental ?? 'AVAILABLE'`.
|
|
110
|
+
*
|
|
111
|
+
* ★ THE DIVERGENCE THIS FILE EXISTS TO PREVENT, and it is one operator wide. The 31,602/31,602
|
|
112
|
+
* agreement with the platform's own `available` flag was measured on a snapshot carrying no rental
|
|
113
|
+
* data at all; drop the coalesce and every one of those features becomes unavailable in the expression
|
|
114
|
+
* while staying available in the predicate.
|
|
115
|
+
*/
|
|
116
|
+
const rentalValue = (property: string): unknown[] => ['coalesce', ['get', property], 'AVAILABLE'];
|
|
117
|
+
|
|
118
|
+
/** The availability rule as an expression — `isAvailable`, operator for operator. */
|
|
119
|
+
export function availableExpression(props: VehicleFilterProps = {}): unknown[] {
|
|
120
|
+
const p = { ...DEFAULT_PROPS, ...props };
|
|
121
|
+
return [
|
|
122
|
+
'all',
|
|
123
|
+
['!', offlineFlag(p.offline)],
|
|
124
|
+
['==', rentalValue(p.rental), 'AVAILABLE'],
|
|
125
|
+
oneOf(p.state, AVAILABLE_STATES),
|
|
126
|
+
oneOf(p.battery, AVAILABLE_BATTERY),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The filter as a style-spec expression, for `layer.filter`.
|
|
132
|
+
*
|
|
133
|
+
* An unconstrained filter is `['literal', true]` rather than an empty `all`: it reads as "no filter"
|
|
134
|
+
* in a style dump, and it is what the layer would have carried before any filter existed.
|
|
135
|
+
*/
|
|
136
|
+
export function vehicleFilterExpression(filter: VehicleFilter, props: VehicleFilterProps = {}): unknown[] {
|
|
137
|
+
const p = { ...DEFAULT_PROPS, ...props };
|
|
138
|
+
const clauses: unknown[][] = [];
|
|
139
|
+
|
|
140
|
+
const states = filterStates(filter);
|
|
141
|
+
if (states.length) clauses.push(oneOf(p.state, states));
|
|
142
|
+
if (filter.battery?.length) clauses.push(oneOf(p.battery, filter.battery));
|
|
143
|
+
if (filter.rental?.length) clauses.push(['in', rentalValue(p.rental), ['literal', [...filter.rental]]]);
|
|
144
|
+
if (filter.offline !== undefined) {
|
|
145
|
+
clauses.push(filter.offline ? offlineFlag(p.offline) : ['!', offlineFlag(p.offline)]);
|
|
146
|
+
}
|
|
147
|
+
if (filter.available !== undefined) {
|
|
148
|
+
const rule = availableExpression(props);
|
|
149
|
+
clauses.push(filter.available ? rule : ['!', rule]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return clauses.length ? ['all', ...clauses] : ['literal', true];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The same filter as a predicate, for the list and the counts beside the map.
|
|
157
|
+
*
|
|
158
|
+
* Returns a function rather than taking the vehicle directly: the sets are built once and reused
|
|
159
|
+
* across the whole fleet, which is the difference between a linear scan and a quadratic one.
|
|
160
|
+
*/
|
|
161
|
+
export function vehicleFilterMatch(filter: VehicleFilter): (vehicle: VehicleFacts) => boolean {
|
|
162
|
+
const states = filterStates(filter);
|
|
163
|
+
const stateSet = states.length ? new Set<VehicleState>(states) : null;
|
|
164
|
+
const batterySet = filter.battery?.length ? new Set<BatteryLevel>(filter.battery) : null;
|
|
165
|
+
const rentalSet = filter.rental?.length ? new Set<RentalState>(filter.rental) : null;
|
|
166
|
+
const available = filter.available;
|
|
167
|
+
const offline = filter.offline;
|
|
168
|
+
|
|
169
|
+
return (vehicle: VehicleFacts): boolean => {
|
|
170
|
+
if (stateSet && !stateSet.has(vehicle.state)) return false;
|
|
171
|
+
if (batterySet && !batterySet.has(vehicle.battery)) return false;
|
|
172
|
+
// The predicate's own `?? 'AVAILABLE'` — the same default the expression coalesces to.
|
|
173
|
+
if (rentalSet && !rentalSet.has(vehicle.rental ?? 'AVAILABLE')) return false;
|
|
174
|
+
if (offline !== undefined && !!vehicle.offline !== offline) return false;
|
|
175
|
+
if (available !== undefined) {
|
|
176
|
+
const is =
|
|
177
|
+
!vehicle.offline &&
|
|
178
|
+
(vehicle.rental ?? 'AVAILABLE') === 'AVAILABLE' &&
|
|
179
|
+
AVAILABLE_STATES.includes(vehicle.state) &&
|
|
180
|
+
AVAILABLE_BATTERY.includes(vehicle.battery);
|
|
181
|
+
if (is !== available) return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── The link ────────────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
//
|
|
189
|
+
// `g:functional,damage;b:LOW;o:1` — one key per axis, values comma-separated. Compact enough to sit
|
|
190
|
+
// beside the camera in a query string, and legible enough that a person can see what a link filters
|
|
191
|
+
// before they open it.
|
|
192
|
+
|
|
193
|
+
const AXIS_KEY: Record<VehicleFilterListAxis | 'offline' | 'available', string> = {
|
|
194
|
+
states: 's',
|
|
195
|
+
groups: 'g',
|
|
196
|
+
battery: 'b',
|
|
197
|
+
rental: 'r',
|
|
198
|
+
offline: 'o',
|
|
199
|
+
available: 'a',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const KNOWN_STATES = new Set(Object.keys(STATE_GROUP));
|
|
203
|
+
const KNOWN_GROUPS = new Set(Object.values(STATE_GROUP));
|
|
204
|
+
const KNOWN_BATTERY = new Set(['HIGH', 'MEDIUM', 'LOW', 'CRITICAL']);
|
|
205
|
+
const KNOWN_RENTAL = new Set(['AVAILABLE', 'RESERVATION', 'DRIVING', 'PARKING', 'DELIVERED']);
|
|
206
|
+
|
|
207
|
+
export function encodeVehicleFilter(filter: VehicleFilter): string {
|
|
208
|
+
const parts: string[] = [];
|
|
209
|
+
const list = (key: string, values?: readonly string[]) => {
|
|
210
|
+
if (values?.length) parts.push(`${key}:${values.join(',')}`);
|
|
211
|
+
};
|
|
212
|
+
list(AXIS_KEY.states, filter.states);
|
|
213
|
+
list(AXIS_KEY.groups, filter.groups);
|
|
214
|
+
list(AXIS_KEY.battery, filter.battery);
|
|
215
|
+
list(AXIS_KEY.rental, filter.rental);
|
|
216
|
+
if (filter.offline !== undefined) parts.push(`${AXIS_KEY.offline}:${filter.offline ? 1 : 0}`);
|
|
217
|
+
if (filter.available !== undefined) parts.push(`${AXIS_KEY.available}:${filter.available ? 1 : 0}`);
|
|
218
|
+
return parts.join(';');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The reverse. A URL is user input, so every value is checked against the vocabulary and anything
|
|
223
|
+
* unrecognised is DROPPED rather than carried through.
|
|
224
|
+
*
|
|
225
|
+
* ★ Dropped, not rejected. A link made by an older build can name a state this one no longer has; the
|
|
226
|
+
* reader should get the rest of their filter, not an empty map and no explanation. An axis left with
|
|
227
|
+
* no valid values falls out entirely, which is the same thing as never having been set.
|
|
228
|
+
*/
|
|
229
|
+
export function decodeVehicleFilter(text: string | null | undefined): VehicleFilter {
|
|
230
|
+
const filter: VehicleFilter = {};
|
|
231
|
+
if (!text) return filter;
|
|
232
|
+
|
|
233
|
+
const keep = (values: string[], known: Set<string>) => {
|
|
234
|
+
const out = values.map((v) => v.trim()).filter((v) => known.has(v));
|
|
235
|
+
return out.length ? out : undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
for (const part of text.split(';')) {
|
|
239
|
+
const at = part.indexOf(':');
|
|
240
|
+
if (at < 0) continue;
|
|
241
|
+
const key = part.slice(0, at).trim();
|
|
242
|
+
const raw = part.slice(at + 1).split(',');
|
|
243
|
+
switch (key) {
|
|
244
|
+
case AXIS_KEY.states:
|
|
245
|
+
filter.states = keep(raw, KNOWN_STATES) as VehicleState[] | undefined;
|
|
246
|
+
break;
|
|
247
|
+
case AXIS_KEY.groups:
|
|
248
|
+
filter.groups = keep(raw, KNOWN_GROUPS) as StateGroup[] | undefined;
|
|
249
|
+
break;
|
|
250
|
+
case AXIS_KEY.battery:
|
|
251
|
+
filter.battery = keep(raw, KNOWN_BATTERY) as BatteryLevel[] | undefined;
|
|
252
|
+
break;
|
|
253
|
+
case AXIS_KEY.rental:
|
|
254
|
+
filter.rental = keep(raw, KNOWN_RENTAL) as RentalState[] | undefined;
|
|
255
|
+
break;
|
|
256
|
+
// A flag is 1 or 0. Anything else — including the empty string a stray `o:` leaves — is not a
|
|
257
|
+
// third state, it is a broken link, and the axis goes unset.
|
|
258
|
+
case AXIS_KEY.offline:
|
|
259
|
+
if (raw[0] === '1' || raw[0] === '0') filter.offline = raw[0] === '1';
|
|
260
|
+
break;
|
|
261
|
+
case AXIS_KEY.available:
|
|
262
|
+
if (raw[0] === '1' || raw[0] === '0') filter.available = raw[0] === '1';
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `keep` returns undefined for an all-invalid axis, which assignment above turns into an explicit
|
|
268
|
+
// `states: undefined` key. Strip those so the result equals a filter that was never given the axis.
|
|
269
|
+
for (const axis of VEHICLE_FILTER_LIST_AXES) if (filter[axis] === undefined) delete filter[axis];
|
|
270
|
+
return filter;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Add or remove one value on a list axis, returning a new filter. The verb a chip row calls. */
|
|
274
|
+
export function toggleVehicleFilter<K extends VehicleFilterListAxis>(
|
|
275
|
+
filter: VehicleFilter,
|
|
276
|
+
axis: K,
|
|
277
|
+
value: NonNullable<VehicleFilter[K]>[number],
|
|
278
|
+
): VehicleFilter {
|
|
279
|
+
const current = (filter[axis] ?? []) as readonly string[];
|
|
280
|
+
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
|
281
|
+
const out = { ...filter, [axis]: next } as VehicleFilter;
|
|
282
|
+
if (!next.length) delete out[axis];
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The key to a vehicle mark — one section per axis, because the axes are independent.
|
|
2
|
+
//
|
|
3
|
+
// ★ THIS IS THE SHAPE THE MARK FORCES. `vehicle.ts` opens by saying a vehicle is three independent
|
|
4
|
+
// axes and not one status enum, and that collapsing them is what every surface gets wrong first. A
|
|
5
|
+
// legend can make that same mistake: 6 state groups × 3 charge bands × 2 connectivity states is 36
|
|
6
|
+
// combinations, and a flat list of any subset of them reads as one enum with 36 values.
|
|
7
|
+
//
|
|
8
|
+
// So: a section per axis, and WITHIN a section exactly one channel moves. Charge rows hold the state
|
|
9
|
+
// still so the ring is the only difference; state rows pass no charge at all, so no ring is drawn and
|
|
10
|
+
// the disc is the only difference. A reader can then attribute what they see to the thing it means.
|
|
11
|
+
//
|
|
12
|
+
// ★ THE ROWS DESCRIBE A MARK, NOT A COLOUR. Each row carries the props to draw a real <FVehicleMark>,
|
|
13
|
+
// which needs no map and renders at natural size. The key shows the artwork the map draws rather than
|
|
14
|
+
// a square approximating it — and the artwork is the only honest swatch for a disc that carries a
|
|
15
|
+
// glyph inside a ring.
|
|
16
|
+
import {
|
|
17
|
+
BATTERY_RING_BAND,
|
|
18
|
+
STATE_GROUP,
|
|
19
|
+
labelOf,
|
|
20
|
+
type BatteryLevel,
|
|
21
|
+
type BatteryRingBand,
|
|
22
|
+
type StateGroup,
|
|
23
|
+
type VehicleState,
|
|
24
|
+
} from './vehicle';
|
|
25
|
+
|
|
26
|
+
/** Which axis a section reads. Named for the axes in `vehicle.ts`, not for the channel that paints them. */
|
|
27
|
+
export type VehicleLegendAxis = 'state' | 'charge' | 'connectivity';
|
|
28
|
+
|
|
29
|
+
/** One row: what to draw, and what it means. */
|
|
30
|
+
export interface VehicleLegendRow {
|
|
31
|
+
key: string;
|
|
32
|
+
label: string;
|
|
33
|
+
/** props for <FVehicleMark> — the row draws the real mark rather than a swatch standing in for it */
|
|
34
|
+
mark: { state: VehicleState; battery?: BatteryLevel; offline?: boolean };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VehicleLegendSection {
|
|
38
|
+
axis: VehicleLegendAxis;
|
|
39
|
+
label: string;
|
|
40
|
+
rows: VehicleLegendRow[];
|
|
41
|
+
/**
|
|
42
|
+
* What an ABSENT mark means, where absence is the common case.
|
|
43
|
+
*
|
|
44
|
+
* ★ It cannot be a row. The mark encodes only exceptions — 80% of the fleet is online and paints no
|
|
45
|
+
* dot at all — so the usual case has no swatch to put beside a label. Stating it is the only way to
|
|
46
|
+
* say it, and leaving it unsaid is what makes a reader think the dot is decoration.
|
|
47
|
+
*/
|
|
48
|
+
note?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One state per group, standing for the group.
|
|
53
|
+
*
|
|
54
|
+
* ★ A row draws one mark, and a mark needs a STATE — there is no such thing as drawing a group. The
|
|
55
|
+
* test holds each of these to its group in STATE_GROUP, so a state that is regrouped later cannot
|
|
56
|
+
* leave a row painting a disc that contradicts its own label. The glyph is the representative state's
|
|
57
|
+
* own, which is why rows are labelled by GROUP and the glyph table stays on the component's page.
|
|
58
|
+
*/
|
|
59
|
+
const GROUP_SPECIMEN: Record<StateGroup, VehicleState> = {
|
|
60
|
+
functional: 'FUNCTIONAL',
|
|
61
|
+
damage: 'SERVICE_ON_SITE',
|
|
62
|
+
hold: 'MAINTENANCE',
|
|
63
|
+
parked: 'SERVICE_WORKSHOP',
|
|
64
|
+
lost: 'LOST',
|
|
65
|
+
retired: 'RETIRED',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reading order: the normal case, then the ways it goes wrong, then the ways it leaves.
|
|
70
|
+
*
|
|
71
|
+
* Not the declaration order and not the colour ramp — a reader opens a key to place what they are
|
|
72
|
+
* looking at, and 74% of the fleet is functional.
|
|
73
|
+
*/
|
|
74
|
+
const GROUP_ORDER: readonly StateGroup[] = [
|
|
75
|
+
'functional',
|
|
76
|
+
'damage',
|
|
77
|
+
'hold',
|
|
78
|
+
'parked',
|
|
79
|
+
'lost',
|
|
80
|
+
'retired',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** A level that lands in each band, so the row draws the ring it names. */
|
|
84
|
+
const BAND_SPECIMEN: Record<BatteryRingBand, BatteryLevel> = {
|
|
85
|
+
OK: 'HIGH',
|
|
86
|
+
LOW: 'LOW',
|
|
87
|
+
CRITICAL: 'CRITICAL',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const BAND_ORDER: readonly BatteryRingBand[] = ['OK', 'LOW', 'CRITICAL'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The state every non-state section draws.
|
|
94
|
+
*
|
|
95
|
+
* Holding it at FUNCTIONAL is what makes those sections readable: the disc and glyph stay put, so the
|
|
96
|
+
* ring or the dot is the only thing a reader has to account for.
|
|
97
|
+
*/
|
|
98
|
+
const HELD = 'FUNCTIONAL' as const;
|
|
99
|
+
|
|
100
|
+
const SECTION_LABEL: Record<VehicleLegendAxis, string> = {
|
|
101
|
+
state: 'State',
|
|
102
|
+
charge: 'Charge',
|
|
103
|
+
connectivity: 'Connectivity',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const DEFAULT_AXES: readonly VehicleLegendAxis[] = ['state', 'charge', 'connectivity'];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The legend for a vehicle mark, one section per axis.
|
|
110
|
+
*
|
|
111
|
+
* Pass the axes a surface actually paints. A surface that omits `battery` on its marks draws no ring,
|
|
112
|
+
* so a charge section would explain a channel that is not there.
|
|
113
|
+
*/
|
|
114
|
+
export function vehicleLegendSections(
|
|
115
|
+
axes: readonly VehicleLegendAxis[] = DEFAULT_AXES,
|
|
116
|
+
): VehicleLegendSection[] {
|
|
117
|
+
const wanted = new Set(axes);
|
|
118
|
+
|
|
119
|
+
return DEFAULT_AXES.filter((axis) => wanted.has(axis)).map((axis) => {
|
|
120
|
+
if (axis === 'state') {
|
|
121
|
+
return {
|
|
122
|
+
axis,
|
|
123
|
+
label: SECTION_LABEL[axis],
|
|
124
|
+
rows: GROUP_ORDER.map((group) => ({
|
|
125
|
+
key: group,
|
|
126
|
+
label: labelOf(group),
|
|
127
|
+
// No charge: the ring would be a second variable in a section about the disc.
|
|
128
|
+
mark: { state: GROUP_SPECIMEN[group] },
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (axis === 'charge') {
|
|
134
|
+
return {
|
|
135
|
+
axis,
|
|
136
|
+
label: SECTION_LABEL[axis],
|
|
137
|
+
rows: BAND_ORDER.map((band) => ({
|
|
138
|
+
key: band,
|
|
139
|
+
label: labelOf(band),
|
|
140
|
+
mark: { state: HELD, battery: BAND_SPECIMEN[band] },
|
|
141
|
+
})),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
axis,
|
|
147
|
+
label: SECTION_LABEL[axis],
|
|
148
|
+
rows: [{ key: 'offline', label: 'Offline', mark: { state: HELD, offline: true } }],
|
|
149
|
+
note: 'No dot means the vehicle is reporting.',
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Re-exported so a caller can hold a row's group against the source of truth. */
|
|
155
|
+
export { STATE_GROUP, BATTERY_RING_BAND };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// What is on screen — the arithmetic, without the map.
|
|
2
|
+
//
|
|
3
|
+
// A source query is a superset of the answer: one copy of a feature PER TILE for anything on a seam,
|
|
4
|
+
// and features outside the viewport because a tile extends past the edge of it. So the list a reader
|
|
5
|
+
// sees comes out of three steps in this order — dedupe, keep what the viewport holds, cap — and each
|
|
6
|
+
// of them is a place a hand-rolled version goes wrong.
|
|
7
|
+
|
|
8
|
+
export interface ViewBox {
|
|
9
|
+
west: number;
|
|
10
|
+
south: number;
|
|
11
|
+
east: number;
|
|
12
|
+
north: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VisibleFeature {
|
|
16
|
+
/** stable across tiles and across reads — what a keyed `v-for` and a table row id use */
|
|
17
|
+
key: string;
|
|
18
|
+
id: string | number | undefined;
|
|
19
|
+
feature: GeoJSON.Feature;
|
|
20
|
+
/** the point it sits at, or the centre of its extent for a line or a polygon */
|
|
21
|
+
at: [number, number];
|
|
22
|
+
props: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CollectOptions {
|
|
26
|
+
/** feature property to key by, when the feature carries no id of its own */
|
|
27
|
+
idBy?: string;
|
|
28
|
+
/** how many to publish. The rest are counted, not returned */
|
|
29
|
+
limit?: number;
|
|
30
|
+
/** keep cluster features. Off, because "40 bikes" is not a row anyone can act on */
|
|
31
|
+
clusters?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 500 rows is already more than a person reads; 30,000 is a page that does not paint. */
|
|
35
|
+
export const VISIBLE_LIMIT = 500;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Longitude compared on a CIRCLE rather than on a line.
|
|
39
|
+
*
|
|
40
|
+
* ★ MAPBOX DOES NOT RETURN A west > east BOX. `Transform.getBounds()` builds its LngLatBounds through
|
|
41
|
+
* `extend`, which takes `Math.min`/`Math.max` of the corners, so `getWest() <= getEast()` always. What
|
|
42
|
+
* it returns when the reader drags past the antimeridian is an UNWRAPPED box — `{ west: 359.85, east:
|
|
43
|
+
* 360.15 }` — because `renderWorldCopies` draws the world again and the camera is genuinely over copy
|
|
44
|
+
* +1. Every feature is still at lng ≈ -0.1, so a straight comparison excludes the entire source and
|
|
45
|
+
* the list empties while the canvas shows the whole fleet.
|
|
46
|
+
*
|
|
47
|
+
* So longitudes are brought into the box's own turn before they are compared. That handles the
|
|
48
|
+
* unwrapped box, the exactly-straddling box, and a hand-built west > east box (which this module still
|
|
49
|
+
* accepts, because a caller assembling a ViewBox by hand may well write one).
|
|
50
|
+
*/
|
|
51
|
+
function lngWithin(box: ViewBox, lng: number): boolean {
|
|
52
|
+
const east = box.east >= box.west ? box.east : box.east + 360;
|
|
53
|
+
// A box spanning a whole turn or more holds every longitude — and the shift below would be
|
|
54
|
+
// meaningless for it.
|
|
55
|
+
if (east - box.west >= 360) return true;
|
|
56
|
+
const shifted = box.west + ((((lng - box.west) % 360) + 360) % 360);
|
|
57
|
+
return shifted <= east;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a box holds a point. Edges count as inside. */
|
|
61
|
+
export function boxContains(box: ViewBox, [lng, lat]: [number, number]): boolean {
|
|
62
|
+
if (lat < box.south || lat > box.north) return false;
|
|
63
|
+
return lngWithin(box, lng);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every coordinate in a geometry, flattened. GeometryCollection included, because a source can hold one. */
|
|
67
|
+
function* coordinatesOf(geometry: GeoJSON.Geometry): Generator<[number, number]> {
|
|
68
|
+
if (geometry.type === 'GeometryCollection') {
|
|
69
|
+
for (const g of geometry.geometries) yield* coordinatesOf(g);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const walk = (value: unknown): void => {
|
|
73
|
+
if (!Array.isArray(value)) return;
|
|
74
|
+
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
|
|
75
|
+
out.push([value[0], value[1]]);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const inner of value) walk(inner);
|
|
79
|
+
};
|
|
80
|
+
const out: [number, number][] = [];
|
|
81
|
+
walk((geometry as { coordinates?: unknown }).coordinates);
|
|
82
|
+
yield* out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The point a feature is listed at: its own, or the centre of what it covers. Null if it covers nothing. */
|
|
86
|
+
export function featureAnchor(feature: GeoJSON.Feature): [number, number] | null {
|
|
87
|
+
const geometry = feature.geometry;
|
|
88
|
+
if (!geometry) return null;
|
|
89
|
+
if (geometry.type === 'Point') return geometry.coordinates as [number, number];
|
|
90
|
+
let west = Infinity;
|
|
91
|
+
let south = Infinity;
|
|
92
|
+
let east = -Infinity;
|
|
93
|
+
let north = -Infinity;
|
|
94
|
+
for (const [lng, lat] of coordinatesOf(geometry)) {
|
|
95
|
+
west = Math.min(west, lng);
|
|
96
|
+
east = Math.max(east, lng);
|
|
97
|
+
south = Math.min(south, lat);
|
|
98
|
+
north = Math.max(north, lat);
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isFinite(west)) return null;
|
|
101
|
+
return [(west + east) / 2, (south + north) / 2];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether a feature is in view. A point is in view when the box holds it; anything else when its
|
|
106
|
+
* extent OVERLAPS the box — a zone the reader is standing inside covers the whole viewport and
|
|
107
|
+
* contains no vertex in it, so testing its coordinates one at a time would drop the one zone that
|
|
108
|
+
* matters most.
|
|
109
|
+
*/
|
|
110
|
+
export function featureInBox(box: ViewBox, feature: GeoJSON.Feature): boolean {
|
|
111
|
+
const geometry = feature.geometry;
|
|
112
|
+
if (!geometry) return false;
|
|
113
|
+
if (geometry.type === 'Point') return boxContains(box, geometry.coordinates as [number, number]);
|
|
114
|
+
let west = Infinity;
|
|
115
|
+
let south = Infinity;
|
|
116
|
+
let east = -Infinity;
|
|
117
|
+
let north = -Infinity;
|
|
118
|
+
for (const [lng, lat] of coordinatesOf(geometry)) {
|
|
119
|
+
west = Math.min(west, lng);
|
|
120
|
+
east = Math.max(east, lng);
|
|
121
|
+
south = Math.min(south, lat);
|
|
122
|
+
north = Math.max(north, lat);
|
|
123
|
+
}
|
|
124
|
+
if (!Number.isFinite(west)) return false;
|
|
125
|
+
if (north < box.south || south > box.north) return false;
|
|
126
|
+
const boxEast = box.east >= box.west ? box.east : box.east + 360;
|
|
127
|
+
if (boxEast - box.west >= 360) return true;
|
|
128
|
+
// The feature's span, moved by whole turns until its centre is nearest the box's — the copy of the
|
|
129
|
+
// world the reader is actually looking at. Same unwrapped-bounds problem as `lngWithin`, but an
|
|
130
|
+
// interval rather than a point, so it is shifted rather than folded.
|
|
131
|
+
const shift = Math.round(((box.west + boxEast) / 2 - (west + east) / 2) / 360) * 360;
|
|
132
|
+
return east + shift >= box.west && west + shift <= boxEast;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The key a feature is deduped and listed by. */
|
|
136
|
+
export function featureKey(feature: GeoJSON.Feature, idBy?: string): string {
|
|
137
|
+
const props = (feature.properties ?? {}) as Record<string, unknown>;
|
|
138
|
+
if (props.cluster_id != null) return `c${props.cluster_id}`;
|
|
139
|
+
const own = feature.id ?? (idBy ? props[idBy] : undefined);
|
|
140
|
+
// Falling back to the geometry is not elegant, and it is better than dropping the feature: a source
|
|
141
|
+
// without ids still lists, it just cannot be selected — and selection is the thing that needs an id.
|
|
142
|
+
return String(own ?? JSON.stringify(feature.geometry));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The three steps, in the order that makes them correct.
|
|
147
|
+
*
|
|
148
|
+
* `count` is what is in view; `items` is what fits under the limit. A surface that shows only
|
|
149
|
+
* `items.length` tells the reader there are 500 bikes in a viewport holding 1,284 — so both come back,
|
|
150
|
+
* and `capped` says which sentence to write.
|
|
151
|
+
*/
|
|
152
|
+
export function collectVisible(
|
|
153
|
+
features: Iterable<GeoJSON.Feature>,
|
|
154
|
+
box: ViewBox,
|
|
155
|
+
options: CollectOptions = {},
|
|
156
|
+
): { items: VisibleFeature[]; count: number; capped: boolean } {
|
|
157
|
+
const limit = options.limit ?? VISIBLE_LIMIT;
|
|
158
|
+
const seen = new Set<string>();
|
|
159
|
+
const items: VisibleFeature[] = [];
|
|
160
|
+
let count = 0;
|
|
161
|
+
|
|
162
|
+
for (const feature of features) {
|
|
163
|
+
const props = (feature.properties ?? {}) as Record<string, unknown>;
|
|
164
|
+
if (props.cluster && !options.clusters) continue;
|
|
165
|
+
// ★ THE VIEWPORT TEST COMES FIRST, and the order is load-bearing. The copies of a feature that
|
|
166
|
+
// `querySourceFeatures` returns are one per tile, and for anything that is not a Point they are
|
|
167
|
+
// tile FRAGMENTS — each carries only that tile's clipped portion of the geometry. Deduping first
|
|
168
|
+
// lets whichever fragment the query happens to yield first decide membership for the whole
|
|
169
|
+
// feature, and the tile order is not viewport order: a zone whose fragment in the neighbouring
|
|
170
|
+
// tile lies in that tile's off-screen skirt would claim the key, fail the box test, and take the
|
|
171
|
+
// on-screen fragment down with it. The zone is painted and absent from the list.
|
|
172
|
+
if (!featureInBox(box, feature)) continue;
|
|
173
|
+
// Deduping after it still collapses the copies that matter — two in-view fragments of one zone,
|
|
174
|
+
// or the identical copies of a point on a seam — because they share a key.
|
|
175
|
+
//
|
|
176
|
+
// ⚠ Except when the source carries no ids: `featureKey` then falls back to the geometry, which
|
|
177
|
+
// DIFFERS per fragment, so one zone lists once per tile it touches. That is the honest limit of
|
|
178
|
+
// what a client can do without an id, and it is why `idBy` exists.
|
|
179
|
+
const key = featureKey(feature, options.idBy);
|
|
180
|
+
if (seen.has(key)) continue;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
// The anchor is resolved before the count, not after the limit: a feature counted but never
|
|
183
|
+
// listable would make `capped` true on a list that is not actually short of anything.
|
|
184
|
+
const at = featureAnchor(feature);
|
|
185
|
+
if (!at) continue;
|
|
186
|
+
count++;
|
|
187
|
+
if (items.length >= limit) continue;
|
|
188
|
+
items.push({ key, id: feature.id ?? (options.idBy ? (props[options.idBy] as string | number) : undefined), feature, at, props });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { items, count, capped: count > items.length };
|
|
192
|
+
}
|