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