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 && (