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