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.
This commit is contained in:
Maurice
2026-11-13 09:23:27 +01:00
parent 1f710bd217
commit 69552ffeef
9 changed files with 238 additions and 37 deletions
@@ -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(<JourneyLayeredMap layers={layers({ marks: [] })} onOpenEntry={onOpenEntry} checkins={[]} entries={[]} />);
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(<JourneyLayeredMap engine="leaflet" layers={layers()} checkins={[]} entries={[]} />);
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(<JourneyLayeredMap layers={layers()} hideLayerControl checkins={[]} entries={[]} />);
expect(screen.getByRole('button', { name: 'Switch to satellite view' })).toBeInTheDocument();
unmount();
render(<JourneyLayeredMap engine="leaflet" checkins={[]} entries={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'Switch to satellite view' }));
expect(last()).toMatchObject({ baseLayer: 'satellite' });
});
});
@@ -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<typeof JourneyMapAuto>,
'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<T>(offered: unknown, action: T): T | undefined {
return offered ? action : undefined;
@@ -43,6 +47,11 @@ function offeredIf<T>(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<BaseLayer>('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({
<div className="isolate h-full w-full">
{engine === 'leaflet' ? <JourneyMap ref={ref} {...mapProps} /> : <JourneyMapAuto ref={ref} {...mapProps} />}
</div>
<div className="pointer-events-auto absolute end-3 z-10" style={{ top: controlsTop }}>
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} tooltipPlacement="bottom" />
</div>
{layers && (
<div
className="pointer-events-none absolute inset-x-3 z-10 flex flex-col items-start gap-2"
style={{ top: controlsTop }}
// Ends before the corner switcher, so a wide row of switches scrolls
// rather than running under it.
style={{ top: controlsTop, paddingInlineEnd: MAP_ROUND_CONTROL_SIZE + CORNER_GAP }}
>
{!hideLayerControl && (
<div className="pointer-events-auto max-w-full">
@@ -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(<JourneyMap checkins={[]} entries={entriesWithCoords} baseLayer="satellite" />);
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(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const vector = await waitFor(() => {
const layer = lastGlLayer();
expect(layer).toBeTruthy();
return layer!;
});
rerender(<JourneyMap checkins={[]} entries={entriesWithCoords} baseLayer="satellite" />);
// 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(<JourneyMap checkins={[]} entries={entriesWithCoords} baseLayer="default" />);
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(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const road = vi.mocked(L.tileLayer).mock.results[0].value as { remove: Mock };
rerender(<JourneyMap checkins={[]} entries={entriesWithCoords} baseLayer="satellite" />);
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 {
+75 -22
View File
@@ -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<string>
/** 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<L.TileLayer | null>(null)
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
const glLayerRef = useRef<BasemapLayer | null>(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<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(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.
@@ -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<string>
/** The reader's base layer (#2594), both renderers. */
baseLayer?: BaseLayer
}
function JourneyMapAuto({ ref, ...props }: Props) {
@@ -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<typeof vi.fn>
@@ -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(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false, entries: false }} />)
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(<JourneyMapGL checkins={[]} entries={entries} baseLayer="satellite" />)
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(<JourneyMapGL checkins={[]} entries={entries} />)
expect(gl.map.addLayer).not.toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), expect.anything())
rerender(<JourneyMapGL checkins={[]} entries={entries} baseLayer="satellite" />)
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-satellite-raster' }), undefined)
gl.map.getLayer.mockReturnValue({ id: 'trip-satellite-raster' })
rerender(<JourneyMapGL checkins={[]} entries={entries} baseLayer="default" />)
expect(gl.map.setLayoutProperty).toHaveBeenLastCalledWith('trip-satellite-raster', 'visibility', 'none')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
})
})
+20 -1
View File
@@ -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<string>
/** 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
+13 -4
View File
@@ -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<typeof Tooltip>['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. */}
<Tooltip label={label} placement="right">
<Tooltip label={label} placement={tooltipPlacement}>
<button
type="button"
onClick={onToggle}
+3 -2
View File
@@ -24,8 +24,9 @@ const SATELLITE_LAYER_ID = 'trip-satellite-raster';
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
// route source so they sit under the current route, which also puts them before the first
// `trip-` layer, and anchoring the imagery there painted right over them.
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
// `trip-` layer, and anchoring the imagery there painted right over them. `journey-` is
// the journey map's own: its tracks and the line between its entries (#2594).
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-', 'journey-'];
/**
* A GL map's zoom on the 256 px tile scale that raster tile URLs, their