feat(map): amap as map api provider and display template

This commit is contained in:
A1phaN
2026-09-07 10:04:12 +08:00
committed by Maurice
parent a066c94ec5
commit 57eac32f9f
92 changed files with 2910 additions and 70 deletions
+20 -4
View File
@@ -53,8 +53,9 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { resolveBasemap } from '../../utils/tileUrl'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
@@ -687,6 +688,13 @@ export const MapView = memo(function MapView({
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
/**
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
@@ -1002,6 +1010,10 @@ export const MapView = memo(function MapView({
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
// outright on an infinite one, which took the whole planner down.
maxZoom={MAP_MAX_ZOOM}
// Omitted entirely for a WGS-84 basemap, which is every install that has
// not chosen Amap: passing L.CRS.EPSG3857 explicitly would be the same
// value, but leaving the prop off keeps those maps byte-identical.
{...(gcjCrs ? { crs: gcjCrs } : {})}
className="w-full h-full bg-[#e5e7eb]"
>
{/* The basemap is a vector style by default and a raster template when the
@@ -1012,8 +1024,12 @@ export const MapView = memo(function MapView({
{isSatellite ? (
<TileLayer
key="satellite"
url={SATELLITE_TILE_URL}
attribution={SATELLITE_TILE_ATTRIBUTION}
// Esri's imagery is WGS-84 and Amap's is GCJ-02, and the map's CRS is
// already fixed by the basemap the user chose. Handing the other datum
// to that projection moves the whole photo a few hundred metres, so the
// satellite toggle follows the datum rather than always meaning Esri.
url={isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL}
attribution={attributionForTile(isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL)}
maxZoom={SATELLITE_TILE_MAXZOOM}
keepBuffer={8}
updateWhenZooming={false}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import L from 'leaflet'
import { crsForBasemap, getCrsGcj02 } from './gcj02Crs'
const CRS_GCJ02 = getCrsGcj02()
/**
* What these guard is the one failure that cannot be seen in a screenshot review
* of a map full of Chinese labels: a marker drawn a few hundred metres from where
* it belongs still looks like a perfectly plausible map.
*/
describe('CRS_GCJ02', () => {
it('projects a WGS-84 coordinate onto the GCJ-02 tile grid', () => {
const wgs = L.latLng(39.90869, 116.39124)
const shifted = CRS_GCJ02.latLngToPoint(wgs, 15)
const unshifted = L.CRS.EPSG3857.latLngToPoint(wgs, 15)
// At zoom 15 the GCJ-02 offset is worth several hundred pixels, so a map that
// silently kept EPSG3857 would fail here rather than being off by a rounding.
const dx = Math.abs(shifted.x - unshifted.x)
const dy = Math.abs(shifted.y - unshifted.y)
expect(Math.hypot(dx, dy)).toBeGreaterThan(20)
})
it('round-trips a coordinate through the pixel plane', () => {
const wgs = L.latLng(31.2304, 121.4737)
const back = CRS_GCJ02.pointToLatLng(CRS_GCJ02.latLngToPoint(wgs, 18), 18)
// Sub-metre at zoom 18, i.e. the click handler and the marker agree.
expect(back.lat).toBeCloseTo(wgs.lat, 6)
expect(back.lng).toBeCloseTo(wgs.lng, 6)
})
it('leaves a coordinate outside China exactly where EPSG3857 puts it', () => {
// Otherwise choosing an Amap basemap would move every foreign trip in the
// account, which is a far bigger failure than the one this file prevents.
const paris = L.latLng(48.8566, 2.3522)
const shifted = CRS_GCJ02.latLngToPoint(paris, 15)
const plain = L.CRS.EPSG3857.latLngToPoint(paris, 15)
expect(shifted.x).toBeCloseTo(plain.x, 6)
expect(shifted.y).toBeCloseTo(plain.y, 6)
})
it('keeps the Web Mercator tile grid, because Amap uses the standard one', () => {
// Same scale and same transformation as EPSG3857: only the datum differs. If
// this drifts, tiles are requested at the wrong zoom or the wrong origin and
// the map is visibly broken rather than subtly offset.
expect(CRS_GCJ02.scale(12)).toBe(L.CRS.EPSG3857.scale(12))
expect(CRS_GCJ02.code).toBe('TREK:GCJ02')
})
it('measures distance in WGS-84, since that is what it is handed', () => {
const a = L.latLng(39.90869, 116.39124)
const b = L.latLng(39.91869, 116.39124)
expect(CRS_GCJ02.distance(a, b)).toBeCloseTo(L.CRS.EPSG3857.distance(a, b), 6)
})
})
describe('crsForBasemap', () => {
it('returns undefined for a WGS-84 basemap so the map keeps Leaflet\u2019s default', () => {
// Not L.CRS.EPSG3857: passing the prop at all pins the CRS, and the point is
// that a map on an ordinary basemap is constructed exactly as it was before.
expect(crsForBasemap(false)).toBeUndefined()
})
it('returns the shifted CRS for a GCJ-02 basemap', () => {
expect(crsForBasemap(true)).toBe(getCrsGcj02())
})
})
+79
View File
@@ -0,0 +1,79 @@
import L from 'leaflet'
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared'
/**
* A Leaflet CRS that draws WGS-84 coordinates on GCJ-02 tiles.
*
* Amap's raster tiles are rendered in GCJ-02 — the datum Chinese law requires
* published maps to use — while every coordinate in TREK is WGS-84: the database
* columns, the search results, the routes from OSRM, an imported GPX track. Put
* one on the other untouched and every marker sits a few hundred metres from the
* building it names, which around a city block is the wrong street.
*
* The fix could live in three places, and two of them are worse:
*
* - Converting each marker at the call site means every component that draws
* anything on a map has to know which basemap is loaded — MapView,
* JourneyMap, CollectionMap, the cluster group, the route polylines, the
* bounds fitting, the click handler that turns a pixel back into a place.
* Miss one and it is off by 300 m with no error anywhere.
* - A constant offset is not a fix at all: the shift is a function of position,
* ranging from roughly 100 m to 800 m across the country.
*
* So it goes into the projection, which is the one place every coordinate
* already passes through. `project` shifts WGS-84 → GCJ-02 before the standard
* Web Mercator maths, `unproject` shifts back, and everything above it —
* markers, polylines, `getBounds`, `containerPointToLatLng` — keeps speaking
* WGS-84 without knowing this exists.
*
* Everything else is inherited from EPSG3857 on purpose: Amap's tile grid IS
* standard Web Mercator (same zoom levels, same 256 px tiles, same origin), so
* the scale and the transformation must not change. Only the datum does.
* `distance()` also stays as it is and stays correct, because it is handed
* WGS-84 latitudes.
*/
function buildCrs(): L.CRS {
const mercator = L.Projection.SphericalMercator
const projection: L.Projection = {
project(latlng: L.LatLng): L.Point {
const gcj = wgs84ToGcj02(latlng.lat, latlng.lng)
return mercator.project(L.latLng(gcj.lat, gcj.lng))
},
unproject(point: L.Point): L.LatLng {
const gcj = mercator.unproject(point)
const wgs = gcj02ToWgs84(gcj.lat, gcj.lng)
return L.latLng(wgs.lat, wgs.lng)
},
bounds: mercator.bounds,
}
return L.Util.extend({}, L.CRS.EPSG3857, { code: 'TREK:GCJ02', projection }) as L.CRS
}
/**
* Built on first use, not at import.
*
* Reading `L.Projection.SphericalMercator` while the module graph loads means
* every importer of MapView has to have a fully-formed Leaflet — which the map
* component tests deliberately do not, they mock it down to the handful of
* exports they assert on. A basemap nobody selected should not be able to break
* an unrelated test file, or a build, by existing.
*/
let cached: L.CRS | null = null
export function getCrsGcj02(): L.CRS {
if (!cached) cached = buildCrs()
return cached
}
/**
* The CRS a map should use for a given basemap.
*
* Returning `undefined` rather than `L.CRS.EPSG3857` matters: passing a `crs`
* option to a MapContainer is what pins it, and Leaflet cannot change a map's
* CRS after construction. Handing back undefined lets the component omit the
* prop entirely for the 99% of installs on a WGS-84 basemap, which keeps their
* map byte-identical to what it was.
*/
export function crsForBasemap(isGcj02: boolean): L.CRS | undefined {
return isGcj02 ? getCrsGcj02() : undefined
}