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
@@ -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)