mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
feat(map): amap as map api provider and display template
This commit is contained in:
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user