diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx
index d32123535..38529236b 100644
--- a/client/src/components/Journey/JourneyMap.test.tsx
+++ b/client/src/components/Journey/JourneyMap.test.tsx
@@ -62,6 +62,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 type { Mock } from 'vitest';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
@@ -89,6 +90,11 @@ vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
// cases are about the GL layer, so the probe says yes here.
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
+// The real CRS is built from Leaflet's projection, which the mock above does not
+// carry. What matters here is only that the map is handed one for Amap tiles.
+vi.mock('../Map/gcj02Crs', () => ({
+ crsForBasemap: (gcj02: boolean) => (gcj02 ? { code: 'TREK:GCJ02' } : undefined),
+}));
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
@@ -420,6 +426,18 @@ describe('JourneyMap', () => {
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
render();
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png');
+ // A WGS-84 template leaves the projection alone: passing the default CRS
+ // explicitly would be the same value, but omitting it keeps the map as it was.
+ expect(vi.mocked(L.map).mock.calls[0][1]).not.toHaveProperty('crs');
+ });
+
+ it('FE-COMP-JOURNEYMAP-027b: an Amap preset builds the map on the GCJ-02 projection', () => {
+ // The same shift the planner map applies. Miss it here and every entry on
+ // the journey page sits a few hundred metres from where the planner drew it.
+ seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: AMAP_ROAD }) });
+ render();
+ expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe(AMAP_ROAD);
+ expect(vi.mocked(L.map).mock.calls[0][1]).toMatchObject({ crs: { code: 'TREK:GCJ02' } });
});
it('FE-COMP-JOURNEYMAP-041: a basemap change restyles in place instead of rebuilding the map (#2097)', async () => {
diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx
index de9c1fe91..33b0e1066 100644
--- a/client/src/components/Journey/JourneyMap.tsx
+++ b/client/src/components/Journey/JourneyMap.tsx
@@ -2,9 +2,10 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'r
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl'
-import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
+import { isGcj02Basemap, isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
+import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
@@ -156,6 +157,10 @@ function JourneyMap(
const storedCartoKey = useCartoApiKey()
const cartoKey = cartoApiKey || storedCartoKey
const tileUrl = resolveTileUrl(mapTileUrl, dark ? OFM_DARK : OFM_POSITRON, cartoKey)
+ // Amap's tiles are GCJ-02 (see gcj02Crs.ts), the same shift the planner map
+ // applies. Leaflet fixes a map's CRS at construction, so this one value is
+ // allowed to rebuild the map where a template change only retiles it.
+ const isGcjBasemap = !isVectorStyle(tileUrl) && isGcj02Basemap(tileUrl)
// Read through a ref by the map effect, retiled in place by its own effect below:
// the CARTO key reaches the store after the first render, and rebuilding the map
// for that raced with the markers and layers already on it (#2097).
@@ -235,7 +240,9 @@ function JourneyMap(
markersRef.current.clear()
+ const crs = crsForBasemap(isGcjBasemap)
const map = L.map(containerRef.current, {
+ ...(crs ? { crs } : {}),
zoomControl: false,
attributionControl: true,
scrollWheelZoom: fullScreen ? true : false,
@@ -384,7 +391,7 @@ function JourneyMap(
glLayerRef.current = null
markersRef.current.clear()
}
- }, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
+ }, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
// 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