diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 20f6c0b85..8992a2dbc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -5081,7 +5081,8 @@ "src/layers/alpr/records.js", "src/layers/alpr/tileRecords.js", "src/sources/infrastructureData.js", - "src/sources/eventPacks.js" + "src/sources/eventPacks.js", + "src/keylessGeocoder.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, diff --git a/src/tools/places.js b/src/tools/places.js index 4a715f389..c946f8fe7 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -24,6 +24,7 @@ export function createGeocodePlaceService({ cacheMs = 10 * 60_000, timeoutMs = 15_000, now = () => Date.now(), + keylessGeocoder = null, } = {}) { // Recent answers by name, so tools that combine others resolve a place to // the same point once instead of once per section. A pending lookup is @@ -33,8 +34,17 @@ export function createGeocodePlaceService({ async function lookup(name, signal) { const query = new URLSearchParams({ q: name }); const response = await fetchImpl(`/api/geocode?${query}`, { signal }); + const payload = await response.json().catch(() => null); + // No server geocoder (no Google key, no Nominatim): ask the keyless one + // the search box uses, rather than reporting every name as unmatched. + if ( + keylessGeocoder && + (payload?.configured === false || payload?.code === 'NOMINATIM_DISABLED') + ) { + const { place } = await keylessGeocoder.geocode(name, { signal }); + return place ? placeFromKeylessResult(place) : null; + } if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`); - const payload = await response.json(); return placeFromGeocodeResult(payload?.results?.[0]); } function start(key, name) { @@ -100,6 +110,14 @@ export function createGeocodePlaceService({ } /** Convert one geocode result to a place with bounds, or null. */ +/** A keyless (Photon) answer in the same shape as a geocode result's. */ +function placeFromKeylessResult({ lat, lng, label, name, viewport }) { + return placeFromGeocodeResult({ + formatted_address: label || name || null, + geometry: { location: { lat, lng }, ...(viewport ? { viewport } : {}) }, + }); +} + export function placeFromGeocodeResult(result) { const location = result?.geometry?.location; if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng)) diff --git a/src/tools/places.test.mjs b/src/tools/places.test.mjs index f4391269a..3ed957606 100644 --- a/src/tools/places.test.mjs +++ b/src/tools/places.test.mjs @@ -93,3 +93,49 @@ test('a shared lookup outlives one cancelled caller, and times out on its own', await places.resolve('Slow').catch(() => {}); assert.equal(signals.length, 3, 'a timed-out lookup is not cached'); }); + +test('without a server geocoder, names resolve through the keyless geocoder', async () => { + const asked = []; + const places = createGeocodePlaceService({ + fetchImpl: async () => + Response.json( + { + error: 'Place search is not configured', + code: 'NOMINATIM_DISABLED', + retryable: false, + }, + { status: 503 }, + ), + keylessGeocoder: { + geocode: async (name) => { + asked.push(name); + return name === 'Paris' + ? { + place: { + lat: 48.8566, + lng: 2.3522, + name: 'Paris', + label: 'Paris, France', + viewport: { + southwest: { lat: 48.81, lng: 2.22 }, + northeast: { lat: 48.9, lng: 2.47 }, + }, + }, + answered: true, + } + : { place: null, answered: true }; + }, + }, + }); + const paris = await places.resolve('Paris'); + assert.equal(paris.name, 'Paris, France'); + assert.deepEqual(paris.point, { lat: 48.8566, lon: 2.3522 }); + assert.deepEqual(paris.bounds, { + west: 2.22, + south: 48.81, + east: 2.47, + north: 48.9, + }); + assert.equal(await places.resolve('Nowhere-at-all'), null); + assert.deepEqual(asked, ['Paris', 'Nowhere-at-all']); +}); diff --git a/src/tools/services.js b/src/tools/services.js index b0edf9f7d..1df29f08d 100644 --- a/src/tools/services.js +++ b/src/tools/services.js @@ -35,6 +35,7 @@ import { createVesselSource, createFlightSource, } from '../sources/live/standalone.js'; +import { createPhotonGeocoder } from '../keylessGeocoder.js'; import { createGeocodePlaceService, createPlaceSearchService, @@ -86,7 +87,10 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) { fetchImpl, tileFetchImpl: fetchImpl, }), - places: createGeocodePlaceService({ fetchImpl }), + places: createGeocodePlaceService({ + fetchImpl, + keylessGeocoder: createPhotonGeocoder({ fetchImpl }), + }), }; }