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
+4 -2
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,13 +301,15 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
+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
+28
View File
@@ -51,6 +51,33 @@ export const OFM_ATTRIBUTION =
const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/**
* Amap (高德) raster basemaps.
*
* The reason to offer these at all: OpenStreetMap and OpenFreeMap are thin
* inside mainland China, and every foreign tile CDN is slow or unreachable from
* a Chinese network. These are the tiles a Chinese user expects to see.
*
* `style=7` is the road map, `style=6` is satellite imagery. `lang=zh_cn`
* because these are the labels the tiles exist for; an install that wants
* English labels is better served by OpenFreeMap.
*
* A single host rather than the `webrd0{1..4}` shard family Amap also serves:
* `{s}` in a Leaflet template is substituted from `subdomains`, which defaults
* to a/b/c and would produce `webrd0a`. Every consumer of a stored template
* would have to pass digits instead, and over HTTP/2 the sharding buys nothing.
*
* IMPORTANT: these tiles are drawn in GCJ-02, not WGS-84. Everything else in
* TREK — every marker, route and bounding box — is WGS-84, and dropping one
* datum on top of the other puts markers a few hundred metres off the street
* they belong to. isGcj02Basemap() in utils/tileUrl.ts spots these URLs and the
* map switches to the shifted CRS in components/Map/gcj02Crs.ts. Anything added
* here that serves GCJ-02 tiles must be recognised by that predicate too.
*/
export const AMAP_ROAD = 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}'
export const AMAP_SATELLITE = 'https://webst01.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}'
export const AMAP_ATTRIBUTION = '&copy; <a href="https://amap.com">高德地图</a>'
/**
* Attribution for whatever basemap a map ended up with. OpenFreeMap asks for a
* credit of its own, and printing OpenStreetMap alone under its tiles is both
@@ -61,6 +88,7 @@ export function attributionForTile(url: string | null | undefined): string {
if (!url) return OSM_ATTRIBUTION
if (url.includes('openfreemap.org')) return OFM_ATTRIBUTION
if (url.includes('arcgisonline.com')) return SATELLITE_TILE_ATTRIBUTION
if (url.includes('autonavi.com')) return AMAP_ATTRIBUTION
return OSM_ATTRIBUTION
}
@@ -38,7 +38,8 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
handleSaveApiKeys, handleValidateKey,
@@ -229,6 +230,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<MAdminSecretInput
aria-label={t('admin.mapsKey')}
value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
@@ -256,8 +258,39 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
)}
</MAdminField>
{/* No Test button, same as the desktop tab: /auth/validate-keys only
knows how to probe Google Places and OpenWeatherMap. */}
<MAdminField label={t('admin.amapKey')} hint={t('admin.amapKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.amapKey')}
value={amapKey}
onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
<select
value={placesProvider}
disabled={savingPlacesProvider}
onChange={(e) => handleSavePlacesProvider(e.target.value)}
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none disabled:opacity-50"
>
<option value="auto">{t('admin.placesProvider.auto')}</option>
<option value="google">{t('admin.placesProvider.google')}</option>
<option value="amap">{t('admin.placesProvider.amap')}</option>
<option value="openstreetmap">{t('admin.placesProvider.openstreetmap')}</option>
</select>
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-warn,#b45309)]">
{t('admin.placesProvider.missingKey')}
</p>
)}
</MAdminField>
<MAdminField label={t('admin.unsplashKey')} hint={t('admin.unsplashKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.unsplashKey')}
value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
@@ -18,6 +18,7 @@ import {
type GlMapProvider,
} from '../../../components/Map/glProviders'
import { withTileApiKey } from '../../../utils/tileUrl'
import { AMAP_ROAD, AMAP_SATELLITE } from '../../../constants/mapDefaults'
import MToggle from '../../components/MToggle'
import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MSetPickerSheet from './MSetPickerSheet'
@@ -39,6 +40,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 },
]
type Provider = 'leaflet' | GlMapProvider
+34 -3
View File
@@ -1168,9 +1168,12 @@ describe('AdminPage', () => {
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
// The Unsplash key is the second 'Enter key...' input (after Maps).
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
// By accessible name, not by position: the card gained an Amap field
// between Maps and Unsplash, and an index would have kept passing while
// asserting about the wrong input.
fireEvent.change(within(apiKeysCard!).getByLabelText('Unsplash API Key'), {
target: { value: 'test-unsplash-key' },
});
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
@@ -1178,6 +1181,34 @@ describe('AdminPage', () => {
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
});
});
it('typing in the Amap API key and clicking Save sends amap_api_key', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
fireEvent.change(within(apiKeysCard!).getByLabelText(/Amap/), {
target: { value: 'test-amap-key' },
});
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(capturedBody?.amap_api_key).toBe('test-amap-key');
});
});
});
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
@@ -253,18 +253,22 @@ describe('AdminSettingsTab', () => {
expect(admin.toggleKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-021: showKeys switches both key inputs to plain text', () => {
renderTab({ showKeys: { maps: true, unsplash: true } });
// Selected by accessible name rather than by position: the API-keys card has
// grown a field before now (Amap), and an index-based lookup silently starts
// asserting about a different input when it does.
it('FE-ADMSET-021: showKeys switches every key input to plain text', () => {
renderTab({ showKeys: { maps: true, unsplash: true, amap: true } });
const inputs = within(card('API Keys')).getAllByPlaceholderText('Enter key...');
expect(inputs[0]).toHaveAttribute('type', 'text');
expect(inputs[1]).toHaveAttribute('type', 'text');
const keys = within(card('API Keys'));
expect(keys.getByLabelText('Google Maps API Key')).toHaveAttribute('type', 'text');
expect(keys.getByLabelText('Unsplash API Key')).toHaveAttribute('type', 'text');
expect(keys.getByLabelText(/Amap/)).toHaveAttribute('type', 'text');
});
it('FE-ADMSET-022: unsplash key input and its visibility toggle', () => {
const admin = renderTab({ unsplashKey: '' });
const unsplashInput = within(card('API Keys')).getAllByPlaceholderText('Enter key...')[1];
const unsplashInput = within(card('API Keys')).getByLabelText('Unsplash API Key');
fireEvent.change(unsplashInput, { target: { value: 'unsplash-key' } });
expect(admin.setUnsplashKey).toHaveBeenCalledWith('unsplash-key');
@@ -272,6 +276,34 @@ describe('AdminSettingsTab', () => {
expect(admin.toggleKey).toHaveBeenCalledWith('unsplash');
});
it('FE-ADMSET-022b: amap key input and its visibility toggle', () => {
const admin = renderTab({ amapKey: '' });
const amapInput = within(card('API Keys')).getByLabelText(/Amap/);
fireEvent.change(amapInput, { target: { value: 'amap-key' } });
expect(admin.setAmapKey).toHaveBeenCalledWith('amap-key');
fireEvent.click(amapInput.parentElement!.querySelector('button')!);
expect(admin.toggleKey).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022c: warns when the selected provider has no key behind it', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing, which is quiet enough to be mistaken for the provider working.
renderTab({ placesProvider: 'amap', amapKey: '' });
expect(
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
).toBeInTheDocument();
});
it('FE-ADMSET-022d: saving the provider goes through updateAppSettings, not the keys', () => {
const admin = renderTab({ placesProvider: 'auto' });
fireEvent.change(within(card('API Keys')).getByLabelText('Place search provider'), {
target: { value: 'amap' },
});
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
renderTab({ mapsKey: '' });
+62 -1
View File
@@ -35,7 +35,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
@@ -546,6 +547,32 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<p className="text-xs text-content-faint mt-1.5">{t('admin.unsplashKeyHint')}</p>
</div>
</ProviderBlock>
{/* Amap Key. No Test button: /auth/validate-keys only knows how to
probe Google Places and OpenWeatherMap, and a button that silently
tests something else is worse than no button. */}
<ProviderBlock title={t('admin.amapKey')}>
<div>
<div className="relative">
<input
type={showKeys.amap ? 'text' : 'password'}
value={amapKey}
onChange={e => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('amap')}
aria-label={t('admin.amapKey')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-faint hover:text-content"
>
{showKeys.amap ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-content-faint mt-1.5">{t('admin.amapKeyHint')}</p>
</div>
</ProviderBlock>
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
so an install can point transit search at Google instead, on the
same key. Falls back to Transitous when no key resolves. A block of
@@ -605,6 +632,40 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</ProviderBlock>
</>)}
{/* Which keyed provider answers place search. Outside the managed guard
on purpose: the operator owns the credentials, but what their users
search against is still the admin's call (see server managed.ts). */}
<ProviderBlock title={t('admin.placesProvider.title')}>
<div>
<p className="text-xs text-content-faint">{t('admin.placesProvider.subtitle')}</p>
<CustomSelect
value={placesProvider}
disabled={savingPlacesProvider}
onChange={value => {
if (value === placesProvider) return
void handleSavePlacesProvider(String(value))
}}
options={[
{ value: 'auto', label: t('admin.placesProvider.auto') },
{ value: 'google', label: t('admin.placesProvider.google') },
{ value: 'amap', label: t('admin.placesProvider.amap') },
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
]}
size="sm"
style={{ marginTop: 8 }}
/>
{/* A provider chosen without a key behind it answers with
OpenStreetMap rather than failing, which is quiet enough to be
mistaken for the provider working. Say so. */}
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
<p className="flex items-start gap-2 text-xs text-warning bg-warning-soft border border-warning/30 rounded-lg px-3 py-2 mt-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
{t('admin.placesProvider.missingKey')}
</p>
)}
</div>
</ProviderBlock>
{/* The search log sits with the index rather than with a provider:
it records which result somebody picked, so a candidate index can
be judged against real searches later. Off unless switched on,
+31 -2
View File
@@ -168,6 +168,15 @@ export function useAdmin() {
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('')
const [amapKey, setAmapKey] = useState<string>('')
/**
* Which provider answers place search. Not a key, so it saves through
* updateAppSettings rather than with the keys — and it is saved on change
* rather than with the Save button, because it is one choice from a list and
* the effect is immediate everywhere.
*/
const [placesProvider, setPlacesProvider] = useState<string>('auto')
const [savingPlacesProvider, setSavingPlacesProvider] = useState<boolean>(false)
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
@@ -234,6 +243,7 @@ export function useAdmin() {
setPasskeyLogin(!!config.passkey_login)
setPasskeyConfigured(!!config.passkey_configured)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
if (config.places_provider) setPlacesProvider(config.places_provider)
} catch (err: unknown) {
// ignore
}
@@ -245,6 +255,7 @@ export function useAdmin() {
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '')
setAmapKey(data.settings?.amap_api_key || '')
} catch (err: unknown) {
// ignore
}
@@ -300,6 +311,7 @@ export function useAdmin() {
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
amap_api_key: amapKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
@@ -313,7 +325,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
@@ -327,7 +339,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
@@ -337,6 +349,21 @@ export function useAdmin() {
}
}
const handleSavePlacesProvider = async (value: string) => {
const previous = placesProvider
setPlacesProvider(value)
setSavingPlacesProvider(true)
try {
await authApi.updateAppSettings({ places_provider: value })
toast.success(t('admin.placesProvider.saved'))
} catch (err: unknown) {
setPlacesProvider(previous)
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSavingPlacesProvider(false)
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
@@ -464,6 +491,8 @@ export function useAdmin() {
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
amapKey, setAmapKey,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
+17
View File
@@ -46,7 +46,15 @@ interface AuthState {
devMode: boolean
isPrerelease: boolean
appVersion: string
/** Google Maps API key. Please note that secondary map api provider is available,
* check `hasAmapKey`. */
hasMapsKey: boolean
/** The same question for the Amap key, and the admin's provider choice. Not
* boolean "hasSearchKey": a notice that says "add a Google key" on an install
* whose admin picked Amap is wrong twice, and the two facts together are what
* the place-search notices need to say something true. */
hasAmapKey: boolean
placesProvider: string
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -75,6 +83,8 @@ interface AuthState {
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -125,6 +135,8 @@ export const useAuthStore = create<AuthState>()(
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -343,6 +355,9 @@ export const useAuthStore = create<AuthState>()(
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
if ('amap_api_key' in keys) {
set({ hasAmapKey: !!keys.amap_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
@@ -386,6 +401,8 @@ export const useAuthStore = create<AuthState>()(
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+4
View File
@@ -369,6 +369,10 @@ export interface AppConfig {
oidc_display_name?: string
oidc_only_mode?: boolean
has_maps_key?: boolean
/** Amap (高德地图) key present for this caller — the alternative places provider. */
has_amap_key?: boolean
/** The admin's places provider choice: 'auto' | 'google' | 'amap' | 'openstreetmap'. */
places_provider?: string
allowed_file_types?: string
timezone?: string
/** When true, users without MFA cannot use the app until they enable it */
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { effectivePlacesProvider, osmKeySuggestion } from './placesProvider'
/**
* These mirror MapsService.resolvePlacesProvider() on the server, case for case.
* The point of the mirror is honest notices, so every case where the notices
* would lie — "add a Google key" on an install whose admin picked Amap — is
* pinned here.
*/
describe('effectivePlacesProvider', () => {
it('auto keeps Google when a Google key is configured, Amap key or not', () => {
// The incumbent wins: an existing install must not move providers because
// a new one became available.
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: true })).toBe('google')
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
it('auto falls to Amap only when there is no Google key', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
})
it('auto with neither key means OpenStreetMap', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: false })).toBe('openstreetmap')
})
it('an explicit amap choice wins even with a Google key configured', () => {
expect(effectivePlacesProvider({ placesProvider: 'amap', hasMapsKey: true, hasAmapKey: true })).toBe('amap')
})
it('an explicit google choice never silently uses Amap instead', () => {
// Misconfigured means "answer with OSM", matching the server — not "bill
// somebody else's provider".
expect(effectivePlacesProvider({ placesProvider: 'google', hasMapsKey: false, hasAmapKey: true })).toBe('openstreetmap')
})
it('openstreetmap ignores both keys', () => {
expect(effectivePlacesProvider({ placesProvider: 'openstreetmap', hasMapsKey: true, hasAmapKey: true })).toBe('openstreetmap')
})
it('a nonsense stored value degrades to auto rather than throwing', () => {
expect(effectivePlacesProvider({ placesProvider: 'not-a-provider', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
expect(effectivePlacesProvider({ placesProvider: undefined, hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
})
describe('osmKeySuggestion', () => {
it('a chosen provider names its own key', () => {
expect(osmKeySuggestion('google')).toBe('google')
expect(osmKeySuggestion('amap')).toBe('amap')
})
it('auto names both, because landing on OSM means neither key is set', () => {
expect(osmKeySuggestion('auto')).toBe('any')
})
it('an explicit OpenStreetMap choice gets no upsell — it is a decision, not a gap', () => {
expect(osmKeySuggestion('openstreetmap')).toBeNull()
// An unrecognised value behaves like auto: the reader degrades it to auto,
// so the notice must not claim the admin chose OSM when they chose nothing.
expect(osmKeySuggestion('garbage')).toBe('any')
expect(osmKeySuggestion(undefined)).toBe('any')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* Which provider place search will actually use, as far as the client can tell.
*
* This mirrors the server's MapsService.resolvePlacesProvider(): the instance's
* places_provider choice, the keys the server reported for this caller, and the
* same precedence. It exists so a notice can say the truth — "search is running
* on OpenStreetMap", not "you lack a Google key" on an install whose admin
* deliberately picked Amap.
*
* A mirror and not the source of truth on purpose: the server resolves keys per
* request against the database, and only it knows for sure. If the two ever
* disagree, the client shows a slightly wrong hint under a notice and nothing
* else — search itself keeps asking the server.
*/
export type PlacesProviderChoice = 'auto' | 'google' | 'amap' | 'openstreetmap'
export type EffectiveSearchProvider = 'google' | 'amap' | 'openstreetmap'
export interface PlacesProviderState {
/** The admin's places_provider choice; anything unrecognised means 'auto'. */
placesProvider?: string | null
/** Whether a Google key resolves for this caller (operator env, instance, own row). */
hasMapsKey: boolean
hasAmapKey: boolean
}
export function effectivePlacesProvider(state: PlacesProviderState): EffectiveSearchProvider {
const choice: PlacesProviderChoice =
state.placesProvider === 'google' || state.placesProvider === 'amap' || state.placesProvider === 'openstreetmap'
? state.placesProvider
: 'auto'
if (choice === 'openstreetmap') return 'openstreetmap'
if (choice === 'google') return state.hasMapsKey ? 'google' : 'openstreetmap'
if (choice === 'amap') return state.hasAmapKey ? 'amap' : 'openstreetmap'
// auto prefers Google, the incumbent: an existing install must not move to a
// different provider, with a different bill, just because Amap appeared.
if (state.hasMapsKey) return 'google'
if (state.hasAmapKey) return 'amap'
return 'openstreetmap'
}
/**
* Which key the "still on OpenStreetMap" upsell should suggest.
*
* A chosen provider names the key that belongs to it — telling an admin who
* picked Amap to add a Google key is advice they already declined. `auto` with
* no key at all is the only case where both are worth naming.
*
* Null when the admin explicitly chose OpenStreetMap: that is a decision, not a
* gap to fill, and the notices stay quiet rather than nag.
*/
export type KeySuggestion = 'google' | 'amap' | 'any'
export function osmKeySuggestion(placesProvider?: string | null): KeySuggestion | null {
switch (placesProvider) {
case 'google':
return 'google'
case 'amap':
return 'amap'
case 'openstreetmap':
return null
// auto resolving to OSM means neither key is configured (a Google key would
// have won), so both are honest suggestions.
default:
return 'any'
}
}
+19
View File
@@ -97,6 +97,25 @@ export function isVectorStyle(url: string | null | undefined): boolean {
return /^https?:\/\//i.test(u) || u.startsWith('mapbox://')
}
/**
* Tiles drawn in GCJ-02 rather than WGS-84.
*
* Amap serves its raster tiles from `*.is.autonavi.com`, and they are shifted
* from the WGS-84 coordinates everything else in TREK uses — by 100 to 800 m
* depending on where in China you are. A map on one of these has to project
* through components/Map/gcj02Crs.ts or every marker lands on the wrong street.
*
* Matched on the host, not on the preset constants, so a self-hosted proxy in
* front of the same tiles still gets the right CRS as long as the hostname
* survives — and so a template a user typed in by hand does too.
*/
const GCJ02_TILE_HOST = /(^|\.)is\.autonavi\.com$/i
export function isGcj02Basemap(url: string | null | undefined): boolean {
if (!url) return false
return GCJ02_TILE_HOST.test(templateHost(url))
}
/**
* What a map should draw, given the user's template and the app's default.
*
+5
View File
@@ -143,6 +143,10 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
setWeatherKey: vi.fn(),
unsplashKey: '',
setUnsplashKey: vi.fn(),
amapKey: '',
setAmapKey: vi.fn(),
placesProvider: 'auto',
savingPlacesProvider: false,
showKeys: {},
setShowKeys: vi.fn(),
savingKeys: false,
@@ -169,6 +173,7 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
handleSaveApiKeys: vi.fn(),
handleValidateKeys: vi.fn(),
handleValidateKey: vi.fn(),
handleSavePlacesProvider: vi.fn(),
handleCreateUser: vi.fn(),
handleCreateInvite: vi.fn(),
handleDeleteInvite: vi.fn(),
@@ -250,12 +250,18 @@ describe('MAdminSettingsSection', () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.type(screen.getAllByPlaceholderText('Enter key...')[0], 'm');
// By accessible name rather than by position: the card gained an Amap field
// between Maps and Unsplash, and an index quietly starts asserting about a
// different input when that happens.
await user.type(screen.getByLabelText('Google Maps API Key'), 'm');
expect(admin.setMapsKey).toHaveBeenCalledWith('m');
await user.type(screen.getAllByPlaceholderText('Enter key...')[1], 'u');
await user.type(screen.getByLabelText('Unsplash API Key'), 'u');
expect(admin.setUnsplashKey).toHaveBeenCalledWith('u');
await user.type(screen.getByLabelText(/Amap/), 'a');
expect(admin.setAmapKey).toHaveBeenCalledWith('a');
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
});