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. */} - +