@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,107 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The scale bar — a round stick to hold against the map.
|
|
3
|
+
//
|
|
4
|
+
// <FMapScale :map="map" />
|
|
5
|
+
//
|
|
6
|
+
// ★ REBUILT, NOT WRAPPED, and the split follows the one FMapZoom already draws. Mapbox's ScaleControl
|
|
7
|
+
// mounts into its own corner container inside the map element, which is not one of the HUD's areas: it
|
|
8
|
+
// cannot sit in a rail, cannot take the canvas-chrome surface with the rest of the chrome, and cannot
|
|
9
|
+
// step aside for an open <FSidePanel>. Geolocate is wrapped because its BEHAVIOUR is the point — a
|
|
10
|
+
// permission flow, a position subscription, an error taxonomy. A scale bar's behaviour is one
|
|
11
|
+
// subtraction and a rounding rule, so nothing is lost by owning it and the placement is gained.
|
|
12
|
+
//
|
|
13
|
+
// ★ IT MEASURES THE MAP, NOT THE ZOOM. Metres-per-pixel varies with latitude — the same zoom is a
|
|
14
|
+
// different distance in London than at the equator — so the reading comes from unprojecting two points
|
|
15
|
+
// on the canvas and asking GL JS how far apart they are. Deriving it from `getZoom()` alone would be
|
|
16
|
+
// wrong everywhere except the equator, and subtly wrong rather than obviously so.
|
|
17
|
+
//
|
|
18
|
+
// ★ IT SAMPLES ACROSS THE MIDDLE. A tilted map has no single scale — the horizon is further away per
|
|
19
|
+
// pixel than the foreground — so a scale bar over a pitched view is an approximation whatever it does.
|
|
20
|
+
// The vertical centre is the least-wrong single answer and is what every implementation uses; the
|
|
21
|
+
// alternative is hiding the bar when pitched, which removes the reading exactly when a reader is most
|
|
22
|
+
// likely to misjudge distance.
|
|
23
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
24
|
+
import type mapboxgl from 'mapbox-gl';
|
|
25
|
+
import { scaleReading, type ScaleUnit } from './scaleBar';
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{
|
|
29
|
+
/** The live map, from <FMap @ready>. Null until it arrives, which renders nothing. */
|
|
30
|
+
map?: mapboxgl.Map | null;
|
|
31
|
+
/** The widest the bar may draw, in px. It fills whatever fraction the rounded distance needs. */
|
|
32
|
+
maxWidth?: number;
|
|
33
|
+
unit?: ScaleUnit;
|
|
34
|
+
}>(),
|
|
35
|
+
{ map: null, maxWidth: 96, unit: 'metric' },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const spanMetres = ref(0);
|
|
39
|
+
|
|
40
|
+
function measure() {
|
|
41
|
+
const m = props.map;
|
|
42
|
+
if (!m) return;
|
|
43
|
+
const canvas = m.getCanvas();
|
|
44
|
+
const height = canvas.clientHeight;
|
|
45
|
+
const width = canvas.clientWidth;
|
|
46
|
+
// A canvas mid-layout reports 0 and every number downstream becomes NaN. scaleReading returns null
|
|
47
|
+
// for that rather than painting a full-width bar with a meaningless label.
|
|
48
|
+
if (!width || !height) {
|
|
49
|
+
spanMetres.value = 0;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const y = height / 2;
|
|
53
|
+
const left = m.unproject([0, y]);
|
|
54
|
+
const right = m.unproject([Math.min(props.maxWidth, width), y]);
|
|
55
|
+
spanMetres.value = left.distanceTo(right);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reading = computed(() => scaleReading(spanMetres.value, props.unit));
|
|
59
|
+
|
|
60
|
+
watch(
|
|
61
|
+
() => props.map,
|
|
62
|
+
(m, _old, onCleanup) => {
|
|
63
|
+
if (!m) return;
|
|
64
|
+
measure();
|
|
65
|
+
// `move` covers pan, zoom, rotate and pitch; `resize` covers a panel opening beside the map.
|
|
66
|
+
m.on('move', measure);
|
|
67
|
+
m.on('resize', measure);
|
|
68
|
+
onCleanup(() => {
|
|
69
|
+
m.off('move', measure);
|
|
70
|
+
m.off('resize', measure);
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
{ immediate: true },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
onBeforeUnmount(() => {
|
|
77
|
+
props.map?.off('move', measure);
|
|
78
|
+
props.map?.off('resize', measure);
|
|
79
|
+
});
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<!-- A readout, not a control: no hover, no press, no focus ring, and nothing to tab to. The chrome
|
|
84
|
+
surface is shared with the buttons so it reads as the same material, and the edge is the button
|
|
85
|
+
recipe's own --btn-line rather than --ui-border, which is a visibly lighter grey.
|
|
86
|
+
★ But the RADIUS is a step tighter than theirs (8px against the controls' 12px), and that is the
|
|
87
|
+
one place it should not match. Radius carries the same meaning here as everywhere in Forest:
|
|
88
|
+
control is md, item is sm. A tile that is not pressable saying so quietly, through its corners,
|
|
89
|
+
is cheaper than any affordance a reader has to test by hovering. -->
|
|
90
|
+
<div
|
|
91
|
+
v-if="reading"
|
|
92
|
+
class="forest-canvas-chrome pointer-events-auto flex flex-col gap-0.5 rounded-sm px-2 py-1 ring ring-[var(--neutral-300)] ring-inset dark:ring-[var(--neutral-700)]"
|
|
93
|
+
:style="{ width: `${maxWidth}px` }"
|
|
94
|
+
>
|
|
95
|
+
<span class="text-[0.6875rem] leading-none font-bold tracking-wide text-[var(--neutral-900)] tabular-nums dark:text-[var(--neutral-100)]">
|
|
96
|
+
{{ reading.label }}
|
|
97
|
+
</span>
|
|
98
|
+
<!-- The bar shortens to the rounded number rather than the number stretching to the bar, so the
|
|
99
|
+
track is drawn full width and the fill is the honest part. -->
|
|
100
|
+
<span class="relative block h-1 w-full">
|
|
101
|
+
<span
|
|
102
|
+
class="absolute inset-y-0 start-0 rounded-[1px] bg-[var(--neutral-900)] transition-[width] duration-(--motion-fast) ease-(--motion-ease-out) motion-reduce:transition-none dark:bg-[var(--neutral-100)]"
|
|
103
|
+
:style="{ width: `${(reading.ratio * 100).toFixed(2)}%` }"
|
|
104
|
+
/>
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Search over the map: one control that becomes a field, and a panel of grouped results under it.
|
|
3
|
+
//
|
|
4
|
+
// <FMapSearch :map="map" :sources="[vehicles, bays, places]" @select="open" />
|
|
5
|
+
//
|
|
6
|
+
// ★ COMPOSED FROM REKA'S LISTBOX, NOT FROM <UCommandPalette>. The palette bundles the input with the
|
|
7
|
+
// list, and that bundle is the whole reason it was wrong here: its `input` slot ships its own heights
|
|
8
|
+
// ([&>input]:h-10 / h-11) which fight --forest-canvas-control, so the field could not be the same size
|
|
9
|
+
// as the zoom pair beside it, and a box whose sizing belongs to another component cannot be morphed
|
|
10
|
+
// out of a button. <UListbox> is no help either — it builds its OWN ListboxRoot, so an external input
|
|
11
|
+
// cannot reach it.
|
|
12
|
+
//
|
|
13
|
+
// ★ AND THE BUNDLE BOUGHT NOTHING. The reason to accept it was the keyboard: `ListboxFilter` wires
|
|
14
|
+
// Down/Up/Home/End, Enter, highlight-on-type, aria-activedescendant and the list's tab-order removal,
|
|
15
|
+
// all through injectListboxRootContext(). That inject resolves through the mounted COMPONENT tree,
|
|
16
|
+
// while a popover teleports DOM only — so the filter still drives a list that has been portalled to
|
|
17
|
+
// <body>. Measured: typing set aria-activedescendant and highlighted the first row, Down walked the
|
|
18
|
+
// rows, Enter selected, with the content mounted outside the root's DOM subtree entirely.
|
|
19
|
+
//
|
|
20
|
+
// ★ THE CONTROL AND THE FIELD ARE ONE ELEMENT. Not a button that is replaced by an input — the input
|
|
21
|
+
// IS the button, clamped to --forest-canvas-control while collapsed and widened on focus. Swapping two
|
|
22
|
+
// components could not animate (nothing to tween between) and could not line up (two different
|
|
23
|
+
// heights). One box that grows solves both, and removes a bug in passing: replacing the focused
|
|
24
|
+
// control fired a focusout with a null relatedTarget, which read as focus leaving and collapsed it
|
|
25
|
+
// again inside a frame.
|
|
26
|
+
//
|
|
27
|
+
// ★ IT DOES NOT FILTER THE MAP. Typing a vehicle ID does not hide the other vehicles — filtering is
|
|
28
|
+
// FFilterTrigger + FFilterClauses. Search navigates: it finds one thing and takes you to it.
|
|
29
|
+
import { computed, onBeforeUnmount, ref, shallowRef, toRef, watch } from 'vue';
|
|
30
|
+
import type mapboxgl from 'mapbox-gl';
|
|
31
|
+
import {
|
|
32
|
+
ListboxContent,
|
|
33
|
+
ListboxFilter,
|
|
34
|
+
ListboxGroup,
|
|
35
|
+
ListboxGroupLabel,
|
|
36
|
+
ListboxItem,
|
|
37
|
+
ListboxRoot,
|
|
38
|
+
} from 'reka-ui';
|
|
39
|
+
import { useMapCamera } from './useMap';
|
|
40
|
+
import { mapSearchTheme } from './mapSearch.theme';
|
|
41
|
+
import {
|
|
42
|
+
beginQuery,
|
|
43
|
+
paletteGroups,
|
|
44
|
+
receiveError,
|
|
45
|
+
receiveResults,
|
|
46
|
+
type MapSearchResult,
|
|
47
|
+
type MapSearchSource,
|
|
48
|
+
type MapSearchState,
|
|
49
|
+
type PaletteItem,
|
|
50
|
+
} from './mapSearch';
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(
|
|
53
|
+
defineProps<{
|
|
54
|
+
/** The live map, from <FMap @ready>. Null until it arrives, which disables the field. */
|
|
55
|
+
map?: mapboxgl.Map | null;
|
|
56
|
+
/** Where results come from. ORDER IS THE GROUP ORDER and must not change at runtime. */
|
|
57
|
+
sources: MapSearchSource[];
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
/** Width the field opens to. */
|
|
60
|
+
width?: string;
|
|
61
|
+
/** Zoom a point result flies to. Close enough to read the street, wide enough to keep a block. */
|
|
62
|
+
pointZoom?: number;
|
|
63
|
+
minQueryLength?: number;
|
|
64
|
+
debounceMs?: number;
|
|
65
|
+
}>(),
|
|
66
|
+
{
|
|
67
|
+
map: null,
|
|
68
|
+
placeholder: 'Search',
|
|
69
|
+
width: '18rem',
|
|
70
|
+
pointZoom: 16,
|
|
71
|
+
minQueryLength: 2,
|
|
72
|
+
debounceMs: 200,
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const emit = defineEmits<{
|
|
77
|
+
/**
|
|
78
|
+
* A pick, with the geometry it resolved to.
|
|
79
|
+
*
|
|
80
|
+
* ★ The geometry is a separate argument because the RESULT often does not carry one: a Mapbox
|
|
81
|
+
* suggestion has no coordinates until `retrieve()` runs, which happens in here. A surface that wants
|
|
82
|
+
* to drop a marker on the chosen place would otherwise have to repeat the resolve it just paid for.
|
|
83
|
+
*/
|
|
84
|
+
select: [result: MapSearchResult, source: MapSearchSource, geometry?: GeoJSON.Geometry];
|
|
85
|
+
}>();
|
|
86
|
+
|
|
87
|
+
const camera = useMapCamera(toRef(props, 'map'));
|
|
88
|
+
|
|
89
|
+
// Presentation lives in the recipe beside this file. It is a recipe rather than classes on the markup
|
|
90
|
+
// because reka's primitives carry no theme of their own — see that file's header for why, and for the
|
|
91
|
+
// coarse-pointer ladder it owns.
|
|
92
|
+
const ui = mapSearchTheme();
|
|
93
|
+
|
|
94
|
+
const term = ref('');
|
|
95
|
+
const state = shallowRef<MapSearchState | null>(null);
|
|
96
|
+
const resolving = ref<string | null>(null);
|
|
97
|
+
const expanded = ref(false);
|
|
98
|
+
const picked = shallowRef<PaletteItem | null>(null);
|
|
99
|
+
const root = ref<HTMLElement | null>(null);
|
|
100
|
+
|
|
101
|
+
let seq = 0;
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
let inflight: AbortController | undefined;
|
|
104
|
+
|
|
105
|
+
const groups = computed(() => (state.value ? paletteGroups(state.value) : []));
|
|
106
|
+
const panelOpen = computed(() => expanded.value && groups.value.length > 0);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The field's box while open: `width`, capped so it never runs past a narrow viewport.
|
|
110
|
+
*
|
|
111
|
+
* ★ WIDTH + MAX-WIDTH, not `w-full`, not a Tailwind arbitrary value, and not `min()`. Each of those
|
|
112
|
+
* was tried against a real phone viewport and each failed differently:
|
|
113
|
+
* · `w-full` is circular — the HUD rail is CONTENT-SIZED, so it shrink-wraps to the collapsed 44px
|
|
114
|
+
* button and a field asking for 100% of it resolves to 44px and never grows.
|
|
115
|
+
* · `w-[min(var(--x),calc(100vw-4rem))]` produced no rule at all; the element carried the class and
|
|
116
|
+
* collapsed to its icon.
|
|
117
|
+
* · `width: min(...)` as an inline style computed to 44px — the declaration was dropped.
|
|
118
|
+
* Two plain declarations survive all of it, and say the same thing: this wide, never wider than the
|
|
119
|
+
* screen. The cap is what makes a phone right; a bare 18rem lines up with the rail at exactly one
|
|
120
|
+
* device width and floats short of the edge on every other.
|
|
121
|
+
*/
|
|
122
|
+
const openBox = computed(() => ({
|
|
123
|
+
width: props.width,
|
|
124
|
+
maxWidth: 'calc(100vw - 4rem)',
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
function cancel() {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
inflight?.abort();
|
|
130
|
+
inflight = undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dispatch(q: string) {
|
|
134
|
+
cancel();
|
|
135
|
+
if (q.length < props.minQueryLength) {
|
|
136
|
+
state.value = null;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const mine = ++seq;
|
|
141
|
+
const controller = new AbortController();
|
|
142
|
+
inflight = controller;
|
|
143
|
+
state.value = beginQuery(props.sources, q, mine);
|
|
144
|
+
|
|
145
|
+
for (const source of props.sources) {
|
|
146
|
+
source
|
|
147
|
+
.search(q, controller.signal)
|
|
148
|
+
.then((results) => {
|
|
149
|
+
// The reducer drops anything belonging to an older query; this guard only avoids the work.
|
|
150
|
+
if (mine !== seq || !state.value) return;
|
|
151
|
+
state.value = receiveResults(state.value, mine, source.id, results);
|
|
152
|
+
})
|
|
153
|
+
.catch(() => {
|
|
154
|
+
if (mine !== seq || !state.value) return;
|
|
155
|
+
state.value = receiveError(state.value, mine, source.id);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
watch(term, (q) => {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
timer = setTimeout(() => dispatch(q), props.debounceMs);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
function collapse() {
|
|
166
|
+
cancel();
|
|
167
|
+
expanded.value = false;
|
|
168
|
+
term.value = '';
|
|
169
|
+
state.value = null;
|
|
170
|
+
// ★ Give up focus, or the control cannot be opened again. Picking a result keeps focus on the input
|
|
171
|
+
// by design (the rows carry @mousedown.prevent so the panel is not torn down under the pointer), so
|
|
172
|
+
// after a pick the collapsed control is still the focused element — and clicking an already-focused
|
|
173
|
+
// element fires no focusin, so the thing that opens it never runs. It looks dead on the second use.
|
|
174
|
+
const active = document.activeElement as HTMLElement | null;
|
|
175
|
+
if (active && root.value?.contains(active)) active.blur();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Clicking away collapses, whether or not anything was typed.
|
|
179
|
+
//
|
|
180
|
+
// ★ Asked a frame later, against where focus ENDED UP, because the event itself lies twice. A focusout
|
|
181
|
+
// with a null relatedTarget also fires when the focused element is merely removed, and the panel's rows
|
|
182
|
+
// live in a TELEPORTED subtree, so `root.contains(relatedTarget)` is false for a click on a result even
|
|
183
|
+
// though it is plainly inside the control. The rows also carry `@mousedown.prevent`, which keeps focus
|
|
184
|
+
// on the input so a pick never trips this at all.
|
|
185
|
+
function onFocusOut() {
|
|
186
|
+
requestAnimationFrame(() => {
|
|
187
|
+
if (!expanded.value) return;
|
|
188
|
+
if (root.value?.contains(document.activeElement)) return;
|
|
189
|
+
collapse();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sourceOf(item: PaletteItem): MapSearchSource {
|
|
194
|
+
// A promoted row carries no source of its own, so find the group its result actually came from.
|
|
195
|
+
const id =
|
|
196
|
+
state.value?.groups.find((g) => item.result && g.results.includes(item.result))?.sourceId ??
|
|
197
|
+
item.sourceId;
|
|
198
|
+
return props.sources.find((s) => s.id === id) ?? props.sources[0];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ListboxRoot types its model as reka's AcceptableValue, so the item comes back widened and is
|
|
202
|
+
// narrowed here rather than at the binding.
|
|
203
|
+
async function choose(value: unknown) {
|
|
204
|
+
picked.value = null;
|
|
205
|
+
const item = value as PaletteItem | null;
|
|
206
|
+
if (!item?.result) return;
|
|
207
|
+
const result = item.result;
|
|
208
|
+
const source = sourceOf(item);
|
|
209
|
+
let geometry = result.geometry;
|
|
210
|
+
|
|
211
|
+
if (!geometry && result.resolve) {
|
|
212
|
+
resolving.value = item.id;
|
|
213
|
+
try {
|
|
214
|
+
geometry = await result.resolve(new AbortController().signal);
|
|
215
|
+
} catch {
|
|
216
|
+
// The panel stays open on a failed resolve: closing on a camera that never moved reads as the
|
|
217
|
+
// pick having silently done nothing.
|
|
218
|
+
resolving.value = null;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
resolving.value = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (geometry) moveTo(geometry);
|
|
225
|
+
collapse();
|
|
226
|
+
emit('select', result, source, geometry);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ★ Centring is a lie on a real ops screen — a side panel and this panel cover part of the map, so the
|
|
230
|
+
// centre of the map element is not the centre of what the reader can see. It already works: <FMap>'s
|
|
231
|
+
// `padding` prop calls setPadding on the transform, and every camera move centres inside the remaining
|
|
232
|
+
// box. Which is why nothing here passes its own padding: that would override it.
|
|
233
|
+
function moveTo(geometry: GeoJSON.Geometry) {
|
|
234
|
+
if (geometry.type === 'Point') {
|
|
235
|
+
camera.focus(geometry.coordinates as [number, number], { zoom: props.pointZoom });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
camera.fit(extent(geometry));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function extent(geometry: GeoJSON.Geometry): [[number, number], [number, number]] {
|
|
242
|
+
let minX = Infinity;
|
|
243
|
+
let minY = Infinity;
|
|
244
|
+
let maxX = -Infinity;
|
|
245
|
+
let maxY = -Infinity;
|
|
246
|
+
const walk = (c: unknown): void => {
|
|
247
|
+
if (Array.isArray(c) && typeof c[0] === 'number' && typeof c[1] === 'number') {
|
|
248
|
+
minX = Math.min(minX, c[0]);
|
|
249
|
+
maxX = Math.max(maxX, c[0]);
|
|
250
|
+
minY = Math.min(minY, c[1]);
|
|
251
|
+
maxY = Math.max(maxY, c[1]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(c)) for (const part of c) walk(part);
|
|
255
|
+
};
|
|
256
|
+
walk('coordinates' in geometry ? geometry.coordinates : []);
|
|
257
|
+
return [
|
|
258
|
+
[minX, minY],
|
|
259
|
+
[maxX, maxY],
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
onBeforeUnmount(cancel);
|
|
264
|
+
</script>
|
|
265
|
+
|
|
266
|
+
<template>
|
|
267
|
+
<!-- ★ A plain element carries the focus wiring, not <ListboxRoot>. A template ref on a component
|
|
268
|
+
resolves to its INSTANCE, so `root.contains(...)` is undefined and the collapse check dies
|
|
269
|
+
silently inside its own rAF — the control simply never closed. -->
|
|
270
|
+
<div
|
|
271
|
+
ref="root"
|
|
272
|
+
class="pointer-events-auto"
|
|
273
|
+
@keydown.escape="collapse"
|
|
274
|
+
@focusin="expanded = true"
|
|
275
|
+
@click="expanded = true"
|
|
276
|
+
@focusout="onFocusOut"
|
|
277
|
+
>
|
|
278
|
+
<!-- ★ `highlight-on-hover` so the pointer drives the SAME state the keyboard does. Without it reka
|
|
279
|
+
marks a row highlighted only from the keyboard, and hovering does nothing at all — two input
|
|
280
|
+
methods reading as two different components. It also means the hover treatment cannot drift
|
|
281
|
+
from the keyboard one, since there is only one of them. -->
|
|
282
|
+
<ListboxRoot v-model="picked" by="id" highlight-on-hover @update:model-value="choose">
|
|
283
|
+
<UPopover
|
|
284
|
+
:open="panelOpen"
|
|
285
|
+
:dismissible="false"
|
|
286
|
+
:content="{
|
|
287
|
+
side: 'bottom',
|
|
288
|
+
align: 'start',
|
|
289
|
+
sideOffset: 8,
|
|
290
|
+
// The field keeps focus while the panel opens — the roving highlight lives on the input via
|
|
291
|
+
// aria-activedescendant, so moving focus into the content would break the combobox pattern.
|
|
292
|
+
trapFocus: false,
|
|
293
|
+
onOpenAutoFocus: (e: Event) => e.preventDefault(),
|
|
294
|
+
}"
|
|
295
|
+
:ui="{ content: ui.panel() }"
|
|
296
|
+
>
|
|
297
|
+
<!-- ★ ONE box for both states. Collapsed it is a square the size of every other canvas control;
|
|
298
|
+
focused it widens to `width`, and the transition is on width alone so the icon never moves
|
|
299
|
+
and the growth reads as the control opening rather than as two things swapping. The width
|
|
300
|
+
itself is an inline style rather than a class — see `openBox` for why none of the CSS
|
|
301
|
+
spellings survive. The panel needs no width of its own; it follows the anchor. -->
|
|
302
|
+
<div
|
|
303
|
+
:class="ui.field()"
|
|
304
|
+
:style="expanded ? openBox : { width: 'var(--forest-canvas-control)' }"
|
|
305
|
+
>
|
|
306
|
+
<UIcon name="i-lucide-search" :class="ui.icon()" />
|
|
307
|
+
<ListboxFilter v-model="term" as-child>
|
|
308
|
+
<input
|
|
309
|
+
type="text"
|
|
310
|
+
:aria-label="placeholder"
|
|
311
|
+
:placeholder="expanded ? placeholder : ''"
|
|
312
|
+
:disabled="!map"
|
|
313
|
+
autocomplete="off"
|
|
314
|
+
spellcheck="false"
|
|
315
|
+
:class="ui.input()"
|
|
316
|
+
/>
|
|
317
|
+
</ListboxFilter>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<template #content>
|
|
321
|
+
<!-- ★ THREE ROWS ON A PHONE, then it scrolls. Uncapped, the panel took 69% of the map and left
|
|
322
|
+
a 21px strip beside it — the cost of covering the map with none of the benefit of a full
|
|
323
|
+
takeover. Keeping roughly 300px of map live is the point: a reader scrolling results wants
|
|
324
|
+
to see WHERE they are while scrolling. Nothing is dropped; the rest is a scroll away. -->
|
|
325
|
+
<ListboxContent :class="ui.viewport()">
|
|
326
|
+
<ListboxGroup v-for="group in groups" :key="group.id">
|
|
327
|
+
<ListboxGroupLabel v-if="group.label" :class="ui.group()">
|
|
328
|
+
{{ group.label }}
|
|
329
|
+
</ListboxGroupLabel>
|
|
330
|
+
|
|
331
|
+
<ListboxItem
|
|
332
|
+
v-for="item in group.items"
|
|
333
|
+
:key="item.id"
|
|
334
|
+
:value="item"
|
|
335
|
+
:disabled="item.disabled"
|
|
336
|
+
:class="ui.item()"
|
|
337
|
+
@mousedown.prevent
|
|
338
|
+
>
|
|
339
|
+
<UIcon v-if="item.icon" :name="item.icon" :class="ui.itemIcon()" />
|
|
340
|
+
<USkeleton v-if="item.kind === 'skeleton'" :class="ui.skeleton()" />
|
|
341
|
+
<!-- ★ Stacked, not side by side. Two labels competing in one flex row let the LONGER one
|
|
342
|
+
keep more width, which collapsed a place name to "SE1 …" while its address kept
|
|
343
|
+
three times the space. The name is what the reader matched against. -->
|
|
344
|
+
<span v-else :class="ui.itemBody()">
|
|
345
|
+
<span :class="ui.itemLabel({ muted: item.kind !== 'result' })">{{ item.label }}</span>
|
|
346
|
+
<span v-if="item.description" :class="ui.itemDescription()">
|
|
347
|
+
{{ item.description }}
|
|
348
|
+
</span>
|
|
349
|
+
</span>
|
|
350
|
+
<UIcon
|
|
351
|
+
v-if="resolving === item.id"
|
|
352
|
+
name="i-lucide-loader-circle"
|
|
353
|
+
:class="ui.spinner()"
|
|
354
|
+
/>
|
|
355
|
+
</ListboxItem>
|
|
356
|
+
</ListboxGroup>
|
|
357
|
+
</ListboxContent>
|
|
358
|
+
</template>
|
|
359
|
+
</UPopover>
|
|
360
|
+
</ListboxRoot>
|
|
361
|
+
</div>
|
|
362
|
+
</template>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The 2D ⇄ 3D toggle — pitch as a MODE you enter on purpose.
|
|
3
|
+
//
|
|
4
|
+
// <FMapTilt :map="map" />
|
|
5
|
+
//
|
|
6
|
+
// ★ This and <FMapCompass> are not the same control, and shipping only one leaves a hole. The compass is
|
|
7
|
+
// RECOVERY: it appears once the view is off-axis and its whole job is the way back. This is ENTRY: it is
|
|
8
|
+
// always there, and it says the map can be tilted at all — which a pointer user has no way to discover
|
|
9
|
+
// otherwise, since the gesture is a modifier-drag nobody guesses.
|
|
10
|
+
//
|
|
11
|
+
// ★ The pressed state is read from the MAP, not from a click count. Pitch changes by gesture, by a
|
|
12
|
+
// camera move, and by the compass's own reset — a toggle holding its own boolean would sit lit over a
|
|
13
|
+
// map the compass had just flattened.
|
|
14
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
15
|
+
import type mapboxgl from 'mapbox-gl';
|
|
16
|
+
import FCanvasControl from '../canvas/FCanvasControl.vue';
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(
|
|
19
|
+
defineProps<{
|
|
20
|
+
map?: mapboxgl.Map | null;
|
|
21
|
+
/**
|
|
22
|
+
* The pitch this enters, in degrees. 60 is the shallowest angle at which extruded buildings read as
|
|
23
|
+
* volumes rather than as smeared footprints, and well inside Mapbox's 85 ceiling — past ~70 the
|
|
24
|
+
* horizon eats the frame and a fleet stops being scannable.
|
|
25
|
+
*/
|
|
26
|
+
pitch?: number;
|
|
27
|
+
}>(),
|
|
28
|
+
{ map: null, pitch: 60 },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Anything above a degree is a deliberate tilt; floating-point pitch rarely lands on exactly 0.
|
|
32
|
+
const FLAT_EPSILON = 1;
|
|
33
|
+
|
|
34
|
+
const current = ref(0);
|
|
35
|
+
const maxPitch = ref(60);
|
|
36
|
+
|
|
37
|
+
const tilted = computed(() => current.value >= FLAT_EPSILON);
|
|
38
|
+
// A surface can forbid tilt outright (maxPitch: 0). Then this is a button that cannot do its one thing.
|
|
39
|
+
const allowed = computed(() => maxPitch.value >= FLAT_EPSILON);
|
|
40
|
+
|
|
41
|
+
const sync = () => {
|
|
42
|
+
const m = props.map;
|
|
43
|
+
if (!m) return;
|
|
44
|
+
current.value = m.getPitch();
|
|
45
|
+
maxPitch.value = m.getMaxPitch();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
watch(
|
|
49
|
+
() => props.map,
|
|
50
|
+
(m, _old, onCleanup) => {
|
|
51
|
+
if (!m) return;
|
|
52
|
+
sync();
|
|
53
|
+
m.on('pitch', sync);
|
|
54
|
+
onCleanup(() => m.off('pitch', sync));
|
|
55
|
+
},
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(() => props.map?.off('pitch', sync));
|
|
60
|
+
|
|
61
|
+
// Clamped to whatever the surface allows, so a map with maxPitch 45 tilts to 45 rather than being asked
|
|
62
|
+
// for an angle it will silently refuse.
|
|
63
|
+
const toggle = () => {
|
|
64
|
+
const m = props.map;
|
|
65
|
+
if (!m) return;
|
|
66
|
+
m.easeTo({ pitch: tilted.value ? 0 : Math.min(props.pitch, m.getMaxPitch()) });
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ★ THE CONTROL WEARS THE MODE IT IS IN, NOT THE ONE THE PRESS ENTERS. A cube says neither, which is
|
|
70
|
+
// why this is lettered at all — and `pressed` means "currently held" in every Forest toggle, so a lit
|
|
71
|
+
// control labelled with the state it is NOT in would invert that rule in the one place a reader has no
|
|
72
|
+
// way to check it. The action stays in the accessible name, where a hover title and a screen reader
|
|
73
|
+
// both find it.
|
|
74
|
+
const mode = computed(() => (tilted.value ? '3D' : '2D'));
|
|
75
|
+
|
|
76
|
+
const label = computed(() => (tilted.value ? 'Flatten the map' : 'Tilt the map'));
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<FCanvasControl
|
|
81
|
+
:text="mode"
|
|
82
|
+
:label="label"
|
|
83
|
+
:disabled="!map || !allowed"
|
|
84
|
+
:pressed="tilted"
|
|
85
|
+
@click="toggle()"
|
|
86
|
+
/>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The region-select tool switch — one control that opens into the tools.
|
|
3
|
+
//
|
|
4
|
+
// <FMapTools v-model:tool="tool" />
|
|
5
|
+
//
|
|
6
|
+
// ★ THE OPENING BEHAVIOUR IS NOT HERE. It is <FCanvasFlyout>, in canvas/, because nothing about a
|
|
7
|
+
// control that unfolds into a cluster is about maps: a rail is narrow whatever it holds. What is left
|
|
8
|
+
// here is the only map-shaped part — which tools exist and what they are called.
|
|
9
|
+
//
|
|
10
|
+
// ★ THE TRIGGER WEARS THE ACTIVE TOOL. The question a reader asks passing a map is "what happens if I
|
|
11
|
+
// drag", and a fixed icon would make them open the group to find out. Lit unless the tool is `off`, so
|
|
12
|
+
// "the map is in its normal state" is legible without reading an icon at all.
|
|
13
|
+
import { computed } from 'vue';
|
|
14
|
+
import FCanvasFlyout from '../canvas/FCanvasFlyout.vue';
|
|
15
|
+
import type { RegionTool } from './regionSelect';
|
|
16
|
+
|
|
17
|
+
const tool = defineModel<RegionTool>('tool', { default: 'off' });
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
/** The axis the cluster opens along — horizontal for a vertical rail, and the reverse. */
|
|
22
|
+
orientation?: 'vertical' | 'horizontal';
|
|
23
|
+
/** Which end the trigger is pinned to, so the cluster opens inward. */
|
|
24
|
+
side?: 'start' | 'end';
|
|
25
|
+
/** Drop a tool a surface has no use for. Keep `off` — it is how a reader puts one down. */
|
|
26
|
+
tools?: RegionTool[];
|
|
27
|
+
}>(),
|
|
28
|
+
{ orientation: 'horizontal', side: 'end', tools: () => ['off', 'lasso', 'box', 'circle'] },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const ICON: Record<RegionTool, string> = {
|
|
32
|
+
off: 'i-lucide-hand',
|
|
33
|
+
lasso: 'i-lucide-lasso',
|
|
34
|
+
box: 'i-lucide-square-dashed',
|
|
35
|
+
circle: 'i-lucide-circle-dashed',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const LABEL: Record<RegionTool, string> = {
|
|
39
|
+
off: 'Pan the map',
|
|
40
|
+
lasso: 'Lasso select',
|
|
41
|
+
box: 'Box select',
|
|
42
|
+
circle: 'Radius select',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const items = computed(() =>
|
|
46
|
+
props.tools.map((t) => ({ value: t, icon: ICON[t], label: LABEL[t], pressed: tool.value === t })),
|
|
47
|
+
);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<FCanvasFlyout
|
|
52
|
+
:icon="ICON[tool]"
|
|
53
|
+
:label="LABEL[tool]"
|
|
54
|
+
:pressed="tool !== 'off'"
|
|
55
|
+
:items="items"
|
|
56
|
+
:orientation="orientation"
|
|
57
|
+
:side="side"
|
|
58
|
+
close-label="Close the tools"
|
|
59
|
+
@select="tool = $event as RegionTool"
|
|
60
|
+
/>
|
|
61
|
+
</template>
|