@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,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The zoom pair, as canvas chrome rather than Mapbox's own control.
|
|
3
|
+
//
|
|
4
|
+
// <FMapZoom :map="map" />
|
|
5
|
+
//
|
|
6
|
+
// ★ WHY NOT mapboxgl.NavigationControl. Not the icons — the PLACEMENT. Mapbox mounts its controls into
|
|
7
|
+
// its own corner containers inside the map element, which are not the HUD's areas: they cannot sit in a
|
|
8
|
+
// rail, cannot take the canvas-chrome surface with everything else, and — the one that bites — cannot
|
|
9
|
+
// step aside for an open <FSidePanel>. The HUD insets its areas from the strip a panel publishes;
|
|
10
|
+
// Mapbox's corners know nothing about it, so a panel simply covers them.
|
|
11
|
+
//
|
|
12
|
+
// Nothing is lost by rebuilding it: zoom is two buttons calling two methods. Where behaviour is the
|
|
13
|
+
// point rather than chrome — geolocation's permissions and tracking — the Mapbox control is WRAPPED
|
|
14
|
+
// instead (FMapGeolocate).
|
|
15
|
+
//
|
|
16
|
+
// ★ THE MAP IS A PROP, NOT AN INJECTION, and that is forced rather than chosen. Not because the control
|
|
17
|
+
// is authored in the page's template: Vue resolves provide/inject through the MOUNTED tree, so slot
|
|
18
|
+
// content injects from whatever renders the slot — UFieldGroup sizes its buttons exactly that way, and
|
|
19
|
+
// this control would inject from <FCanvasHud> if the HUD provided anything. The reason is siblinghood.
|
|
20
|
+
// <FMap> sits in the HUD's default slot and this sits in a rail, so it is never on the ancestor chain and
|
|
21
|
+
// `inject(FOREST_MAP)` resolves to nothing. (The same siblinghood makes FSidePanel publish a custom
|
|
22
|
+
// property instead of providing a value.) The page already holds the instance from `@ready`.
|
|
23
|
+
import { onBeforeUnmount, ref, watch } from 'vue';
|
|
24
|
+
import type mapboxgl from 'mapbox-gl';
|
|
25
|
+
import FCanvasControl from '../canvas/FCanvasControl.vue';
|
|
26
|
+
import FCanvasControlGroup from '../canvas/FCanvasControlGroup.vue';
|
|
27
|
+
|
|
28
|
+
const props = withDefaults(
|
|
29
|
+
defineProps<{
|
|
30
|
+
/** The live map, from <FMap @ready>. Null until it arrives, which disables the pair. */
|
|
31
|
+
map?: mapboxgl.Map | null;
|
|
32
|
+
/** The hub is horizontal; rails are not. */
|
|
33
|
+
orientation?: 'vertical' | 'horizontal';
|
|
34
|
+
}>(),
|
|
35
|
+
{ map: null, orientation: 'vertical' },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// ★ Disabled at the ends, which a hand-rolled pair forgets: a zoom-in that does nothing at maxZoom is a
|
|
39
|
+
// control lying about what it can do. Stock's NavigationControl tracks this, so rebuilding it without
|
|
40
|
+
// the tracking would be a regression rather than a like-for-like swap.
|
|
41
|
+
const atMax = ref(false);
|
|
42
|
+
const atMin = ref(false);
|
|
43
|
+
|
|
44
|
+
const sync = () => {
|
|
45
|
+
const m = props.map;
|
|
46
|
+
if (!m) return;
|
|
47
|
+
const z = m.getZoom();
|
|
48
|
+
atMax.value = z >= m.getMaxZoom() - 0.001;
|
|
49
|
+
atMin.value = z <= m.getMinZoom() + 0.001;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
watch(
|
|
53
|
+
() => props.map,
|
|
54
|
+
(m, _old, onCleanup) => {
|
|
55
|
+
if (!m) return;
|
|
56
|
+
sync();
|
|
57
|
+
m.on('zoom', sync);
|
|
58
|
+
onCleanup(() => m.off('zoom', sync));
|
|
59
|
+
},
|
|
60
|
+
{ immediate: true },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
onBeforeUnmount(() => props.map?.off('zoom', sync));
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<FCanvasControlGroup :orientation="orientation">
|
|
68
|
+
<FCanvasControl
|
|
69
|
+
icon="i-lucide-plus"
|
|
70
|
+
label="Zoom in"
|
|
71
|
+
:disabled="!map || atMax"
|
|
72
|
+
@click="map?.zoomIn()"
|
|
73
|
+
/>
|
|
74
|
+
<FCanvasControl
|
|
75
|
+
icon="i-lucide-minus"
|
|
76
|
+
label="Zoom out"
|
|
77
|
+
:disabled="!map || atMin"
|
|
78
|
+
@click="map?.zoomOut()"
|
|
79
|
+
/>
|
|
80
|
+
</FCanvasControlGroup>
|
|
81
|
+
</template>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// FMarker — Forest's marker on a Mapbox map, declared rather than assembled. Nest it in <FMap> and
|
|
3
|
+
// it finds the map itself; props drive appearance, so a bike that drops to 9% or gets selected
|
|
4
|
+
// re-renders like any other component instead of having classes toggled on a detached element.
|
|
5
|
+
//
|
|
6
|
+
// Slots: the default one REPLACES the pin — the shape, the fill, the ring and the size, not just the icon —
|
|
7
|
+
// for a surface bringing its own object, e.g. <FVehicleMark>. `popup` takes an <FMapPopup>. They are separate
|
|
8
|
+
// on purpose: a popup in the default slot renders nothing visible and would silently blank the marker.
|
|
9
|
+
//
|
|
10
|
+
// Two recipes, which is the whole system:
|
|
11
|
+
// pin — a filled circle, tone = the fill. Live entities: bikes, the active job, an alert.
|
|
12
|
+
// pill — a quiet surface, tone = the text. Anything carrying a value or a name (charge, id).
|
|
13
|
+
// Both wear a halo ring in the page background colour, which is what keeps them readable over any
|
|
14
|
+
// light preset. Colour follows the map doctrine: primary for live entities, neutral for inert
|
|
15
|
+
// places, warning/error for alerts only.
|
|
16
|
+
//
|
|
17
|
+
// ★ The element handed to mapbox NEVER carries a Vue :class — mapbox writes `mapboxgl-marker` and an
|
|
18
|
+
// anchor class onto it, and a Vue class patch wipes them (the marker stops being positioned). So the
|
|
19
|
+
// element is a plain wrapper created imperatively and the styled content is teleported inside it.
|
|
20
|
+
import { computed, nextTick, onBeforeUnmount, provide, ref, shallowRef, useSlots, watch } from 'vue';
|
|
21
|
+
import mapboxgl from 'mapbox-gl';
|
|
22
|
+
import { FOREST_MARKER } from './mapContext';
|
|
23
|
+
import { useLazyHost, useMapReady } from './useMap';
|
|
24
|
+
|
|
25
|
+
// mapbox keeps its anchor union internal, so state it here rather than reaching for a private type.
|
|
26
|
+
type Anchor =
|
|
27
|
+
| 'center' | 'top' | 'bottom' | 'left' | 'right'
|
|
28
|
+
| 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(
|
|
31
|
+
defineProps<{
|
|
32
|
+
lngLat: [number, number];
|
|
33
|
+
/** `pin` for live entities, `pill` when the marker carries a value or a name */
|
|
34
|
+
shape?: 'pin' | 'pill';
|
|
35
|
+
color?: 'primary' | 'neutral' | 'warning' | 'error';
|
|
36
|
+
/** icon name from the registered collections, e.g. `i-forest-bike`, `i-lucide-battery-low` */
|
|
37
|
+
icon?: string;
|
|
38
|
+
/** pill text. Ignored by `pin`, which is icon-only */
|
|
39
|
+
label?: string;
|
|
40
|
+
size?: 'sm' | 'md' | 'lg';
|
|
41
|
+
/** lifts and rings the marker — for the one entity a surface is acting on */
|
|
42
|
+
selected?: boolean;
|
|
43
|
+
/** takes clicks: adds a cursor, a focus ring, and a touch-sized hit area on coarse pointers */
|
|
44
|
+
interactive?: boolean;
|
|
45
|
+
/** accessible name for the interactive case. Falls back to `label` */
|
|
46
|
+
ariaLabel?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The reader can move it, and `dragend` reports where it landed — a location picker, a bay being
|
|
49
|
+
* placed, "report a problem here".
|
|
50
|
+
*
|
|
51
|
+
* ★ Mapbox owns the gesture, including the pointer capture that stops the MAP panning under the
|
|
52
|
+
* drag. Rebuilding it on the element would fight that: the marker would move and the map would
|
|
53
|
+
* pan with it.
|
|
54
|
+
*/
|
|
55
|
+
draggable?: boolean;
|
|
56
|
+
anchor?: Anchor;
|
|
57
|
+
offset?: [number, number];
|
|
58
|
+
}>(),
|
|
59
|
+
{
|
|
60
|
+
shape: 'pin',
|
|
61
|
+
color: 'primary',
|
|
62
|
+
icon: undefined,
|
|
63
|
+
label: undefined,
|
|
64
|
+
size: 'md',
|
|
65
|
+
selected: false,
|
|
66
|
+
interactive: false,
|
|
67
|
+
ariaLabel: undefined,
|
|
68
|
+
draggable: false,
|
|
69
|
+
anchor: 'center',
|
|
70
|
+
offset: undefined,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const emit = defineEmits<{
|
|
75
|
+
click: [];
|
|
76
|
+
/**
|
|
77
|
+
* Dragging finished, with where it landed. Fires only on `dragend`, never per frame — a surface
|
|
78
|
+
* reacting to a drag usually reverse geocodes or saves, and doing either sixty times a second is
|
|
79
|
+
* both wrong and billed.
|
|
80
|
+
*/
|
|
81
|
+
dragend: [at: [number, number]];
|
|
82
|
+
}>();
|
|
83
|
+
|
|
84
|
+
const marker = shallowRef<mapboxgl.Marker | null>(null);
|
|
85
|
+
// A <FMapPopup> in the `popup` slot binds to this marker.
|
|
86
|
+
provide(FOREST_MARKER, marker);
|
|
87
|
+
|
|
88
|
+
// The element mapbox owns. Created on demand, not during setup (which may run on a server), and kept
|
|
89
|
+
// free of Vue bindings — see the note above.
|
|
90
|
+
const { host, ensure } = useLazyHost();
|
|
91
|
+
|
|
92
|
+
useMapReady((m) => {
|
|
93
|
+
if (marker.value) return;
|
|
94
|
+
marker.value = new mapboxgl.Marker({
|
|
95
|
+
element: ensure(),
|
|
96
|
+
anchor: props.anchor,
|
|
97
|
+
draggable: props.draggable,
|
|
98
|
+
...(props.offset ? { offset: props.offset } : {}),
|
|
99
|
+
})
|
|
100
|
+
.setLngLat(props.lngLat)
|
|
101
|
+
.addTo(m);
|
|
102
|
+
|
|
103
|
+
marker.value.on('dragend', () => {
|
|
104
|
+
const at = marker.value!.getLngLat();
|
|
105
|
+
emit('dragend', [at.lng, at.lat]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
watch(
|
|
110
|
+
() => props.lngLat,
|
|
111
|
+
(at) => marker.value?.setLngLat(at),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Toggleable after creation, because a surface often arms dragging only in an editing mode.
|
|
115
|
+
watch(
|
|
116
|
+
() => props.draggable,
|
|
117
|
+
(on) => marker.value?.setDraggable(on),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// The wrong-slot trap, made loud. A popup put in the DEFAULT slot replaces the pin instead of
|
|
121
|
+
// attaching to it: the slot renders only a teleport anchor, so the marker paints nothing and there is
|
|
122
|
+
// no error anywhere. Nothing distinguishes that from a marker that is simply empty, so the check is
|
|
123
|
+
// the DOM: content was provided, yet the visual box has no element and no text in it.
|
|
124
|
+
const visualEl = ref<HTMLElement | null>(null);
|
|
125
|
+
const slots = useSlots();
|
|
126
|
+
if (import.meta.env.DEV) {
|
|
127
|
+
watch(marker, async (mk) => {
|
|
128
|
+
if (!mk || !slots.default) return;
|
|
129
|
+
await nextTick();
|
|
130
|
+
const el = visualEl.value;
|
|
131
|
+
if (!el || el.childElementCount > 0 || el.textContent?.trim()) return;
|
|
132
|
+
console.warn(
|
|
133
|
+
'[FMarker] the default slot rendered nothing, so this marker is blank. An <FMapPopup> belongs ' +
|
|
134
|
+
'in the `popup` slot — the default slot REPLACES the pin.\n' +
|
|
135
|
+
' <FMarker><template #popup><FMapPopup>…</FMapPopup></template></FMarker>',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onBeforeUnmount(() => marker.value?.remove());
|
|
141
|
+
|
|
142
|
+
// ── Appearance ────────────────────────────────────────────────────────────
|
|
143
|
+
const PIN_SIZE = { sm: 'size-6', md: 'size-7', lg: 'size-9' };
|
|
144
|
+
const ICON_SIZE = { sm: 'size-3', md: 'size-3.5', lg: 'size-4.5' };
|
|
145
|
+
const PILL_SIZE = { sm: 'px-2 py-0.5 text-[11px]', md: 'px-2.5 py-1 text-xs', lg: 'px-3 py-1.5 text-sm' };
|
|
146
|
+
// pin: tone fills. pill: tone is the text on a quiet surface.
|
|
147
|
+
const PIN_TONE = {
|
|
148
|
+
primary: 'bg-primary text-inverted',
|
|
149
|
+
neutral: 'bg-elevated text-default',
|
|
150
|
+
warning: 'bg-warning text-inverted',
|
|
151
|
+
error: 'bg-error text-inverted',
|
|
152
|
+
};
|
|
153
|
+
const PILL_TONE = {
|
|
154
|
+
primary: 'text-primary',
|
|
155
|
+
neutral: 'text-muted',
|
|
156
|
+
warning: 'text-warning',
|
|
157
|
+
error: 'text-error',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ★ A DEFAULT SLOT REPLACES THE PIN, INCLUDING ITS CHROME.
|
|
162
|
+
*
|
|
163
|
+
* The comment at the top of this file always said the slot replaces the pin; the classes did not — the slot
|
|
164
|
+
* only replaced the ICON, so an object handed in still landed inside a coloured circle with the pin's ring,
|
|
165
|
+
* shadow and fixed size. `<FVehicleMark>` in the slot came out as a marker inside a marker.
|
|
166
|
+
*
|
|
167
|
+
* With a slot, the wrapper keeps only what belongs to the MARKER rather than to the pin: layout, the transition,
|
|
168
|
+
* the lift when selected, and the pointer. The object brings its own shape and its own edge — the vehicle asset
|
|
169
|
+
* already carries a background-coloured stroke, which is the same job `ring-2 ring-(--ui-bg)` does for a pin.
|
|
170
|
+
*/
|
|
171
|
+
const visual = computed(() => {
|
|
172
|
+
// The transition carries the selected lift/scale — a small near element, so the --motion-fast pair.
|
|
173
|
+
const base = 'flex items-center justify-center transition duration-(--motion-fast) ease-(--motion-ease-out)';
|
|
174
|
+
if (slots.default) {
|
|
175
|
+
return [base, props.selected ? 'z-10 scale-110' : '', props.interactive ? 'cursor-pointer' : ''];
|
|
176
|
+
}
|
|
177
|
+
return [
|
|
178
|
+
base,
|
|
179
|
+
'rounded-full shadow-md',
|
|
180
|
+
// The halo: a ring in the page background separates the marker from whatever the basemap put
|
|
181
|
+
// underneath it, in every light preset.
|
|
182
|
+
'ring-2 ring-(--ui-bg)',
|
|
183
|
+
props.shape === 'pin'
|
|
184
|
+
? [PIN_SIZE[props.size], PIN_TONE[props.color]]
|
|
185
|
+
: ['gap-1 bg-default font-semibold ring-1 ring-default', PILL_SIZE[props.size], PILL_TONE[props.color]],
|
|
186
|
+
props.selected ? 'z-10 scale-110 ring-primary' : '',
|
|
187
|
+
props.interactive ? 'cursor-pointer' : '',
|
|
188
|
+
];
|
|
189
|
+
});
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
<template>
|
|
193
|
+
<Teleport v-if="host" :to="host">
|
|
194
|
+
<component
|
|
195
|
+
:is="interactive ? 'button' : 'div'"
|
|
196
|
+
:type="interactive ? 'button' : undefined"
|
|
197
|
+
:aria-label="interactive ? (ariaLabel ?? label) : undefined"
|
|
198
|
+
:class="[
|
|
199
|
+
'flex items-center justify-center',
|
|
200
|
+
// Touch surfaces need a 44px target; growing the pin instead would break the size ladder,
|
|
201
|
+
// so the hit area is transparent padding around it. Fine pointers keep the tight box.
|
|
202
|
+
interactive ? 'coarse:size-11 rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary' : '',
|
|
203
|
+
]"
|
|
204
|
+
@click="emit('click')"
|
|
205
|
+
>
|
|
206
|
+
<span ref="visualEl" :class="visual">
|
|
207
|
+
<!-- Default slot REPLACES the pin, so a popup must not live here — it would render as two
|
|
208
|
+
teleport anchors and suppress the icon. That is what the `popup` slot below is for. -->
|
|
209
|
+
<slot>
|
|
210
|
+
<UIcon v-if="icon" :name="icon" :class="ICON_SIZE[size]" />
|
|
211
|
+
<span v-if="label && shape === 'pill'">{{ label }}</span>
|
|
212
|
+
</slot>
|
|
213
|
+
</span>
|
|
214
|
+
<!-- <FMapPopup> declares itself here and teleports its body into mapbox's popup element. -->
|
|
215
|
+
<slot name="popup" />
|
|
216
|
+
</component>
|
|
217
|
+
</Teleport>
|
|
218
|
+
</template>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The transport bar for a track.
|
|
3
|
+
//
|
|
4
|
+
// It is FTrackScrubber and not FMapPlayback because it needs no map — the same reason FVehicleMark and
|
|
5
|
+
// FVehicleLegend are not FMap-prefixed. It scrubs a track; whether anything is drawing that track is
|
|
6
|
+
// not its business.
|
|
7
|
+
import { computed } from 'vue';
|
|
8
|
+
import { elapsedLabel } from './track';
|
|
9
|
+
import type { TrackPlayback } from './useTrackPlayback';
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
playback: TrackPlayback;
|
|
14
|
+
/** the multipliers offered. Default 1, 2, 4, 8 */
|
|
15
|
+
speeds?: number[];
|
|
16
|
+
/** what the slider is scrubbing, for a screen reader */
|
|
17
|
+
label?: string;
|
|
18
|
+
}>(),
|
|
19
|
+
{ speeds: () => [1, 2, 4, 8], label: 'Playback position' },
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Speed is derived from what the surface passes into useTrackPlayback, so this control asks rather
|
|
23
|
+
// than sets. The surface owns the value it hands the clock.
|
|
24
|
+
const emit = defineEmits<{ 'update:speed': [speed: number | 'fit'] }>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ★ THE MENU SHOWS THE SETTING, NOT THE RESOLVED MULTIPLIER.
|
|
28
|
+
*
|
|
29
|
+
* `fit` on a 50-minute trip resolves to about 101×. Binding the menu to that put 101.4 in a list of
|
|
30
|
+
* 1/2/4/8 — a selected value present in no option, truncated to "101…" by the control's width.
|
|
31
|
+
* `fit` is the first option because it is the default and the one most readers want.
|
|
32
|
+
*/
|
|
33
|
+
const speedItems = computed(() => [
|
|
34
|
+
{ label: 'Fit', value: 'fit' as const },
|
|
35
|
+
...props.speeds.map((s) => ({ label: `${s}×`, value: s })),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const empty = computed(() => !props.playback.span.value.ms);
|
|
39
|
+
const elapsed = computed(() => elapsedLabel(props.playback.at.value - props.playback.span.value.start));
|
|
40
|
+
const total = computed(() => elapsedLabel(props.playback.span.value.ms));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The wall-clock time under the playhead.
|
|
44
|
+
*
|
|
45
|
+
* ★ This is what a screen reader announces. "43 per cent" is a number nobody can act on — an operator
|
|
46
|
+
* auditing a rental is looking for a moment, not a fraction.
|
|
47
|
+
*/
|
|
48
|
+
const stamp = computed(() =>
|
|
49
|
+
empty.value ? '' : new Date(props.playback.at.value).toLocaleTimeString('en-GB'),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const percent = computed({
|
|
53
|
+
get: () => Math.round(props.playback.progress.value * 100),
|
|
54
|
+
set: (v: number) => props.playback.seekProgress(v / 100),
|
|
55
|
+
});
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div class="flex items-center gap-3">
|
|
60
|
+
<UButton
|
|
61
|
+
:icon="playback.playing.value ? 'i-lucide-pause' : 'i-lucide-play'"
|
|
62
|
+
:aria-label="playback.playing.value ? 'Pause' : 'Play'"
|
|
63
|
+
color="neutral"
|
|
64
|
+
variant="ghost"
|
|
65
|
+
:disabled="empty"
|
|
66
|
+
@click="playback.toggle()"
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
<!-- Times are tabular: Mohr has no tabular figures, so the role swaps the face. Without it the
|
|
70
|
+
digits jitter as the clock runs and the eye tracks that instead of the map. -->
|
|
71
|
+
<span class="type-tabular text-sm">{{ elapsed }}</span>
|
|
72
|
+
|
|
73
|
+
<USlider
|
|
74
|
+
v-model="percent"
|
|
75
|
+
class="flex-1"
|
|
76
|
+
:min="0"
|
|
77
|
+
:max="100"
|
|
78
|
+
:disabled="empty"
|
|
79
|
+
:aria-label="label"
|
|
80
|
+
:aria-valuetext="stamp"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<span class="type-tabular text-sm text-dimmed">{{ total }}</span>
|
|
84
|
+
|
|
85
|
+
<USelect
|
|
86
|
+
:model-value="playback.mode.value"
|
|
87
|
+
:items="speedItems"
|
|
88
|
+
:disabled="empty"
|
|
89
|
+
aria-label="Playback speed"
|
|
90
|
+
class="w-24"
|
|
91
|
+
@update:model-value="(v: number | 'fit') => emit('update:speed', v)"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The key to a vehicle mark.
|
|
3
|
+
//
|
|
4
|
+
// <FCanvasCard><FVehicleLegend /></FCanvasCard>
|
|
5
|
+
// <FVehicleLegend :axes="['state']" /> a surface that paints no ring
|
|
6
|
+
//
|
|
7
|
+
// ★ A SECTION PER AXIS, and the sections are the content. A vehicle is three independent axes rather
|
|
8
|
+
// than one status enum, so a flat list would read as one enum with 36 values. Within a section exactly
|
|
9
|
+
// one channel moves — see vehicleLegend.ts, which builds the rows and holds everything else still.
|
|
10
|
+
//
|
|
11
|
+
// ★ THE SWATCH IS THE MARK. <FVehicleMark> needs no map and draws at its natural size, so a row shows
|
|
12
|
+
// the artwork the map actually draws rather than a square approximating a disc inside a ring. It is
|
|
13
|
+
// also the only swatch that can show a glyph, which no colour chip can.
|
|
14
|
+
//
|
|
15
|
+
// ★ IT CARRIES NO SURFACE, like <FMapLegend>. Over a basemap it wants a panel, and FCanvasCard is the
|
|
16
|
+
// panel the rest of the chrome uses.
|
|
17
|
+
import FVehicleMark from './FVehicleMark.vue';
|
|
18
|
+
import { vehicleLegendSections, type VehicleLegendAxis } from './vehicleLegend';
|
|
19
|
+
import { MARKER_CANVAS } from './vehicle';
|
|
20
|
+
import { computed } from 'vue';
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
/** The axes this surface paints. Drop `charge` where the marks carry no ring. */
|
|
25
|
+
axes?: VehicleLegendAxis[];
|
|
26
|
+
/**
|
|
27
|
+
* The drawn size of each row's mark.
|
|
28
|
+
*
|
|
29
|
+
* Defaults to the asset's own canvas — 1:1, the size the map draws. A key that shrank its marks
|
|
30
|
+
* would be showing a reader something other than what they are looking at, so scaling down is a
|
|
31
|
+
* decision a surface makes deliberately rather than a default it inherits.
|
|
32
|
+
*/
|
|
33
|
+
size?: number;
|
|
34
|
+
/**
|
|
35
|
+
* The colour the marks' edges paint, forwarded to each <FVehicleMark>.
|
|
36
|
+
*
|
|
37
|
+
* A mark separates from its background with an edge in the background's own colour, so a legend on
|
|
38
|
+
* a panel that is not `--ui-bg` passes that panel's token or the edge stops working.
|
|
39
|
+
*/
|
|
40
|
+
edge?: string;
|
|
41
|
+
}>(),
|
|
42
|
+
{ axes: () => ['state', 'charge', 'connectivity'], size: MARKER_CANVAS, edge: undefined },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const sections = computed(() => vehicleLegendSections(props.axes));
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<div class="flex flex-col gap-3">
|
|
50
|
+
<section v-for="section in sections" :key="section.axis" class="flex flex-col gap-1.5">
|
|
51
|
+
<p class="type-overline text-dimmed">{{ section.label }}</p>
|
|
52
|
+
|
|
53
|
+
<ul role="list" class="flex flex-col gap-1">
|
|
54
|
+
<li v-for="row in section.rows" :key="row.key" class="flex items-center gap-2">
|
|
55
|
+
<FVehicleMark
|
|
56
|
+
:state="row.mark.state"
|
|
57
|
+
:battery="row.mark.battery"
|
|
58
|
+
:offline="row.mark.offline"
|
|
59
|
+
:size="size"
|
|
60
|
+
:edge="edge"
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
<span class="type-caption text-default">{{ row.label }}</span>
|
|
64
|
+
</li>
|
|
65
|
+
</ul>
|
|
66
|
+
|
|
67
|
+
<!-- The silent case. It has no row because it paints nothing: a reporting vehicle carries no dot
|
|
68
|
+
at all, which is what keeps 80% of the fleet from being inked to say nothing is happening. -->
|
|
69
|
+
<p v-if="section.note" class="type-caption text-muted">{{ section.note }}</p>
|
|
70
|
+
</section>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ⚠ MODULE SCOPE, deliberately. `<script setup>` runs once PER INSTANCE, so a counter declared there resets
|
|
4
|
+
* to the same value for every mark — every instance would take the id `fvm1`, and the gradient collision this
|
|
5
|
+
* prefix exists to prevent would be back with extra steps.
|
|
6
|
+
*/
|
|
7
|
+
let seq = 0;
|
|
8
|
+
const nextUid = () => `fvm${(seq += 1)}`;
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
// FVehicleMark — one vehicle, drawn from its facts. NOT a map component.
|
|
13
|
+
//
|
|
14
|
+
// ★ WHY `Mark` AND NOT `Marker`. They are two concepts in this system, not synonyms:
|
|
15
|
+
//
|
|
16
|
+
// MARK the graphic. Artwork with no position — it goes in a list row or a detail header.
|
|
17
|
+
// MARKER a mark POSITIONED on a map, by a symbol layer or by <FMarker> at a lngLat.
|
|
18
|
+
//
|
|
19
|
+
// This takes no coordinate and needs no map, so `FVehicleMarker` would be a promise it does not keep — and
|
|
20
|
+
// `FMarker` already owns "marker" for the positioned thing. The docs on /maps/fleet-markers declare both terms.
|
|
21
|
+
//
|
|
22
|
+
// ★ IT PAINTS THE UPLOADED ASSET, so there is ONE source of truth for the artwork and two renderers for two
|
|
23
|
+
// jobs. The fleet is a symbol layer, because 30k marks have to be on the GPU and need collision, sort keys and
|
|
24
|
+
// paint-order slots. Everything else — the selected vehicle on a map, a list row, a detail header, a legend —
|
|
25
|
+
// is this component, because a DOM object can be focused, read by a screen reader, and put in a table cell,
|
|
26
|
+
// and a symbol inside a canvas is pixels.
|
|
27
|
+
//
|
|
28
|
+
// Before this existed the two paths drew DIFFERENT marks: `FMarker` painted a plain pin with `i-forest-bike`,
|
|
29
|
+
// so a vehicle changed shape when you selected it, and the two could drift apart with no build error to catch
|
|
30
|
+
// it. Now selection changes the halo and nothing else.
|
|
31
|
+
//
|
|
32
|
+
// ⚠ NO FETCH. The asset is inlined (`markerAsset.ts`, emitted by the generator) rather than loaded from a
|
|
33
|
+
// public path, because a consuming app has no `/map-icons/` URL and a fetch would flash an empty mark on first
|
|
34
|
+
// paint. It costs ~8 KB in the bundle of anything that imports this.
|
|
35
|
+
//
|
|
36
|
+
// Usage on a map — FMarker's default slot replaces the pin, so the mark is the marker:
|
|
37
|
+
// <FMarker :lng-lat="v.at" selected interactive><FVehicleMark v-bind="v.facts" /></FMarker>
|
|
38
|
+
// Usage anywhere else:
|
|
39
|
+
// <FVehicleMark state="FUNCTIONAL" battery="LOW" :size="28" />
|
|
40
|
+
import { computed } from 'vue';
|
|
41
|
+
import { MARKER_SVG } from './markerAsset';
|
|
42
|
+
import manifest from '../../icons/forest/map/manifest.json';
|
|
43
|
+
import {
|
|
44
|
+
BADGE_COLOURS,
|
|
45
|
+
BADGE_EDGE,
|
|
46
|
+
IN_USE_GRADIENT,
|
|
47
|
+
LOCKED_MARK,
|
|
48
|
+
OFFLINE_DOT,
|
|
49
|
+
RENTAL_PAUSED,
|
|
50
|
+
RENTAL_TAKEN,
|
|
51
|
+
STATE_GROUP,
|
|
52
|
+
STATE_GROUP_COLOUR,
|
|
53
|
+
batteryRingToken,
|
|
54
|
+
labelOf,
|
|
55
|
+
stateLabel,
|
|
56
|
+
markerParams,
|
|
57
|
+
paintMarkerSvg,
|
|
58
|
+
type BadgeShape,
|
|
59
|
+
type BatteryLevel,
|
|
60
|
+
type RentalState,
|
|
61
|
+
type VehicleState,
|
|
62
|
+
} from './vehicle';
|
|
63
|
+
import { resolveMapColor } from './mapColor';
|
|
64
|
+
import { useTokenMode } from './useTokenMode';
|
|
65
|
+
|
|
66
|
+
const props = withDefaults(
|
|
67
|
+
defineProps<{
|
|
68
|
+
/** what the operation thinks of the vehicle */
|
|
69
|
+
state: VehicleState;
|
|
70
|
+
/**
|
|
71
|
+
* Charge. OMIT IT and no ring is drawn at all — the mark is then 20% smaller from the same artwork, which
|
|
72
|
+
* is correct and must not be compensated for. Pass it on a surface that dispatches on charge.
|
|
73
|
+
*/
|
|
74
|
+
battery?: BatteryLevel;
|
|
75
|
+
/** the vehicle has stopped reporting */
|
|
76
|
+
offline?: boolean;
|
|
77
|
+
/** what a rider is doing with it. The platform's own `rentalState`, passed through */
|
|
78
|
+
rental?: RentalState;
|
|
79
|
+
/** the fourth region — a claim, or an open task. Nothing paints one in production yet */
|
|
80
|
+
badge?: BadgeShape;
|
|
81
|
+
/** the badge's fill, as a token name. Never `--ui-error` or `--ui-warning` */
|
|
82
|
+
badgeColour?: string;
|
|
83
|
+
/** the drawn size in px. The asset is a 40-unit canvas, so 40 is 1:1 */
|
|
84
|
+
size?: number;
|
|
85
|
+
/**
|
|
86
|
+
* The colour the mark's edges paint — its separation from whatever is behind it.
|
|
87
|
+
*
|
|
88
|
+
* Defaults to `--ui-bg`, which is right on a page and on a Forest basemap. A surface drawing the mark on a
|
|
89
|
+
* coloured panel passes that panel's token instead, or the edge disappears into it.
|
|
90
|
+
*/
|
|
91
|
+
edge?: string;
|
|
92
|
+
}>(),
|
|
93
|
+
{
|
|
94
|
+
battery: undefined,
|
|
95
|
+
offline: false,
|
|
96
|
+
rental: 'AVAILABLE',
|
|
97
|
+
badge: undefined,
|
|
98
|
+
badgeColour: BADGE_COLOURS.sky,
|
|
99
|
+
size: 40,
|
|
100
|
+
edge: '--ui-bg',
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// ★ NOT `isDark`. resolveMapColor reads getComputedStyle, so it has to run AFTER the mode class lands on
|
|
105
|
+
// <html>; a boolean ref flips first and resolves the mode the page just left, which leaves the mark one toggle
|
|
106
|
+
// behind. useTokenMode ticks off the DOM mutation instead.
|
|
107
|
+
const tokenMode = useTokenMode();
|
|
108
|
+
const colour = (t: string) => resolveMapColor(`var(${t})`);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A unique id prefix per instance.
|
|
112
|
+
*
|
|
113
|
+
* ⚠ REQUIRED. The asset declares a gradient id, and an id is unique per DOCUMENT — fine in Mapbox, where each
|
|
114
|
+
* icon is its own document, and broken here: two of these on one page would both resolve `url(#inUse)` to the
|
|
115
|
+
* first one's stops, so a list of vehicles would show the first row's gradient on every row.
|
|
116
|
+
*/
|
|
117
|
+
const uid = nextUid();
|
|
118
|
+
|
|
119
|
+
const artwork = computed(() => {
|
|
120
|
+
void tokenMode.value;
|
|
121
|
+
const edge = colour(props.edge);
|
|
122
|
+
const rented = RENTAL_TAKEN.includes(props.rental);
|
|
123
|
+
return paintMarkerSvg(
|
|
124
|
+
MARKER_SVG,
|
|
125
|
+
markerParams(
|
|
126
|
+
props.state,
|
|
127
|
+
{
|
|
128
|
+
body: colour(STATE_GROUP_COLOUR[STATE_GROUP[props.state]]),
|
|
129
|
+
glyph: edge,
|
|
130
|
+
edge,
|
|
131
|
+
// omitted battery → no ring, both of the outer circle's params transparent together
|
|
132
|
+
battery: props.battery ? { fill: colour(batteryRingToken(props.battery)) } : undefined,
|
|
133
|
+
dot: props.offline ? { fill: colour(OFFLINE_DOT) } : undefined,
|
|
134
|
+
inUse: rented
|
|
135
|
+
? { from: colour(IN_USE_GRADIENT.from), to: colour(IN_USE_GRADIENT.to) }
|
|
136
|
+
: undefined,
|
|
137
|
+
locked: props.rental === RENTAL_PAUSED ? { fill: colour(LOCKED_MARK) } : undefined,
|
|
138
|
+
},
|
|
139
|
+
props.badge ? { shape: props.badge, fill: colour(props.badgeColour), edge: colour(BADGE_EDGE) } : undefined,
|
|
140
|
+
props.rental,
|
|
141
|
+
) as Record<string, string>,
|
|
142
|
+
{ inner: true, idPrefix: uid },
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** the accessible name — the facts in words, because the mark itself is a picture */
|
|
147
|
+
const label = computed(() => {
|
|
148
|
+
const bits = [stateLabel(props.state)];
|
|
149
|
+
if (props.rental !== 'AVAILABLE') bits.push(labelOf(props.rental));
|
|
150
|
+
if (props.battery) bits.push(`battery ${labelOf(props.battery)}`);
|
|
151
|
+
if (props.offline) bits.push('offline');
|
|
152
|
+
return bits.join(', ');
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<svg
|
|
158
|
+
:width="size"
|
|
159
|
+
:height="size"
|
|
160
|
+
:viewBox="`0 0 ${manifest.size} ${manifest.size}`"
|
|
161
|
+
class="block shrink-0"
|
|
162
|
+
role="img"
|
|
163
|
+
:aria-label="label"
|
|
164
|
+
v-html="artwork"
|
|
165
|
+
/>
|
|
166
|
+
</template>
|