mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(map): amap as map api provider and display template
This commit is contained in:
+4
-2
@@ -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)
|
||||
|
||||
@@ -53,8 +53,9 @@ import { NightPauseDrag } from './NightPauseDrag'
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { resolveBasemap } from '../../utils/tileUrl'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapLayerSwitcher } from './MapLayerSwitcher'
|
||||
@@ -687,6 +688,13 @@ export const MapView = memo(function MapView({
|
||||
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
|
||||
const poiClickRef = useRef(onPoiClick)
|
||||
poiClickRef.current = onPoiClick
|
||||
/**
|
||||
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
|
||||
* decided before the map is constructed, because Leaflet cannot change a map's
|
||||
* CRS afterwards — hence the whole map, not just the tile layer.
|
||||
*/
|
||||
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
@@ -1002,6 +1010,10 @@ export const MapView = memo(function MapView({
|
||||
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
|
||||
// outright on an infinite one, which took the whole planner down.
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
// Omitted entirely for a WGS-84 basemap, which is every install that has
|
||||
// not chosen Amap: passing L.CRS.EPSG3857 explicitly would be the same
|
||||
// value, but leaving the prop off keeps those maps byte-identical.
|
||||
{...(gcjCrs ? { crs: gcjCrs } : {})}
|
||||
className="w-full h-full bg-[#e5e7eb]"
|
||||
>
|
||||
{/* The basemap is a vector style by default and a raster template when the
|
||||
@@ -1012,8 +1024,12 @@ export const MapView = memo(function MapView({
|
||||
{isSatellite ? (
|
||||
<TileLayer
|
||||
key="satellite"
|
||||
url={SATELLITE_TILE_URL}
|
||||
attribution={SATELLITE_TILE_ATTRIBUTION}
|
||||
// Esri's imagery is WGS-84 and Amap's is GCJ-02, and the map's CRS is
|
||||
// already fixed by the basemap the user chose. Handing the other datum
|
||||
// to that projection moves the whole photo a few hundred metres, so the
|
||||
// satellite toggle follows the datum rather than always meaning Esri.
|
||||
url={isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL}
|
||||
attribution={attributionForTile(isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL)}
|
||||
maxZoom={SATELLITE_TILE_MAXZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import L from 'leaflet'
|
||||
import { crsForBasemap, getCrsGcj02 } from './gcj02Crs'
|
||||
|
||||
const CRS_GCJ02 = getCrsGcj02()
|
||||
|
||||
/**
|
||||
* What these guard is the one failure that cannot be seen in a screenshot review
|
||||
* of a map full of Chinese labels: a marker drawn a few hundred metres from where
|
||||
* it belongs still looks like a perfectly plausible map.
|
||||
*/
|
||||
describe('CRS_GCJ02', () => {
|
||||
it('projects a WGS-84 coordinate onto the GCJ-02 tile grid', () => {
|
||||
const wgs = L.latLng(39.90869, 116.39124)
|
||||
const shifted = CRS_GCJ02.latLngToPoint(wgs, 15)
|
||||
const unshifted = L.CRS.EPSG3857.latLngToPoint(wgs, 15)
|
||||
|
||||
// At zoom 15 the GCJ-02 offset is worth several hundred pixels, so a map that
|
||||
// silently kept EPSG3857 would fail here rather than being off by a rounding.
|
||||
const dx = Math.abs(shifted.x - unshifted.x)
|
||||
const dy = Math.abs(shifted.y - unshifted.y)
|
||||
expect(Math.hypot(dx, dy)).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('round-trips a coordinate through the pixel plane', () => {
|
||||
const wgs = L.latLng(31.2304, 121.4737)
|
||||
const back = CRS_GCJ02.pointToLatLng(CRS_GCJ02.latLngToPoint(wgs, 18), 18)
|
||||
// Sub-metre at zoom 18, i.e. the click handler and the marker agree.
|
||||
expect(back.lat).toBeCloseTo(wgs.lat, 6)
|
||||
expect(back.lng).toBeCloseTo(wgs.lng, 6)
|
||||
})
|
||||
|
||||
it('leaves a coordinate outside China exactly where EPSG3857 puts it', () => {
|
||||
// Otherwise choosing an Amap basemap would move every foreign trip in the
|
||||
// account, which is a far bigger failure than the one this file prevents.
|
||||
const paris = L.latLng(48.8566, 2.3522)
|
||||
const shifted = CRS_GCJ02.latLngToPoint(paris, 15)
|
||||
const plain = L.CRS.EPSG3857.latLngToPoint(paris, 15)
|
||||
expect(shifted.x).toBeCloseTo(plain.x, 6)
|
||||
expect(shifted.y).toBeCloseTo(plain.y, 6)
|
||||
})
|
||||
|
||||
it('keeps the Web Mercator tile grid, because Amap uses the standard one', () => {
|
||||
// Same scale and same transformation as EPSG3857: only the datum differs. If
|
||||
// this drifts, tiles are requested at the wrong zoom or the wrong origin and
|
||||
// the map is visibly broken rather than subtly offset.
|
||||
expect(CRS_GCJ02.scale(12)).toBe(L.CRS.EPSG3857.scale(12))
|
||||
expect(CRS_GCJ02.code).toBe('TREK:GCJ02')
|
||||
})
|
||||
|
||||
it('measures distance in WGS-84, since that is what it is handed', () => {
|
||||
const a = L.latLng(39.90869, 116.39124)
|
||||
const b = L.latLng(39.91869, 116.39124)
|
||||
expect(CRS_GCJ02.distance(a, b)).toBeCloseTo(L.CRS.EPSG3857.distance(a, b), 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('crsForBasemap', () => {
|
||||
it('returns undefined for a WGS-84 basemap so the map keeps Leaflet\u2019s default', () => {
|
||||
// Not L.CRS.EPSG3857: passing the prop at all pins the CRS, and the point is
|
||||
// that a map on an ordinary basemap is constructed exactly as it was before.
|
||||
expect(crsForBasemap(false)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the shifted CRS for a GCJ-02 basemap', () => {
|
||||
expect(crsForBasemap(true)).toBe(getCrsGcj02())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import L from 'leaflet'
|
||||
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* A Leaflet CRS that draws WGS-84 coordinates on GCJ-02 tiles.
|
||||
*
|
||||
* Amap's raster tiles are rendered in GCJ-02 — the datum Chinese law requires
|
||||
* published maps to use — while every coordinate in TREK is WGS-84: the database
|
||||
* columns, the search results, the routes from OSRM, an imported GPX track. Put
|
||||
* one on the other untouched and every marker sits a few hundred metres from the
|
||||
* building it names, which around a city block is the wrong street.
|
||||
*
|
||||
* The fix could live in three places, and two of them are worse:
|
||||
*
|
||||
* - Converting each marker at the call site means every component that draws
|
||||
* anything on a map has to know which basemap is loaded — MapView,
|
||||
* JourneyMap, CollectionMap, the cluster group, the route polylines, the
|
||||
* bounds fitting, the click handler that turns a pixel back into a place.
|
||||
* Miss one and it is off by 300 m with no error anywhere.
|
||||
* - A constant offset is not a fix at all: the shift is a function of position,
|
||||
* ranging from roughly 100 m to 800 m across the country.
|
||||
*
|
||||
* So it goes into the projection, which is the one place every coordinate
|
||||
* already passes through. `project` shifts WGS-84 → GCJ-02 before the standard
|
||||
* Web Mercator maths, `unproject` shifts back, and everything above it —
|
||||
* markers, polylines, `getBounds`, `containerPointToLatLng` — keeps speaking
|
||||
* WGS-84 without knowing this exists.
|
||||
*
|
||||
* Everything else is inherited from EPSG3857 on purpose: Amap's tile grid IS
|
||||
* standard Web Mercator (same zoom levels, same 256 px tiles, same origin), so
|
||||
* the scale and the transformation must not change. Only the datum does.
|
||||
* `distance()` also stays as it is and stays correct, because it is handed
|
||||
* WGS-84 latitudes.
|
||||
*/
|
||||
function buildCrs(): L.CRS {
|
||||
const mercator = L.Projection.SphericalMercator
|
||||
const projection: L.Projection = {
|
||||
project(latlng: L.LatLng): L.Point {
|
||||
const gcj = wgs84ToGcj02(latlng.lat, latlng.lng)
|
||||
return mercator.project(L.latLng(gcj.lat, gcj.lng))
|
||||
},
|
||||
unproject(point: L.Point): L.LatLng {
|
||||
const gcj = mercator.unproject(point)
|
||||
const wgs = gcj02ToWgs84(gcj.lat, gcj.lng)
|
||||
return L.latLng(wgs.lat, wgs.lng)
|
||||
},
|
||||
bounds: mercator.bounds,
|
||||
}
|
||||
return L.Util.extend({}, L.CRS.EPSG3857, { code: 'TREK:GCJ02', projection }) as L.CRS
|
||||
}
|
||||
|
||||
/**
|
||||
* Built on first use, not at import.
|
||||
*
|
||||
* Reading `L.Projection.SphericalMercator` while the module graph loads means
|
||||
* every importer of MapView has to have a fully-formed Leaflet — which the map
|
||||
* component tests deliberately do not, they mock it down to the handful of
|
||||
* exports they assert on. A basemap nobody selected should not be able to break
|
||||
* an unrelated test file, or a build, by existing.
|
||||
*/
|
||||
let cached: L.CRS | null = null
|
||||
|
||||
export function getCrsGcj02(): L.CRS {
|
||||
if (!cached) cached = buildCrs()
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* The CRS a map should use for a given basemap.
|
||||
*
|
||||
* Returning `undefined` rather than `L.CRS.EPSG3857` matters: passing a `crs`
|
||||
* option to a MapContainer is what pins it, and Leaflet cannot change a map's
|
||||
* CRS after construction. Handing back undefined lets the component omit the
|
||||
* prop entirely for the 99% of installs on a WGS-84 basemap, which keeps their
|
||||
* map byte-identical to what it was.
|
||||
*/
|
||||
export function crsForBasemap(isGcj02: boolean): L.CRS | undefined {
|
||||
return isGcj02 ? getCrsGcj02() : undefined
|
||||
}
|
||||
@@ -474,12 +474,49 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
renderColumn()
|
||||
describe('PlaceDetailsColumn — no key behind place search', () => {
|
||||
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
|
||||
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
|
||||
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
|
||||
empty()
|
||||
renderColumn({ keySuggestion: 'google' })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-027b: suggests the Amap key when that is the one the admin chose', async () => {
|
||||
// An install on Amap is not "missing a Google key" — the hint has to name
|
||||
// the key that would actually move search off OpenStreetMap.
|
||||
empty()
|
||||
renderColumn({ keySuggestion: 'amap' })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHintAmap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-027c: names both keys when the choice is automatic and neither is set', async () => {
|
||||
empty()
|
||||
renderColumn({ keySuggestion: 'any' })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHintAny')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-027d: stays quiet when OpenStreetMap was chosen on purpose', async () => {
|
||||
// The null suggestion: an explicit admin decision, not a gap to fill.
|
||||
empty()
|
||||
renderColumn({ keySuggestion: null })
|
||||
|
||||
await screen.findByText('places.details.nothing')
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
|
||||
empty()
|
||||
renderColumn({ keySuggestion: null })
|
||||
|
||||
await screen.findByText('places.details.nothing')
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -490,7 +527,7 @@ describe('PlaceDetailsColumn — nothing found', () => {
|
||||
facts: [],
|
||||
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
|
||||
})
|
||||
renderColumn()
|
||||
renderColumn({ keySuggestion: 'google' })
|
||||
|
||||
await screen.findByText('Ein Museum.')
|
||||
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Loader2,
|
||||
ScrollText,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
@@ -26,8 +27,19 @@ import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './place
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { KeySuggestion } from '../../utils/placesProvider'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/**
|
||||
* The "add a key" hint line, per which key would move search off OpenStreetMap.
|
||||
* The Google variant reuses the long-standing key every locale already carries.
|
||||
*/
|
||||
const NO_KEY_HINT_KEY: Record<Exclude<KeySuggestion, never>, string> = {
|
||||
google: 'places.details.noKeyHint',
|
||||
amap: 'places.details.noKeyHintAmap',
|
||||
any: 'places.details.noKeyHintAny',
|
||||
}
|
||||
|
||||
/** The place the column is describing. Null while nothing is selected. */
|
||||
export interface PlaceDetailsSelection {
|
||||
placeId?: string
|
||||
@@ -51,7 +63,12 @@ interface PlaceDetailsColumnProps {
|
||||
timeFormat?: string
|
||||
/** For grouping the rating count's digits. */
|
||||
locale?: string
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
/**
|
||||
* Which API key the "more detail available" hint should suggest, or null when
|
||||
* there is nothing to suggest — a configured provider answers, or the admin
|
||||
* chose OpenStreetMap on purpose and the hint would be a nag.
|
||||
*/
|
||||
keySuggestion?: KeySuggestion | null
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -117,6 +134,7 @@ export default function PlaceDetailsColumn({
|
||||
language,
|
||||
timeFormat = '24h',
|
||||
locale = 'en-US',
|
||||
keySuggestion = null,
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
@@ -236,14 +254,20 @@ export default function PlaceDetailsColumn({
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
{/* Only when both are true. With a key behind the search there is
|
||||
nothing to suggest, and on a place the free sources DID describe
|
||||
the suggestion would be an advert. */}
|
||||
{keySuggestion && <NoKeyHint t={t} suggestion={keySuggestion} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -472,6 +496,19 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
* It also names who to ask: the key is an instance-wide setting, so on most
|
||||
* installs the person reading this cannot act on it themselves.
|
||||
*/
|
||||
function NoKeyHint({ t, suggestion }: { t: TranslationFn; suggestion: KeySuggestion }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t(NO_KEY_HINT_KEY[suggestion])}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The star rating, as stars.
|
||||
|
||||
@@ -462,6 +462,35 @@ describe('PlaceFormModal', () => {
|
||||
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The notice says what is actually true per provider choice, not "add a
|
||||
// Google key" on an install whose admin picked Amap. See utils/placesProvider.
|
||||
describe('FE-PLANNER-PLACEFORM-022b: the OSM notice follows the provider choice', () => {
|
||||
it('suggests the Amap key when the admin chose Amap and none is set', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'amap' });
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.getByText(/高德|Amap/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when the chosen provider has its key', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: true, placesProvider: 'amap' });
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when the admin explicitly chose OpenStreetMap', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'openstreetmap' });
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
// A deliberate choice is not a gap to fill — no upsell line at all.
|
||||
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet under auto when a Google key is configured', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, hasAmapKey: true, placesProvider: 'auto' });
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Category ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => {
|
||||
|
||||
@@ -7,6 +7,18 @@ import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { effectivePlacesProvider, osmKeySuggestion, type KeySuggestion } from '../../utils/placesProvider'
|
||||
|
||||
/**
|
||||
* The "search is on OpenStreetMap" line, per which key would actually move it
|
||||
* off. The Google variant reuses the long-standing key, which every locale
|
||||
* already carries; the other two are new with Amap.
|
||||
*/
|
||||
const OSM_ACTIVE_KEY: Record<KeySuggestion, string> = {
|
||||
google: 'places.osmActive',
|
||||
amap: 'places.osmActiveAmap',
|
||||
any: 'places.osmActiveAny',
|
||||
}
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -158,7 +170,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
|
||||
const { hasMapsKey, hasAmapKey, placesProvider, placesEnrichEnabled } = useAuthStore()
|
||||
// What the search notices say depends on what will actually answer: an
|
||||
// install whose admin picked Amap is not "missing a Google key".
|
||||
const searchProvider = effectivePlacesProvider({ placesProvider, hasMapsKey, hasAmapKey })
|
||||
const keySuggestion = osmKeySuggestion(placesProvider)
|
||||
const can = useCanDo()
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -647,7 +663,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
language,
|
||||
locale,
|
||||
timeFormat,
|
||||
hasMapsKey,
|
||||
searchProvider,
|
||||
keySuggestion,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -719,7 +736,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
hasMapsKey,
|
||||
searchProvider,
|
||||
keySuggestion,
|
||||
placesEnrichEnabled,
|
||||
can,
|
||||
tripObj,
|
||||
@@ -793,12 +811,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
language={language}
|
||||
timeFormat={S.timeFormat}
|
||||
locale={S.locale}
|
||||
// Null when search genuinely has no key behind it — the column then
|
||||
// explains which one would help, or stays quiet when OpenStreetMap
|
||||
// was chosen on purpose.
|
||||
keySuggestion={searchProvider === 'openstreetmap' ? keySuggestion : null}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
|
||||
{/* Place Search */}
|
||||
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
|
||||
{searchProvider === 'openstreetmap' && keySuggestion && (
|
||||
<p className="mb-2 text-xs text-content-faint">
|
||||
{t(OSM_ACTIVE_KEY[keySuggestion])}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { gcj02ToWgs84 } from '@trek/shared'
|
||||
import { getAmapUrlForPlace } from './placeAmap'
|
||||
|
||||
describe('getAmapUrlForPlace', () => {
|
||||
it('writes the position as lng,lat in GCJ-02', () => {
|
||||
const url = getAmapUrlForPlace({ name: '天安门', lat: 39.90869, lng: 116.39124 })!
|
||||
const position = new URL(url).searchParams.get('position')!
|
||||
const [lng, lat] = position.split(',').map(Number)
|
||||
|
||||
// Longitude first — the opposite of every other pair in TREK, and what
|
||||
// Amap's URI API specifies.
|
||||
expect(lng).toBeGreaterThan(100)
|
||||
expect(lat).toBeLessThan(90)
|
||||
|
||||
// And in Amap's datum: converting back must land on the stored WGS-84 value.
|
||||
// Handing the WGS-84 pair over unconverted would drop the pin a few hundred
|
||||
// metres away, which in a city is the wrong block.
|
||||
const back = gcj02ToWgs84(lat, lng)
|
||||
expect(back.lat).toBeCloseTo(39.90869, 4)
|
||||
expect(back.lng).toBeCloseTo(116.39124, 4)
|
||||
expect(lat).not.toBeCloseTo(39.90869, 4)
|
||||
})
|
||||
|
||||
it('carries the place name as the marker label', () => {
|
||||
const url = getAmapUrlForPlace({ name: '故宫博物院', lat: 39.9163, lng: 116.3972 })!
|
||||
expect(new URL(url).searchParams.get('name')).toBe('故宫博物院')
|
||||
})
|
||||
|
||||
it('labels an unnamed place with its coordinates rather than returning nothing', () => {
|
||||
// Amap refuses a marker request with no name, so the alternative to this is
|
||||
// no link at all for a place added by right-clicking the map.
|
||||
const url = getAmapUrlForPlace({ name: '', lat: 39.9163, lng: 116.3972 })!
|
||||
const params = new URL(url).searchParams
|
||||
expect(params.get('name')).toBe(params.get('position'))
|
||||
})
|
||||
|
||||
it('needs coordinates: a name alone has nothing to place', () => {
|
||||
expect(getAmapUrlForPlace({ name: '某地', lat: null, lng: null } as never)).toBeNull()
|
||||
expect(getAmapUrlForPlace(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a coordinate outside China unshifted', () => {
|
||||
// GCJ-02 does not apply there, and shifting it would move the pin off the
|
||||
// place for a trip that has nothing to do with China.
|
||||
const url = getAmapUrlForPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })!
|
||||
expect(new URL(url).searchParams.get('position')).toBe('2.294500,48.858400')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { wgs84ToGcj02 } from '@trek/shared'
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
|
||||
|
||||
/**
|
||||
* Open a place in Amap (高德地图), which is what a phone in China has installed.
|
||||
*
|
||||
* `uri.amap.com` is Amap's own cross-platform handover endpoint: it opens the app
|
||||
* when it is present and falls back to Amap's web map when it is not, which is
|
||||
* the same property that made the https form the right choice for CoMaps. The
|
||||
* `src` and `callnative` parameters are what Amap documents for this: `callnative=1`
|
||||
* asks it to hand over to the app rather than staying on the web page.
|
||||
*
|
||||
* The coordinate is converted to GCJ-02 first. Amap reads `position` in its own
|
||||
* datum, and TREK stores WGS-84, so handing the stored value over unconverted
|
||||
* drops the pin a few hundred metres away — far enough, in a city, to be the
|
||||
* wrong block. This is the mirror image of what the server does when it reads an
|
||||
* Amap result (see server/src/nest/maps/providers/amap.provider.ts).
|
||||
*
|
||||
* Longitude first, unlike every other coordinate pair in this codebase: that is
|
||||
* the order Amap's URI API specifies.
|
||||
*/
|
||||
export function getAmapUrlForPlace(place: PlaceLike | null | undefined): string | null {
|
||||
if (!place || place.lat == null || place.lng == null) return null
|
||||
const gcj = wgs84ToGcj02(place.lat, place.lng)
|
||||
const position = `${gcj.lng.toFixed(6)},${gcj.lat.toFixed(6)}`
|
||||
const name = place.name?.trim()
|
||||
// Amap labels the marker from `name` and refuses the request without one, so an
|
||||
// unnamed place gets its coordinates as the label rather than no link at all.
|
||||
const label = encodeURIComponent(name || position)
|
||||
return `https://uri.amap.com/marker?position=${position}&name=${label}&src=trek&coordinate=gaode&callnative=1`
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { isOutsideChina } from '@trek/shared'
|
||||
import type { AssignmentPlace, Place } from '../../types'
|
||||
import { getAmapUrlForPlace } from './placeAmap'
|
||||
import { getCoMapsUrlForPlace } from './placeCoMaps'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
|
||||
|
||||
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||
|
||||
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
|
||||
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps' | 'amap'
|
||||
|
||||
export interface NavigationTarget {
|
||||
id: NavigationAppId
|
||||
@@ -92,6 +94,17 @@ export function getNavigationTargets(
|
||||
const coMapsUrl = getCoMapsUrlForPlace(place)
|
||||
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
|
||||
|
||||
// Offered by WHERE the place is, not by who is looking at it. Amap only has a
|
||||
// map of China, so it is the right choice for a stop in Shanghai whoever is
|
||||
// planning the trip, and dead weight for one in Lisbon whoever is planning it —
|
||||
// and this row is deliberately short. showsAppleMaps() above narrows by
|
||||
// platform for the same reason; this one narrows by geography because that is
|
||||
// what decides whether the link leads anywhere.
|
||||
if (place.lat != null && place.lng != null && !isOutsideChina(place.lat, place.lng)) {
|
||||
const amapUrl = getAmapUrlForPlace(place)
|
||||
if (amapUrl) targets.push({ id: 'amap', label: '高德地图', url: amapUrl })
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
|
||||
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
||||
// Places-API enrichment (#886) needs a Google Maps key. Not the places
|
||||
// *provider* choice: enrichment's photos and summary come from Google (and,
|
||||
// keyless, from Wikimedia), which is independent of which provider answers
|
||||
// search — an Amap install with a Google key still enriches through Google.
|
||||
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
||||
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'
|
||||
import type { Place } from '../../types'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
@@ -40,6 +41,11 @@ const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
// Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for
|
||||
// these so markers still land on the right street (see gcj02Crs.ts). The only
|
||||
// basemap here that is genuinely good inside mainland China.
|
||||
{ name: '高德地图 (Amap)', url: AMAP_ROAD },
|
||||
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
|
||||
]
|
||||
|
||||
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||
|
||||
@@ -51,6 +51,33 @@ export const OFM_ATTRIBUTION =
|
||||
|
||||
const OSM_ATTRIBUTION = '© <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 = '© <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
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user