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
}
@@ -474,12 +474,49 @@ describe('PlaceDetailsColumn — hours and rating', () => {
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
describe('PlaceDetailsColumn — no key behind place search', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ keySuggestion: 'google' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-027b: suggests the Amap key when that is the one the admin chose', async () => {
// An install on Amap is not "missing a Google key" — the hint has to name
// the key that would actually move search off OpenStreetMap.
empty()
renderColumn({ keySuggestion: 'amap' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAmap')).toBeInTheDocument()
})
it('FE-PDC-027c: names both keys when the choice is automatic and neither is set', async () => {
empty()
renderColumn({ keySuggestion: 'any' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAny')).toBeInTheDocument()
})
it('FE-PDC-027d: stays quiet when OpenStreetMap was chosen on purpose', async () => {
// The null suggestion: an explicit admin decision, not a gap to fill.
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
@@ -490,7 +527,7 @@ describe('PlaceDetailsColumn — nothing found', () => {
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn()
renderColumn({ keySuggestion: 'google' })
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
@@ -15,6 +15,7 @@ import {
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -26,8 +27,19 @@ import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './place
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { KeySuggestion } from '../../utils/placesProvider'
import type { TranslationFn } from '../../types'
/**
* The "add a key" hint line, per which key would move search off OpenStreetMap.
* The Google variant reuses the long-standing key every locale already carries.
*/
const NO_KEY_HINT_KEY: Record<Exclude<KeySuggestion, never>, string> = {
google: 'places.details.noKeyHint',
amap: 'places.details.noKeyHintAmap',
any: 'places.details.noKeyHintAny',
}
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
@@ -51,7 +63,12 @@ interface PlaceDetailsColumnProps {
timeFormat?: string
/** For grouping the rating count's digits. */
locale?: string
/** False on an instance with no Google key, which is most of them. */
/**
* Which API key the "more detail available" hint should suggest, or null when
* there is nothing to suggest — a configured provider answers, or the admin
* chose OpenStreetMap on purpose and the hint would be a nag.
*/
keySuggestion?: KeySuggestion | null
t: TranslationFn
}
@@ -117,6 +134,7 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
keySuggestion = null,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -236,14 +254,20 @@ export default function PlaceDetailsColumn({
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<div className="space-y-2">
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
{/* Only when both are true. With a key behind the search there is
nothing to suggest, and on a place the free sources DID describe
the suggestion would be an advert. */}
{keySuggestion && <NoKeyHint t={t} suggestion={keySuggestion} />}
</div>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -472,6 +496,19 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t, suggestion }: { t: TranslationFn; suggestion: KeySuggestion }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t(NO_KEY_HINT_KEY[suggestion])}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -462,6 +462,35 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// The notice says what is actually true per provider choice, not "add a
// Google key" on an install whose admin picked Amap. See utils/placesProvider.
describe('FE-PLANNER-PLACEFORM-022b: the OSM notice follows the provider choice', () => {
it('suggests the Amap key when the admin chose Amap and none is set', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/高德|Amap/i)).toBeInTheDocument();
});
it('stays quiet when the chosen provider has its key', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: true, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
it('stays quiet when the admin explicitly chose OpenStreetMap', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'openstreetmap' });
render(<PlaceFormModal {...defaultProps} />);
// A deliberate choice is not a gap to fill — no upsell line at all.
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
it('stays quiet under auto when a Google key is configured', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, hasAmapKey: true, placesProvider: 'auto' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
});
// ── Category ─────────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => {
@@ -7,6 +7,18 @@ import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { effectivePlacesProvider, osmKeySuggestion, type KeySuggestion } from '../../utils/placesProvider'
/**
* The "search is on OpenStreetMap" line, per which key would actually move it
* off. The Google variant reuses the long-standing key, which every locale
* already carries; the other two are new with Amap.
*/
const OSM_ACTIVE_KEY: Record<KeySuggestion, string> = {
google: 'places.osmActive',
amap: 'places.osmActiveAmap',
any: 'places.osmActiveAny',
}
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -158,7 +170,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const { hasMapsKey, hasAmapKey, placesProvider, placesEnrichEnabled } = useAuthStore()
// What the search notices say depends on what will actually answer: an
// install whose admin picked Amap is not "missing a Google key".
const searchProvider = effectivePlacesProvider({ placesProvider, hasMapsKey, hasAmapKey })
const keySuggestion = osmKeySuggestion(placesProvider)
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -647,7 +663,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
hasMapsKey,
searchProvider,
keySuggestion,
placesEnrichEnabled,
can,
tripObj,
@@ -719,7 +736,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
hasMapsKey,
searchProvider,
keySuggestion,
placesEnrichEnabled,
can,
tripObj,
@@ -793,12 +811,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
// Null when search genuinely has no key behind it — the column then
// explains which one would help, or stays quiet when OpenStreetMap
// was chosen on purpose.
keySuggestion={searchProvider === 'openstreetmap' ? keySuggestion : null}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{searchProvider === 'openstreetmap' && keySuggestion && (
<p className="mb-2 text-xs text-content-faint">
{t(OSM_ACTIVE_KEY[keySuggestion])}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { gcj02ToWgs84 } from '@trek/shared'
import { getAmapUrlForPlace } from './placeAmap'
describe('getAmapUrlForPlace', () => {
it('writes the position as lng,lat in GCJ-02', () => {
const url = getAmapUrlForPlace({ name: '天安门', lat: 39.90869, lng: 116.39124 })!
const position = new URL(url).searchParams.get('position')!
const [lng, lat] = position.split(',').map(Number)
// Longitude first — the opposite of every other pair in TREK, and what
// Amap's URI API specifies.
expect(lng).toBeGreaterThan(100)
expect(lat).toBeLessThan(90)
// And in Amap's datum: converting back must land on the stored WGS-84 value.
// Handing the WGS-84 pair over unconverted would drop the pin a few hundred
// metres away, which in a city is the wrong block.
const back = gcj02ToWgs84(lat, lng)
expect(back.lat).toBeCloseTo(39.90869, 4)
expect(back.lng).toBeCloseTo(116.39124, 4)
expect(lat).not.toBeCloseTo(39.90869, 4)
})
it('carries the place name as the marker label', () => {
const url = getAmapUrlForPlace({ name: '故宫博物院', lat: 39.9163, lng: 116.3972 })!
expect(new URL(url).searchParams.get('name')).toBe('故宫博物院')
})
it('labels an unnamed place with its coordinates rather than returning nothing', () => {
// Amap refuses a marker request with no name, so the alternative to this is
// no link at all for a place added by right-clicking the map.
const url = getAmapUrlForPlace({ name: '', lat: 39.9163, lng: 116.3972 })!
const params = new URL(url).searchParams
expect(params.get('name')).toBe(params.get('position'))
})
it('needs coordinates: a name alone has nothing to place', () => {
expect(getAmapUrlForPlace({ name: '某地', lat: null, lng: null } as never)).toBeNull()
expect(getAmapUrlForPlace(null)).toBeNull()
})
it('leaves a coordinate outside China unshifted', () => {
// GCJ-02 does not apply there, and shifting it would move the pin off the
// place for a trip that has nothing to do with China.
const url = getAmapUrlForPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })!
expect(new URL(url).searchParams.get('position')).toBe('2.294500,48.858400')
})
})
@@ -0,0 +1,33 @@
import { wgs84ToGcj02 } from '@trek/shared'
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
/**
* Open a place in Amap (高德地图), which is what a phone in China has installed.
*
* `uri.amap.com` is Amap's own cross-platform handover endpoint: it opens the app
* when it is present and falls back to Amap's web map when it is not, which is
* the same property that made the https form the right choice for CoMaps. The
* `src` and `callnative` parameters are what Amap documents for this: `callnative=1`
* asks it to hand over to the app rather than staying on the web page.
*
* The coordinate is converted to GCJ-02 first. Amap reads `position` in its own
* datum, and TREK stores WGS-84, so handing the stored value over unconverted
* drops the pin a few hundred metres away — far enough, in a city, to be the
* wrong block. This is the mirror image of what the server does when it reads an
* Amap result (see server/src/nest/maps/providers/amap.provider.ts).
*
* Longitude first, unlike every other coordinate pair in this codebase: that is
* the order Amap's URI API specifies.
*/
export function getAmapUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place || place.lat == null || place.lng == null) return null
const gcj = wgs84ToGcj02(place.lat, place.lng)
const position = `${gcj.lng.toFixed(6)},${gcj.lat.toFixed(6)}`
const name = place.name?.trim()
// Amap labels the marker from `name` and refuses the request without one, so an
// unnamed place gets its coordinates as the label rather than no link at all.
const label = encodeURIComponent(name || position)
return `https://uri.amap.com/marker?position=${position}&name=${label}&src=trek&coordinate=gaode&callnative=1`
}
@@ -1,11 +1,13 @@
import { isOutsideChina } from '@trek/shared'
import type { AssignmentPlace, Place } from '../../types'
import { getAmapUrlForPlace } from './placeAmap'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps' | 'amap'
export interface NavigationTarget {
id: NavigationAppId
@@ -92,6 +94,17 @@ export function getNavigationTargets(
const coMapsUrl = getCoMapsUrlForPlace(place)
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
// Offered by WHERE the place is, not by who is looking at it. Amap only has a
// map of China, so it is the right choice for a stop in Shanghai whoever is
// planning the trip, and dead weight for one in Lisbon whoever is planning it —
// and this row is deliberately short. showsAppleMaps() above narrows by
// platform for the same reason; this one narrows by geography because that is
// what decides whether the link leads anywhere.
if (place.lat != null && place.lng != null && !isOutsideChina(place.lat, place.lng)) {
const amapUrl = getAmapUrlForPlace(place)
if (amapUrl) targets.push({ id: 'amap', label: '高德地图', url: amapUrl })
}
return targets
}
@@ -79,7 +79,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
// Places-API enrichment (#886) needs a Google Maps key. Not the places
// *provider* choice: enrichment's photos and summary come from Google (and,
// keyless, from Wikimedia), which is independent of which provider answers
// search — an Amap install with a Google key still enriches through Google.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -12,6 +12,7 @@ import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { withTileApiKey } from '../../utils/tileUrl'
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'
import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
@@ -40,6 +41,11 @@ const MAP_PRESETS: MapPreset[] = [
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
// Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for
// these so markers still land on the right street (see gcj02Crs.ts). The only
// basemap here that is genuinely good inside mainland China.
{ name: '高德地图 (Amap)', url: AMAP_ROAD },
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
]
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a