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