fix(maps): project the journey map like the planner on a GCJ-02 basemap

The journey map read the same tile setting as the planner but built its
Leaflet map without the shifted CRS, so an Amap basemap put every entry a
few hundred metres off. A change of datum rebuilds the map, since Leaflet
cannot swap a CRS after construction; every other template change still
retiles in place.
This commit is contained in:
Maurice
2026-09-12 13:56:48 +02:00
parent 84c277993c
commit fdb993de62
2 changed files with 27 additions and 2 deletions
@@ -62,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories'; import { buildSettings } from '../../../tests/helpers/factories';
import { AMAP_ROAD } from '../../constants/mapDefaults';
import type { Mock } from 'vitest'; import type { Mock } from 'vitest';
import L from 'leaflet'; import L from 'leaflet';
import JourneyMap from './JourneyMap'; 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 // 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. // cases are about the GL layer, so the probe says yes here.
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} })); 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 = [ const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, { 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' }) }); seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />); render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png'); 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(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
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 () => { it('FE-COMP-JOURNEYMAP-041: a basemap change restyles in place instead of rebuilding the map (#2097)', async () => {
+9 -2
View File
@@ -2,9 +2,10 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'r
import L from 'leaflet' import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useCartoApiKey } from '../../hooks/useTileUrl' 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 { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap' import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
import { crsForBasemap } from '../Map/gcj02Crs'
import { escapeHtml, type JourneyTrack } from '@trek/shared' import { escapeHtml, type JourneyTrack } from '@trek/shared'
export interface MapMarkerItem { export interface MapMarkerItem {
@@ -156,6 +157,10 @@ function JourneyMap(
const storedCartoKey = useCartoApiKey() const storedCartoKey = useCartoApiKey()
const cartoKey = cartoApiKey || storedCartoKey const cartoKey = cartoApiKey || storedCartoKey
const tileUrl = resolveTileUrl(mapTileUrl, dark ? OFM_DARK : OFM_POSITRON, cartoKey) 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: // 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 // 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). // for that raced with the markers and layers already on it (#2097).
@@ -235,7 +240,9 @@ function JourneyMap(
markersRef.current.clear() markersRef.current.clear()
const crs = crsForBasemap(isGcjBasemap)
const map = L.map(containerRef.current, { const map = L.map(containerRef.current, {
...(crs ? { crs } : {}),
zoomControl: false, zoomControl: false,
attributionControl: true, attributionControl: true,
scrollWheelZoom: fullScreen ? true : false, scrollWheelZoom: fullScreen ? true : false,
@@ -384,7 +391,7 @@ function JourneyMap(
glLayerRef.current = null glLayerRef.current = null
markersRef.current.clear() 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 // 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 // marker and track it just drew. A vector basemap restyles instead, which also