From 69552ffeef7e0a92f24ab3af1ecd75c3680659fc Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 09:23:27 +0100 Subject: [PATCH] feat(journey): satellite base layer on every journey map, the shared page included (#2594) The journey map gets the planner's round satellite switch in its top corner, on the desktop aside, the phone views and the public page. Leaflet takes the vector basemap off while the imagery shows and reattaches it on the way back; the GL renderer slides the imagery under the journey's own layers. The choice is per view and opens on the map, so no setting is read or written. --- .../Journey/JourneyLayeredMap.test.tsx | 23 ++++- .../components/Journey/JourneyLayeredMap.tsx | 21 +++- .../components/Journey/JourneyMap.test.tsx | 57 ++++++++++- client/src/components/Journey/JourneyMap.tsx | 97 ++++++++++++++----- .../src/components/Journey/JourneyMapAuto.tsx | 3 + .../components/Journey/JourneyMapGL.test.tsx | 31 +++++- .../src/components/Journey/JourneyMapGL.tsx | 21 +++- .../src/components/Map/MapLayerSwitcher.tsx | 17 +++- client/src/components/Map/glRasterLayers.ts | 5 +- 9 files changed, 238 insertions(+), 37 deletions(-) diff --git a/client/src/components/Journey/JourneyLayeredMap.test.tsx b/client/src/components/Journey/JourneyLayeredMap.test.tsx index 25fde6c8b..c6dbbe31b 100644 --- a/client/src/components/Journey/JourneyLayeredMap.test.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.test.tsx @@ -1,4 +1,4 @@ -// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-003 +// FE-JOURNEY-LAYEREDMAP-001 to FE-JOURNEY-LAYEREDMAP-005 // // JRN-04: the journey map with its layers: what the renderer is handed, and a // picked mark's card. @@ -110,4 +110,25 @@ describe('JourneyLayeredMap', () => { rerender(); expect(screen.queryByRole('dialog')).toBeNull(); }); + + it('FE-JOURNEY-LAYEREDMAP-004: the satellite switch opens on the map and swaps the base of this view (#2594)', () => { + render(); + expect(last()).toMatchObject({ baseLayer: 'default' }); + + fireEvent.click(screen.getByRole('button', { name: 'Switch to satellite view' })); + expect(last()).toMatchObject({ baseLayer: 'satellite' }); + + fireEvent.click(screen.getByRole('button', { name: 'Switch to map view' })); + expect(last()).toMatchObject({ baseLayer: 'default' }); + }); + + it('FE-JOURNEY-LAYEREDMAP-005: the switch stays on the phone and without layers, where the shared page draws', () => { + const { unmount } = render(); + expect(screen.getByRole('button', { name: 'Switch to satellite view' })).toBeInTheDocument(); + unmount(); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to satellite view' })); + expect(last()).toMatchObject({ baseLayer: 'satellite' }); + }); }); diff --git a/client/src/components/Journey/JourneyLayeredMap.tsx b/client/src/components/Journey/JourneyLayeredMap.tsx index 1c9f2da7f..021d549c5 100644 --- a/client/src/components/Journey/JourneyLayeredMap.tsx +++ b/client/src/components/Journey/JourneyLayeredMap.tsx @@ -1,5 +1,6 @@ import type { JourneyMapPlace } from '@trek/shared'; import { useCallback, useState, type ComponentProps, type Ref } from 'react'; +import { MAP_ROUND_CONTROL_SIZE, MapLayerSwitcher, type BaseLayer } from '../Map/MapLayerSwitcher'; import JourneyMap, { type JourneyMapHandle } from './JourneyMap'; import JourneyMapAuto from './JourneyMapAuto'; import JourneyMapLayerControl from './JourneyMapLayerControl'; @@ -9,7 +10,7 @@ import type { JourneyMapLayersView } from './useJourneyMapLayers'; type MapProps = Omit< ComponentProps, - 'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds' + 'ref' | 'layerVisibility' | 'marks' | 'onMarksPick' | 'plannedEntryIds' | 'baseLayer' > & { /** CARTO key of a shared page, which the Leaflet renderer reads; see JourneyMap. */ cartoApiKey?: string; @@ -32,6 +33,9 @@ interface Props extends MapProps { onWriteAboutPlace?: (place: JourneyMapPlace) => void; } +/** The gap between the switcher in the top corner and the column of controls beside it. */ +const CORNER_GAP = 8; + /** A card action, only where the screen offers one. */ function offeredIf(offered: unknown, action: T): T | undefined { return offered ? action : undefined; @@ -43,6 +47,11 @@ function offeredIf(offered: unknown, action: T): T | undefined { * surface draws through here, the desktop aside, the phone screens and the * shared page, so the layers mean the same everywhere. A switched-off layer * is hidden in place: the map is not rebuilt and keeps its view. + * + * The satellite switch sits in the top corner on every one of them, the phone + * included (#2594): it is what a visitor of a shared hike reads the route by. + * It belongs to this view alone and opens on the map, so nobody's own setting + * is read or written, which a shared page could not do anyway. */ export default function JourneyLayeredMap({ ref, @@ -59,6 +68,8 @@ export default function JourneyLayeredMap({ ...rest }: Props) { const [picked, setPicked] = useState<{ marks: JourneyMapMark[]; key: string } | null>(null); + const [baseLayer, setBaseLayer] = useState('default'); + const toggleBaseLayer = useCallback(() => setBaseLayer((was) => (was === 'satellite' ? 'default' : 'satellite')), []); const visibility = layers?.visibility; const onMarksPick = useCallback((marks: JourneyMapMark[]) => { setPicked({ marks, key: marks.map((m) => m.key).join(',') }); @@ -92,6 +103,7 @@ export default function JourneyLayeredMap({ marks: layers?.marks, onMarksPick, plannedEntryIds: layers?.plannedEntryIds, + baseLayer, }; return ( @@ -99,10 +111,15 @@ export default function JourneyLayeredMap({
{engine === 'leaflet' ? : }
+
+ +
{layers && (
{!hideLayerControl && (
diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx index be395a70f..019391519 100644 --- a/client/src/components/Journey/JourneyMap.test.tsx +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-063 +// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-066 vi.mock('../../api/websocket', () => ({ connect: vi.fn(), @@ -78,7 +78,7 @@ vi.mock('leaflet', () => { return { default: { map: vi.fn(() => mockMap), - tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })), + tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn(), remove: vi.fn() })), marker: vi.fn(() => mockMarker), polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }), divIcon: vi.fn(() => ({})), @@ -91,7 +91,7 @@ vi.mock('leaflet', () => { DomEvent: domEvent, }, map: vi.fn(() => mockMap), - tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })), + tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn(), remove: vi.fn() })), marker: vi.fn(() => mockMarker), polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }), divIcon: vi.fn(() => ({})), @@ -107,7 +107,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; import { buildSettings } from '../../../tests/helpers/factories'; -import { AMAP_ROAD } from '../../constants/mapDefaults'; +import { AMAP_ROAD, AMAP_SATELLITE, SATELLITE_TILE_URL } from '../../constants/mapDefaults'; import type { Mock } from 'vitest'; import L from 'leaflet'; import JourneyMap from './JourneyMap'; @@ -665,6 +665,55 @@ describe('JourneyMap', () => { expect(layer.getMaplibreMap().setStyle).not.toHaveBeenCalledWith(expect.stringContaining('tiles.test')); }); + describe('satellite base layer (#2594)', () => { + const lastGlLayer = () => { + const results = vi.mocked(maplibreGL).mock.results; + return results[results.length - 1]?.value as { remove: Mock } | undefined; + }; + + it('FE-COMP-JOURNEYMAP-064: a map opened on satellite draws the imagery and never loads the vector style', async () => { + render(); + expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe(SATELLITE_TILE_URL); + // The style arrives after a dynamic import, so give it the chance to. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(maplibreGL).not.toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-065: the switch swaps the base in place, and back to the vector style', async () => { + const { rerender } = render(); + const vector = await waitFor(() => { + const layer = lastGlLayer(); + expect(layer).toBeTruthy(); + return layer!; + }); + + rerender(); + // Not a rebuild: the markers and tracks stay where they were drawn. + expect(L.map).toHaveBeenCalledTimes(1); + expect(mockedMap().remove).not.toHaveBeenCalled(); + // The GL canvas comes off rather than drawing on under the imagery. + expect(vector.remove).toHaveBeenCalled(); + const imagery = vi.mocked(L.tileLayer).mock.results[0].value as { remove: Mock }; + expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe(SATELLITE_TILE_URL); + + const stylesBefore = vi.mocked(maplibreGL).mock.calls.length; + rerender(); + expect(imagery.remove).toHaveBeenCalled(); + await waitFor(() => expect(vi.mocked(maplibreGL).mock.calls.length).toBe(stylesBefore + 1)); + expect(L.map).toHaveBeenCalledTimes(1); + }); + + it("FE-COMP-JOURNEYMAP-066: on an Amap basemap the imagery is Amap's own, on the same datum", () => { + seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: AMAP_ROAD }) }); + const { rerender } = render(); + const road = vi.mocked(L.tileLayer).mock.results[0].value as { remove: Mock }; + + rerender(); + expect(road.remove).toHaveBeenCalled(); + expect(vi.mocked(L.tileLayer).mock.calls[1][0]).toBe(AMAP_SATELLITE); + }); + }); + it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop pans to that marker after the settle delay', () => { vi.useFakeTimers(); try { diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 3a111f270..4dd2c03cc 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -4,9 +4,10 @@ import L from 'leaflet' import { useSettingsStore } from '../../store/settingsStore' import { useCartoApiKey } from '../../hooks/useTileUrl' import { isGcj02Basemap, isVectorStyle, resolveTileUrl } from '../../utils/tileUrl' -import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults' +import { AMAP_SATELLITE, OFM_DARK, OFM_POSITRON, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL, attributionForTile } from '../../constants/mapDefaults' import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap' import { crsForBasemap } from '../Map/gcj02Crs' +import type { BaseLayer } from '../Map/MapLayerSwitcher' import { escapeHtml, type JourneyTrack } from '@trek/shared' import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks' @@ -94,6 +95,8 @@ interface Props { onMarksPick?: (marks: JourneyMapMark[]) => void /** Entry pins whose place the plan had too, marked with a check. */ plannedEntryIds?: ReadonlySet + /** The reader's base layer (#2594): the basemap, or the satellite imagery in its place. */ + baseLayer?: BaseLayer } /** The map's own zoom buttons, frosted so they hold on any tile. */ @@ -296,8 +299,29 @@ function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitB } } +/** + * The tile layer that draws a raster base: the user's own template, or the + * satellite imagery. The imagery follows the basemap's datum, as on the planner + * map: Esri's is WGS-84 and Amap's GCJ-02, and the other one on this map's + * projection would sit a few hundred metres off. + */ +function rasterBase(url: string, satellite: boolean, gcj: boolean): L.TileLayer { + const tileUrl = satellite ? (gcj ? AMAP_SATELLITE : SATELLITE_TILE_URL) : url + return L.tileLayer(tileUrl, { + maxZoom: satellite ? SATELLITE_TILE_MAXZOOM : 18, + attribution: attributionForTile(tileUrl), + referrerPolicy: 'strict-origin-when-cross-origin', + // Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle + // before loading tiles). On the journey mobile combined view we flyTo + // constantly when switching cards, so tiles lag visibly — force eager + // updates and keep a larger ring of off-screen tiles ready. + updateWhenIdle: false, + keepBuffer: 4, + } as any) +} + function JourneyMap( - { entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props, + { entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, baseLayer = 'default', ref }: Props, ) { // The leading inset is read when the map frames, not a reason to rebuild it. const paddingStartRef = useRef(paddingStart) @@ -326,6 +350,16 @@ function JourneyMap( const tileLayerRef = useRef(null) // GL layer or the raster stand-in a browser without WebGL gets instead (#2288). const glLayerRef = useRef(null) + // Read by the map effect like the tile url, and switched in place by its own + // effect below: a rebuild would drop every marker and track for a new backdrop. + const satellite = baseLayer === 'satellite' + const satelliteRef = useRef(satellite) + satelliteRef.current = satellite + // Bumped by every base drawn, so a vector style still loading for a base the + // reader has already switched away from never lands on the map. + const baseDrawRef = useRef(0) + // Which base the map carries now, so the switch effect redraws only on a change. + const drawnSatelliteRef = useRef(satellite) const containerRef = useRef(null) const mapRef = useRef(null) const markersRef = useRef>(new Map()) @@ -358,6 +392,30 @@ function JourneyMap( plannedRef.current = plannedEntryIds }) + /** + * Draws the base: the satellite imagery while the reader asked for it, + * otherwise the basemap, which is a vector style unless the user brought their + * own raster template. Whatever drew it before is taken off first. A vector + * style is a GL canvas of its own, so leaving it under the imagery would keep + * a WebGL context busy drawing what nobody can see. + */ + const drawBase = useCallback((map: L.Map, torn: () => boolean) => { + const draw = ++baseDrawRef.current + drawnSatelliteRef.current = satelliteRef.current + tileLayerRef.current?.remove() + tileLayerRef.current = null + detachBasemapLayer(glLayerRef.current) + glLayerRef.current = null + const url = tileUrlRef.current + if (!satelliteRef.current && isVectorStyle(url)) { + void attachVectorBasemap(map, url, glLayerRef, () => torn() || draw !== baseDrawRef.current) + return + } + const tiles = rasterBase(url, satelliteRef.current, !isVectorStyle(url) && isGcj02Basemap(url)) + tiles.addTo(map) + tileLayerRef.current = tiles + }, []) + const syncEntryCard = useEntryCardSync({ onCardClickRef: onMarkerCardClickRef, mapRef, markersRef, itemsRef, highlightedRef }) const highlightMarker = useCallback((id: string | null) => { @@ -421,25 +479,9 @@ function JourneyMap( map.on('dragstart', readerMoved) containerRef.current.addEventListener('wheel', readerMoved, { passive: true }) - // The basemap is a vector style unless the user brought their own raster - // template, so which layer draws it is decided per template rather than once. - if (isVectorStyle(tileUrlRef.current)) { - void attachVectorBasemap(map, tileUrlRef.current, glLayerRef, () => cancelled) - } else { - const tiles = L.tileLayer(tileUrlRef.current, { - maxZoom: 18, - attribution: attributionForTile(tileUrlRef.current), - referrerPolicy: 'strict-origin-when-cross-origin', - // Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle - // before loading tiles). On the journey mobile combined view we flyTo - // constantly when switching cards, so tiles lag visibly — force eager - // updates and keep a larger ring of off-screen tiles ready. - updateWhenIdle: false, - keepBuffer: 4, - } as any) - tiles.addTo(map) - tileLayerRef.current = tiles - } + // Which layer draws the base is decided per template and per the reader's + // switch rather than once; see drawBase. + drawBase(map, () => cancelled) const items = buildMarkerItems(entries) itemsRef.current = items @@ -573,7 +615,7 @@ function JourneyMap( markersRef.current.clear() drawnRef.current = NOTHING_DRAWN() } - }, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap]) + }, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap, drawBase]) // A switch puts its layer back on the map or takes it off, exactly as drawn. useEffect(() => { @@ -595,11 +637,22 @@ function JourneyMap( // Retile in place rather than through the effect above, which would drop every // marker and track it just drew. A vector basemap restyles instead, which also // avoids spending a WebGL context on every theme toggle. + // Under the imagery the basemap is not drawn at all, and the switch back draws + // it from the current template. useEffect(() => { + if (satelliteRef.current) return if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl) else tileLayerRef.current?.setUrl(tileUrl) }, [tileUrl]) + // The reader's base layer switch (#2594). Nothing to do when the map effect + // has just drawn the base the switch asks for. + useEffect(() => { + const map = mapRef.current + if (!map || drawnSatelliteRef.current === satellite) return + drawBase(map, () => mapRef.current !== map) + }, [satellite, drawBase]) + // Photo layer (#1614). Its own effect on purpose: photos arriving must not tear // down and rebuild the map the way the entry effect does. Redrawn after every // move, but the clusters sit on a world grid, so only a zoom regroups them. diff --git a/client/src/components/Journey/JourneyMapAuto.tsx b/client/src/components/Journey/JourneyMapAuto.tsx index dff1adaf7..654f652af 100644 --- a/client/src/components/Journey/JourneyMapAuto.tsx +++ b/client/src/components/Journey/JourneyMapAuto.tsx @@ -10,6 +10,7 @@ import type { JourneyTrack } from '@trek/shared' import type { MapPhoto } from './journeyPhotoLayer' import type { JourneyMapLayerVisibility } from './journeyMapLayers' import type { JourneyMapMark } from './journeyMapMarks' +import type { BaseLayer } from '../Map/MapLayerSwitcher' // Unified handle — both providers expose the same three methods. export type JourneyMapAutoHandle = JourneyMapHandle @@ -57,6 +58,8 @@ interface Props { marks?: JourneyMapMark[] onMarksPick?: (marks: JourneyMapMark[]) => void plannedEntryIds?: ReadonlySet + /** The reader's base layer (#2594), both renderers. */ + baseLayer?: BaseLayer } function JourneyMapAuto({ ref, ...props }: Props) { diff --git a/client/src/components/Journey/JourneyMapGL.test.tsx b/client/src/components/Journey/JourneyMapGL.test.tsx index 9b7e22ad3..31934a8a3 100644 --- a/client/src/components/Journey/JourneyMapGL.test.tsx +++ b/client/src/components/Journey/JourneyMapGL.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453, FE-COMP-JMAPGL-2910 +// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-050, FE-COMP-JMAPGL-2453, FE-COMP-JMAPGL-2910 type Spy = ReturnType @@ -47,6 +47,9 @@ const gl = vi.hoisted(() => { addSource: vi.fn(), addLayer: vi.fn(), getSource: vi.fn((): unknown => null), + // The satellite imagery (#2594) asks for its layer and for the style's layers to slide under. + getLayer: vi.fn((): unknown => undefined), + getStyle: vi.fn((): unknown => ({ layers: [] })), setTerrain: vi.fn(), setConfigProperty: vi.fn(), setLayoutProperty: vi.fn(), @@ -185,6 +188,8 @@ beforeEach(() => { gl.map.getZoom.mockReturnValue(10) gl.map.project.mockImplementation(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })) gl.map.getSource.mockReturnValue(null) + gl.map.getLayer.mockReturnValue(undefined) + gl.map.getStyle.mockReturnValue({ layers: [] }) vi.mocked(isStandardFamily).mockReturnValue(false) vi.mocked(supportsCustom3d).mockReturnValue(false) vi.mocked(wantsTerrain).mockReturnValue(false) @@ -900,4 +905,28 @@ describe('JourneyMapGL', () => { rerender() expect(gl.map.setLayoutProperty).toHaveBeenLastCalledWith('journey-route-covered-line', 'visibility', 'none') }) + + it("FE-COMP-JMAPGL-049: a map opened on satellite slides the imagery under the journey's own layers (#2594)", () => { + withToken() + gl.map.getStyle.mockReturnValue({ layers: [{ id: 'water' }, { id: 'journey-route-line' }] }) + render() + + expect(gl.map.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({ type: 'raster' })) + // Over the basemap's water, under the line between the entries. + expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), 'journey-route-line') + }) + + it('FE-COMP-JMAPGL-050: the switch flips the imagery in place, without rebuilding the map', () => { + withToken() + const { rerender } = render() + expect(gl.map.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything()) + + rerender() + expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), undefined) + + gl.map.getLayer.mockReturnValue({ id: 'trip-satellite-raster' }) + rerender() + expect(gl.map.setLayoutProperty).toHaveBeenLastCalledWith('trip-satellite-raster', 'visibility', 'none') + expect(mapboxgl.Map).toHaveBeenCalledTimes(1) + }) }) diff --git a/client/src/components/Journey/JourneyMapGL.tsx b/client/src/components/Journey/JourneyMapGL.tsx index fab59cdeb..42a86127c 100644 --- a/client/src/components/Journey/JourneyMapGL.tsx +++ b/client/src/components/Journey/JourneyMapGL.tsx @@ -4,6 +4,8 @@ import { useSettingsStore } from '../../store/settingsStore' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders' import { buildGlMap, initialMapStyle } from '../Map/glSupport' +import { applySatellite } from '../Map/glRasterLayers' +import type { BaseLayer } from '../Map/MapLayerSwitcher' import { mapboxTileCacheRequest, tileCacheActive } from '../../native/tileCache' import type { JourneyTrack } from '@trek/shared' import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup' @@ -68,6 +70,8 @@ interface Props { marks?: JourneyMapMark[] onMarksPick?: (marks: JourneyMapMark[]) => void plannedEntryIds?: ReadonlySet + /** See the Leaflet twin (#2594): the imagery goes on under the journey's own layers. */ + baseLayer?: BaseLayer glProvider?: GlMapProvider /** * The GL engine, injected instead of imported. Both SDKs used to be pulled in @@ -202,7 +206,7 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin } function JourneyMapGL( - { entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props, + { entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, paddingStart = 0, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, baseLayer = 'default', ref }: Props, ) { // The leading inset is read when the map frames, not a reason to rebuild it. const paddingStartRef = useRef(paddingStart) @@ -261,6 +265,10 @@ function JourneyMapGL( }) const darkRef = useRef(dark) darkRef.current = dark + // Read when a rebuilt map loads, and switched in place by its own effect below. + const satellite = baseLayer === 'satellite' + const satelliteRef = useRef(satellite) + satelliteRef.current = satellite const mapLangRef = useRef(mapLang) mapLangRef.current = mapLang @@ -516,6 +524,9 @@ function JourneyMapGL( } } + // After the journey's own layers, so the imagery can be slipped in under them. + if (satelliteRef.current) applySatellite(map, true) + // markers items.forEach((item) => { const el = markerHtml(item.dayColor, item.dayLabel, false, plannedRef.current.has(item.id)) @@ -617,6 +628,14 @@ function JourneyMapGL( shownRef.current = layerVisibility }, [layerVisibility]) + // The reader's base layer switch (#2594), in place: a rebuild would drop every + // marker and track for a new backdrop. A map whose style is not in yet refuses + // the layer quietly and gets it from its load handler instead. + useEffect(() => { + const map = mapRef.current + if (map) applySatellite(map, satellite) + }, [satellite]) + // The plan's check on an entry pin follows the plan layer in place. useEffect(() => { const before = iconPlannedRef.current diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 55ace706f..67e6d2f1e 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,7 +1,7 @@ import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react' import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer' import { PisteKeyButton } from '../Tours/winter/PisteKey' -import type { ComponentType, CSSProperties } from 'react' +import type { ComponentProps, ComponentType, CSSProperties } from 'react' import { useTranslation } from '../../i18n' import { FerrataLineIcon } from '../Ferratas/FerrataGlyph' import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer' @@ -42,8 +42,17 @@ export const MAP_ROUND_CONTROL_SIZE = 42 // Round base-layer switcher for both planner maps, Leaflet and GL (default street // tiles ↔ satellite). Same frosted shell as MapCompassPill so it lines up with the -// other map controls; the icon shows the layer it switches to. -export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onToggle: () => void }) { +// other map controls; the icon shows the layer it switches to. The journey map +// wears it too, in its top corner, so its tooltip can open below instead. +export function MapLayerSwitcher({ + active, + onToggle, + tooltipPlacement = 'right', +}: { + active: BaseLayer + onToggle: () => void + tooltipPlacement?: ComponentProps['placement'] +}) { const { t } = useTranslation() const isSatellite = active === 'satellite' const Icon = isSatellite ? MapIcon : Satellite @@ -55,7 +64,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo TripRouteOverviewPill. To the right, though: this one sits at the bottom left of the map, where a tooltip to the left would land on the day sidebar and one below would land off the screen. */} - +