From 00cac02665019b5217ef473d6c58542256a184c4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 15:56:37 +0200 Subject: [PATCH] feat(maps): satellite on the GL maps too, not just Leaflet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The imagery toggle has been there for a while and only ever worked on Leaflet, so anyone on MapLibre or Mapbox — which is the default since the vector basemap landed — had a switch they could not see and a view they could not reach. Leaflet swaps its whole tile layer for the imagery. A GL map cannot: its basemap is a style with dozens of layers in it. So the same tokenless ESRI source goes on as a raster layer of its own, inserted before the first layer TREK draws, which leaves the route, the pins and the tracks on top and legible. Switching back hides it rather than tearing it down, so going to and fro does not re-fetch a tile. Built on demand rather than once at load, because a basemap change rebuilds the style from scratch and takes every source with it; the styledata event is subscribed for the same reason. The switch reads the setting the Leaflet map already stores, so the choice survives an instance changing renderer, and it sits in the same corner with the same offsets. --- client/src/components/Map/MapViewGL.test.tsx | 64 ++++++++++++++ client/src/components/Map/MapViewGL.tsx | 89 +++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 784c60060..3f8ffe0f7 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -1955,4 +1955,68 @@ describe('MapViewGL', () => { // in the wrong place. expect(layer.firstElementChild).not.toBe(handle) }) + // ── Satellite ─────────────────────────────────────────────────────────────── + // + // Leaflet has had the imagery for a while and swaps its whole tile layer for it. + // A GL map cannot do that, because its basemap is a style with dozens of layers, + // so the imagery goes on as a raster layer under everything TREK draws. + + it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, + } as never) + glMap.getStyle.mockReturnValue({ + layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }], + }) + + render() + await flushFrames() + + expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ + type: 'raster', + tiles: [expect.stringContaining('arcgisonline.com')], + })) + // Anchored before the route, or the imagery would be painted over it. + expect(glMap.addLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }), + 'trip-route', + ) + }) + + it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, + } as never) + + render() + await flushFrames() + + expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything()) + }) + + it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => { + loadOnAttach() + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' }, + } as never) + glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] }) + // Present from the first pass on, the way the real map reports it afterwards. + glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null)) + glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null)) + + const { rerender } = render() + await flushFrames() + + act(() => { + useSettingsStore.setState({ + settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' }, + } as never) + }) + rerender() + await flushFrames() + + expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none') + }) }) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 72005e7c4..015e9d604 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' +import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { isCustomPlaceImage, photoCacheKey } from './placePhoto' @@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' -import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' +import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport' function categoryIconSvg(iconName: string | null | undefined, size: number): string { @@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle' const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count' const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit' const GPX_HIT_LAYER_ID = 'trip-gpx-hit' +/** + * The satellite base layer, the GL twin of the Leaflet one. + * + * Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the + * basemap is a style with dozens of layers in it. So the imagery goes on as a raster + * layer of its own, drawn over the style and under everything TREK adds — the route, + * the pins and the tracks stay on top and stay legible, which is the same order the + * Leaflet map ends up with. + * + * Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key. + */ +const SATELLITE_SOURCE_ID = 'trip-satellite' +const SATELLITE_LAYER_ID = 'trip-satellite-raster' +/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */ +const OWN_LAYER_PREFIXES = ['trip-', 'trek-'] type PlaceWithCoords = Place & { lat: number; lng: number } @@ -233,6 +249,44 @@ function addPlaceClusterLayers(map: any): void { }) } +/** + * Puts the imagery on the map, or takes it off again. + * + * Built on demand rather than once at load: a style change drops every source the map + * had, and a hot reload does the same. Guarded on its own absence, so the usual pass is + * a no-op and only the visibility flips. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function applySatellite(map: any, on: boolean): void { + try { + if (!map.isStyleLoaded?.()) return + if (!map.getSource(SATELLITE_SOURCE_ID)) { + if (!on) return // Nothing to build while it is switched off. + map.addSource(SATELLITE_SOURCE_ID, { + type: 'raster', + tiles: [SATELLITE_TILE_URL], + tileSize: 256, + maxzoom: SATELLITE_TILE_MAXZOOM, + attribution: SATELLITE_TILE_ATTRIBUTION, + }) + // Under the first thing TREK draws, over everything the basemap style draws. + // Without the anchor the imagery lands on top and buries the route. + const layers = map.getStyle?.()?.layers ?? [] + const firstOwn = layers.find((l: { id: string }) => + OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix))) + map.addLayer({ + id: SATELLITE_LAYER_ID, + type: 'raster', + source: SATELLITE_SOURCE_ID, + paint: { 'raster-opacity': 1 }, + }, firstOwn?.id) + } + if (map.getLayer(SATELLITE_LAYER_ID)) { + map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none') + } + } catch { /* a style that refuses the layer keeps the plain basemap */ } +} + function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { // A stop that interrupts the drive gets its own small disc, decided before the photo // branch: the brand logo a fuel search comes back with is exactly what this replaces. @@ -594,6 +648,15 @@ export function MapViewGL({ const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') + // The same stored choice the Leaflet map reads, so the two renderers agree. + const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' + const updateSetting = useSettingsStore(s => s.updateSetting) + const isSatellite = baseLayer === 'satellite' + const toggleBaseLayer = useCallback(() => { + // The store flips synchronously, so the map switches even offline; a failed save + // is logged there rather than blocking the switch. + updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) + }, [isSatellite, updateSetting]) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true @@ -2058,6 +2121,18 @@ export function MapViewGL({ else map.once('load', apply) }, [userPosition, trackingMode, glProvider]) + // Satellite, the same setting the Leaflet map reads, so switching it on one renderer + // and reloading into the other keeps the choice. `styledata` is subscribed because a + // basemap change rebuilds the style from scratch and takes the imagery with it. + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + const apply = () => applySatellite(map, isSatellite) + apply() + map.on('styledata', apply) + return () => { map.off('styledata', apply) } + }, [isSatellite, mapReady, glProvider]) + if (!isMapLibre && !mapboxToken) { return (
@@ -2093,6 +2168,18 @@ export function MapViewGL({ bottomOffset={buttonBottom as unknown as number} /> )} + {/* Same pill, same corner, same offsets as the Leaflet map: the switch should not + move when an instance changes renderer. The day panel is a centred card on the + desktop map and never reaches the pill, but on a phone it is full width and + does, which is why the lift is only there. */} +
+ +
{/* Hover tooltip — cursor-following name/category/address card, identical to the Leaflet map's overlay (no anchored popup, no photo). */} {!hoverDisabled && hoverPlace && hoverPos && !isMobile && (