From 8b36c28b062b7d6f7c4f99fb6e5ada5d55bcb9ac Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 21:23:13 -0700 Subject: [PATCH] refactor: compose configurable geocoding and route services --- CHANGELOG.md | 7 + docs/APPLICATION.md | 49 ++++++ docs/CURRENT-STATE.md | 8 + scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 5 +- server/providers/overpass.js | 4 +- server/providers/places/google.js | 14 +- server/providers/places/routes.js | 17 +- server/providers/regional.js | 1 + server/providers/regional/briefing.js | 40 ++--- server/providers/regional/place.js | 55 +++--- src/annotations/annotationEngine.js | 28 +-- src/annotations/annotationResolver.js | 27 ++- src/hud.js | 5 +- src/keylessGeocoder.js | 28 ++- src/keylessGeocoder.test.mjs | 4 +- src/search/defaults.js | 54 ++++++ src/search/geospatial.js | 88 ++++++++++ src/search/geospatial.test.mjs | 239 ++++++++++++++++++++++++++ src/search/http.js | 132 ++++++++++++++ src/search/index.js | 6 + src/standalone/application.js | 2 + src/standalone/placeSearch.js | 33 +--- src/ui/applicationShell.js | 2 +- src/voice/gevActions.js | 121 +++++-------- 25 files changed, 764 insertions(+), 211 deletions(-) create mode 100644 src/search/defaults.js create mode 100644 src/search/geospatial.js create mode 100644 src/search/geospatial.test.mjs create mode 100644 src/search/http.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 16986c795..904ee7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Configurable geospatial services + +- Compose geocoding, place context and routes through independent providers. +- Allow compatible endpoint configuration without changing voice tools or annotation behavior. +- Isolate configured source caches and reject results after cancellation. + + ## ALPR camera locations - Label the loaded camera count as nearby, show a purple-dot legend, and add diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index f65695631..e3f1c3660 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -100,3 +100,52 @@ Later component extractions can replace a constructor's internals without changing the startup contract or moving standalone imports into the package. Run the unit suite, package boundary gate, build, tracking and first-run browser checks when changing this wiring. + +## Geospatial services + +`src/search` owns forward/reverse geocoding, place search and route provider +interfaces. `createStandaloneApplication({ geospatial })` accepts `endpoints` +and `providers`; the defaults preserve Google/Photon search, Google place context +and the local OSRM route proxy. Location, annotations, HUD labels and voice use +the composed service. Provider-specific credentials belong in the selected +transport; server secrets never belong in browser configuration. + +For a compatible protocol, configure `geocode`, `photon`, `reverse`, `textSearch`, +`nearby` or `route` endpoints. These are developer-selected configuration, not +URLs accepted from page queries or model arguments. A different protocol supplies +an adapter function instead. The exported `createDefaultPlaceSearch` constructor +supports the same options for direct composition: + +```js +const services = createDefaultPlaceSearch({ + resolveApiKey, + signal, + endpoints: { photon: 'https://search.example/api/', route: '/api/routes' }, + providers: { + route: routeProvider, + routeProfiles: ['foot', 'car'], + }, +}); +``` + +Forward `providers.geocode` is the existing ordered geocoder array. Other +operations are independent functions: `reverseGeocode(latitude, longitude, +{ signal })`, `textSearch(query, point, { signal })`, `nearby(point, { signal })` +and `route(coordinates, profile, { signal })`. Points use `latitude`, `longitude` +and `radiusM`; route coordinates use `[longitude, latitude]` in WGS84. Routes +return `{ geometry, distanceM, durationS }` with metres and seconds. Unavailable +routes return null, retaining the explicitly labelled direct-line fallback. +Place search returns normalized place arrays; reverse lookup returns address, +locality, region, country and label fields used by scene context. Unsupported +operations are reported in `capabilities`; adapters can provide attribution and +supported route profiles. A provider's lifetime and caller cancellation both +invalidate late response bodies. + +Node middleware is configured separately. `googlePlacesContextProxy` accepts +`endpoints: { nearby, textSearch }`, `resolveApiKey` and `fetchImpl`. +`overpassProxy({ routing: { endpoints: { foot, car, bike }, fetchImpl } })` +selects compatible OSRM base URLs while preserving coordinate/span limits. +`regionalBriefProxy({ placeProvider })` accepts the regional lookup function; +`createRegionalPlaceProvider({ endpoint, requestJson })` supplies the existing +serialized Nominatim implementation. Configuration is per instance, including +routing and regional caches. Request parameters cannot override these endpoints. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e4455ab3..6d1aac7d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Geospatial lookups are composed through `src/search`: forward/reverse geocoding, +text/nearby search and routing use configurable providers/endpoints. Annotation, +HUD and voice consumers share the configured service. Existing Google/Photon +selection, route/direct-line honesty and tool schemas are preserved. Node +Google, OSRM and Nominatim adapters accept trusted construction-time configuration; +request parameters cannot choose arbitrary upstream URLs. See APPLICATION.md. + + The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations, not camera footage or plate records. City-scale queries use the existing Overpass provider, with capped results, retry, cached-data and incomplete-coverage notices. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f2acaea16..aa81159f0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -532,5 +532,9 @@ "src/mapStackChips.test.mjs", "src/mapStackUnavailableReason.test.mjs", "src/mapStartup.test.mjs", - "src/maps/sourceFactories.test.mjs" + "src/maps/sourceFactories.test.mjs", + "src/search/geospatial.js", + "src/search/http.js", + "src/search/defaults.js", + "src/search/geospatial.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f704a7a38..3cca567d5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -263,7 +263,10 @@ "src/search/index.js", "src/search/placeSearch.js", "src/search/google.js", - "src/keylessGeocoder.js" + "src/keylessGeocoder.js", + "src/search/geospatial.js", + "src/search/http.js", + "src/search/defaults.js" ], "external": [] }, diff --git a/server/providers/overpass.js b/server/providers/overpass.js index 824baff5a..9ab9c8301 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -59,7 +59,7 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') { * * @returns {import('vite').Plugin} */ -function overpassProxy() { +function overpassProxy({ routing = {} } = {}) { const installMiddleware = (server) => { server.middlewares.use('/api/overpass', async (req, res) => { // Hoisted out of the try so the catch's serve-stale lookup can see it @@ -200,7 +200,7 @@ function overpassProxy() { } }); - installRouteMiddleware(server.middlewares); + installRouteMiddleware(server.middlewares, routing); }; return { name: 'overpass-proxy', diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 9dacd144b..1ee83a892 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -45,6 +45,8 @@ export function validatePlacesCoordinates(searchParams) { /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, + fetchImpl = (...args) => fetch(...args), + endpoints = {}, } = {}) { function install(middlewares) { middlewares.use('/api/google/nearby-places', async (req, res) => { @@ -96,10 +98,12 @@ export function googlePlacesContextProxy({ ); try { - const response = await fetch( - 'https://places.googleapis.com/v1/places:searchNearby', + const response = await fetchImpl( + endpoints.nearby || + 'https://places.googleapis.com/v1/places:searchNearby', { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, @@ -214,10 +218,12 @@ export function googlePlacesContextProxy({ ); try { - const response = await fetch( - 'https://places.googleapis.com/v1/places:searchText', + const response = await fetchImpl( + endpoints.textSearch || + 'https://places.googleapis.com/v1/places:searchText', { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': apiKey, diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 04d20a74d..b566db33a 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -9,8 +9,6 @@ import { /** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ const ROUTE_CACHE_MS = 600000; -const _routeCache = new Map(); - /** Hard cap on the OSRM route response we will buffer. */ const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB @@ -25,7 +23,12 @@ const _routeRateLimiter = makeRateLimiter({ globalMax: 200, }); -export function installRouteMiddleware(middlewares) { +export function installRouteMiddleware( + middlewares, + { endpoints = {}, fetchImpl = (...args) => fetch(...args) } = {}, +) { + const _routeCache = new Map(); + // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike). // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] middlewares.use('/api/route', async (req, res) => { @@ -96,13 +99,17 @@ export function installRouteMiddleware(middlewares) { res.end(JSON.stringify(cached.payload)); return; } - const upstream = `https://routing.openstreetmap.de/routed-${profile}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; + const base = + endpoints[profile] || + `https://routing.openstreetmap.de/routed-${profile}`; + const upstream = `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}?overview=full&geometries=geojson&alternatives=false&steps=false`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 12000); let osrm; try { - const upstreamRes = await fetch(upstream, { + const upstreamRes = await fetchImpl(upstream, { signal: controller.signal, + redirect: 'error', headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, }); if (!upstreamRes.ok) return fail('no route found'); diff --git a/server/providers/regional.js b/server/providers/regional.js index 6416d01b2..3b097c8ef 100644 --- a/server/providers/regional.js +++ b/server/providers/regional.js @@ -4,3 +4,4 @@ export { } from './regional/briefing.js'; export { weatherEffectsProxy } from './regional/weather-effects.js'; export { validRegionalPoint } from './regional/query.js'; +export { createRegionalPlaceProvider } from './regional/place.js'; diff --git a/server/providers/regional/briefing.js b/server/providers/regional/briefing.js index a0a6e7cdb..c5b17a12b 100644 --- a/server/providers/regional/briefing.js +++ b/server/providers/regional/briefing.js @@ -14,33 +14,33 @@ const REGIONAL_BRIEF_STALE_MS = 60 * 60_000; const REGIONAL_BRIEF_MAX_CACHE = 120; -const _regionalBriefCache = new Map(); - -const _regionalBriefInFlight = new Map(); - -const _regionalBriefRateLimiter = makeRateLimiter({ - windowMs: 60_000, - max: 30, - globalMax: 90, -}); - -function trimRegionalBriefCache() { - while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) { - const oldest = _regionalBriefCache.keys().next().value; - if (oldest === undefined) break; - _regionalBriefCache.delete(oldest); - } -} - /** True when at least one regional source produced usable data. */ function regionalBriefHasAnySource({ place, weather, news } = {}) { return Boolean(place || weather || (news && news.status !== 'unavailable')); } -function regionalBriefProxy() { +function regionalBriefProxy({ placeProvider = fetchRegionalPlace } = {}) { + const _regionalBriefCache = new Map(); + + const _regionalBriefInFlight = new Map(); + + const _regionalBriefRateLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 30, + globalMax: 90, + }); + + function trimRegionalBriefCache() { + while (_regionalBriefCache.size > REGIONAL_BRIEF_MAX_CACHE) { + const oldest = _regionalBriefCache.keys().next().value; + if (oldest === undefined) break; + _regionalBriefCache.delete(oldest); + } + } + async function refresh(point, key) { const [placeResult, weatherResult] = await Promise.allSettled([ - fetchRegionalPlace(point), + placeProvider(point), fetchRegionalWeather(point), ]); const place = placeResult.status === 'fulfilled' ? placeResult.value : null; diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js index 2ee706015..a27e9c6e6 100644 --- a/server/providers/regional/place.js +++ b/server/providers/regional/place.js @@ -1,37 +1,42 @@ import { fetchRegionalJson } from './http.js'; import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.js'; -let _nominatimQueue = Promise.resolve(); +/** Construct the serialized Nominatim adapter with a trusted endpoint. */ +export function createRegionalPlaceProvider({ + endpoint = 'https://nominatim.openstreetmap.org/reverse', + requestJson = fetchRegionalJson, +} = {}) { + let _nominatimQueue = Promise.resolve(); -let _nominatimLastRequestAt = 0; + let _nominatimLastRequestAt = 0; -function fetchRegionalPlace(point) { - const task = _nominatimQueue.then(async () => { - const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt)); - if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); - _nominatimLastRequestAt = Date.now(); - const params = new URLSearchParams({ - format: 'jsonv2', - lat: point.latitude.toFixed(5), - lon: point.longitude.toFixed(5), - zoom: '10', - addressdetails: '1', - 'accept-language': 'en', - }); - const payload = await fetchRegionalJson( - `https://nominatim.openstreetmap.org/reverse?${params}`, - { + function fetchRegionalPlace(point) { + const task = _nominatimQueue.then(async () => { + const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt)); + if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); + _nominatimLastRequestAt = Date.now(); + const params = new URLSearchParams({ + format: 'jsonv2', + lat: point.latitude.toFixed(5), + lon: point.longitude.toFixed(5), + zoom: '10', + addressdetails: '1', + 'accept-language': 'en', + }); + const payload = await requestJson(`${endpoint}?${params}`, { headers: { 'User-Agent': 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', Referer: 'https://github.com/bilawalsidhu/gods-eye-view', }, - }, - ); - return normalizeRegionalPlace(payload); - }); - _nominatimQueue = task.catch(() => null); - return task; + }); + return normalizeRegionalPlace(payload); + }); + _nominatimQueue = task.catch(() => null); + return task; + } + + return fetchRegionalPlace; } -export { fetchRegionalPlace }; +export const fetchRegionalPlace = createRegionalPlaceProvider(); diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 3c964c402..e289b8393 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,3 +1,4 @@ +import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js'; @@ -94,7 +95,7 @@ export function normalizeTargetKey(target) { } export function createAnnotationEngine({ - placeSearch, + placeSearch = defaultGeospatial, viewer, renderer, outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, @@ -418,7 +419,7 @@ export function createAnnotationEngine({ } // Real street-following route (OSM/OSRM), mode-aware. const mode = normalizeMode(spec.mode); - const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal); + const routed = await fetchRoute(resolvedPts.map((p) => [p.lon, p.lat]), mode, signal, placeSearch); if (routed) { return { path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), @@ -1101,25 +1102,10 @@ function normalizeMode(m) { return 'foot'; } -/** Fetch a real street-following route from the /api/route proxy (OSM/OSRM). */ -async function fetchRoute(coordPairs, mode, externalSignal) { - const controller = new AbortController(); - const onAbort = () => controller.abort(); - if (externalSignal) { - if (externalSignal.aborted) controller.abort(); - else externalSignal.addEventListener('abort', onAbort, { once: true }); - } - const timer = setTimeout(() => controller.abort(), 13000); - try { - const coords = coordPairs.map(([lon, lat]) => `${lon.toFixed(6)},${lat.toFixed(6)}`).join(';'); - const res = await fetch(`/api/route?profile=${mode}&coords=${encodeURIComponent(coords)}`, { signal: controller.signal }); - const data = await res.json(); - if (data?.ok && Array.isArray(data.geometry) && data.geometry.length >= 2) return data; - } catch { /* routing unavailable / aborted → caller falls back to straight segments */ } finally { - clearTimeout(timer); - if (externalSignal) externalSignal.removeEventListener('abort', onAbort); - } - return null; +/** Route failure remains an explicitly labelled direct line in the caller. */ +async function fetchRoute(coordPairs, mode, signal, service) { + try { return await service.route?.(coordPairs, mode, { signal }) || null; } + catch { return null; } } function greatCircleM(a, b) { diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 87b6514ae..3d2e302d7 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,3 +1,4 @@ +import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; import { lookupNaturalRegionOutline, findNaturalRegion } from '../data/naturalEarthRegions.js'; @@ -124,7 +125,7 @@ export async function resolveAnnotationTarget({ // miss we fall through to geocode + fetchLocalMonument below. The model's entityKind counts too: // a point_feature by fact ("Heroes of the Alamo" — no monument word) deserves the same path. if (center && (isMonumentLikeQuery(query) || isGroundsLikeQuery(query) || entityKind === 'point_feature')) { - const placeHit = await placesTextSearch(query, center.lat, center.lon, 6000, signal); + const placeHit = await placesTextSearch(query, center.lat, center.lon, 6000, signal, placeSearch); if (placeHit) { trace.places = `${placeHit.lat.toFixed(5)},${placeHit.lon.toFixed(5)}`; if (placeHit.distanceM <= PLACES_MAX_DISTANCE_M) { @@ -163,7 +164,7 @@ export async function resolveAnnotationTarget({ geocodeFar = true; } if (geocodeFar) { - const placeHit = await placesTextSearch(query, center.lat, center.lon, 6000, signal); + const placeHit = await placesTextSearch(query, center.lat, center.lon, 6000, signal, placeSearch); if (placeHit && placeHit.distanceM <= PLACES_MAX_DISTANCE_M) { lat = placeHit.lat; lon = placeHit.lon; @@ -627,7 +628,7 @@ function normalizeGeocodeViewport(vp) { }; } -const placesCache = new Map(); // Text Search hits, keyed by query + rounded view centre +const placesCaches = new WeakMap(); // Cache isolated by provider configuration /** * View-biased Google Places TEXT SEARCH for a named landmark/POI. Geocoding @@ -640,7 +641,9 @@ const placesCache = new Map(); // Text Search hits, keyed by query + rounded vie * @returns {Promise} */ -async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) { +async function placesTextSearch(query, centerLat, centerLon, radiusM, signal, service = defaultGeospatial) { + const placesCache = placesCaches.get(service) || new Map(); + placesCaches.set(service, placesCache); const q = String(query || '').trim(); if (!q || !Number.isFinite(centerLat) || !Number.isFinite(centerLon)) return null; @@ -648,16 +651,10 @@ async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) { const cached = cacheRead(placesCache, cacheKey); if (cached !== undefined) return cached; - const params = new URLSearchParams({ - q, - lat: String(centerLat), - lon: String(centerLon), - radiusM: String(radiusM), - }); try { - const response = await fetch(`/api/google/text-search?${params}`, { signal }); - if (!response.ok) { negCache(placesCache, cacheKey, signal, false); return null; } // transient - const data = await response.json(); + const data = { places: await service.textSearch?.(q, { + latitude: centerLat, longitude: centerLon, radiusM, + }, { signal }) }; const hit = Array.isArray(data?.places) ? data.places.find((p) => Number.isFinite(p?.latitude) && Number.isFinite(p?.longitude)) : null; @@ -1788,13 +1785,13 @@ export function viewportBias(viewer) { * plain geocode path. Returns the Places hit * ({ lat, lon, label, types, viewport, distanceM, … }) or null. */ -export async function placesNearViewRecovery(viewer, query, geocoded = null, signal = undefined) { +export async function placesNearViewRecovery(viewer, query, geocoded = null, signal = undefined, placeSearch = defaultGeospatial) { const center = pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer); if (!center) return null; const geocodeFar = !geocoded || approximateDistanceM(center.lat, center.lon, geocoded.lat, geocoded.lon) / 1000 > MIN_DRIFT_FLOOR_KM; if (!geocodeFar) return null; - const hit = await placesTextSearch(query, center.lat, center.lon, 6000, signal); + const hit = await placesTextSearch(query, center.lat, center.lon, 6000, signal, placeSearch); return (hit && hit.distanceM <= PLACES_MAX_DISTANCE_M) ? hit : null; } diff --git a/src/hud.js b/src/hud.js index 74b9c044e..fe152a478 100644 --- a/src/hud.js +++ b/src/hud.js @@ -66,7 +66,8 @@ export class IntelHUD { * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * camera telemetry and coordinate derivation. */ - constructor(viewer) { + constructor(viewer, { placeSearch } = {}) { + this.placeSearch = placeSearch; this.viewer = viewer; this._visible = false; this._autoMode = true; // auto show/hide based on style @@ -700,7 +701,7 @@ export class IntelHUD { } async _summaryContext() { - const labels = await getBasemapLabelContext(this.viewer); + const labels = await getBasemapLabelContext(this.viewer, this.placeSearch); const enabledLayers = this._dataManager?.getAll?.() ?.filter((layer) => layer.enabled) .map((layer) => layer.name) || []; diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 614fa5adc..6033a6776 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -199,9 +199,9 @@ export function normalizePhotonFeature(feature) { */ export function photonSearchUrl( query, - { bias = null, limit = PHOTON_RESULT_LIMIT } = {}, + { bias = null, limit = PHOTON_RESULT_LIMIT, endpoint = PHOTON_ENDPOINT } = {}, ) { - const url = new URL(PHOTON_ENDPOINT); + const url = new URL(endpoint); url.searchParams.set('q', String(query ?? '')); url.searchParams.set('limit', String(limit)); @@ -296,13 +296,19 @@ function trimPhotonCache(cache) { */ export async function geocodeKeylessWithOutcome( query, - { bias = null, fetchImpl = fetch, signal, cache = photonCache } = {}, + { + bias = null, + fetchImpl = fetch, + signal, + cache = photonCache, + endpoint = PHOTON_ENDPOINT, + } = {}, ) { signal?.throwIfAborted(); const trimmed = String(query ?? '').trim(); if (!trimmed) return { place: null, answered: true }; - const memoKey = `${bias ?? ''}\n${trimmed}`; + const memoKey = `${endpoint}\n${bias ?? ''}\n${trimmed}`; // Only answered outcomes are memoised, so a hit is always an answer. const cached = cache.get(memoKey); if (cached && cached.expires > Date.now()) @@ -341,14 +347,16 @@ export async function geocodeKeylessWithOutcome( let feature = null; let everyAskAnswered = true; if (bias) { - const biased = await ask(photonSearchUrl(trimmed, { bias })); + const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint })); if (biased === null) everyAskAnswered = false; else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); } signal?.throwIfAborted(); if (!feature) { - const anywhere = await ask(photonSearchUrl(trimmed, { bias: null })); + const anywhere = await ask( + photonSearchUrl(trimmed, { bias: null, endpoint }), + ); if (anywhere === null) everyAskAnswered = false; else { // "Hoan Kiem Lake, Hanoi" names a place and then the region holding it, the @@ -396,10 +404,16 @@ export async function geocodeKeyless(query, options) { /** Construct an independent Photon provider with a bounded query cache. */ export function createPhotonGeocoder({ fetchImpl = (...args) => fetch(...args), + endpoint = PHOTON_ENDPOINT, } = {}) { const cache = new Map(); return { geocode: (query, options = {}) => - geocodeKeylessWithOutcome(query, { ...options, fetchImpl, cache }), + geocodeKeylessWithOutcome(query, { + ...options, + fetchImpl, + cache, + endpoint, + }), }; } diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 142cb1cdb..2c9565054 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -314,7 +314,9 @@ test('forward geocoding is composed once and consumers do not call a source dire assert.match(source, /placeSearch\.geocode\(/); assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/); } - const setup = fs.readFileSync(path.join(ROOT, 'src/standalone/placeSearch.js'), 'utf8'); + const standalone = fs.readFileSync(path.join(ROOT, 'src/standalone/placeSearch.js'), 'utf8'); + assert.match(standalone, /createDefaultPlaceSearch as createStandalonePlaceSearch/); + const setup = fs.readFileSync(path.join(ROOT, 'src/search/defaults.js'), 'utf8'); assert.match(setup, /createGoogleGeocoder/); assert.match(setup, /createPhotonGeocoder/); }); diff --git a/src/search/defaults.js b/src/search/defaults.js new file mode 100644 index 000000000..4668ed7ce --- /dev/null +++ b/src/search/defaults.js @@ -0,0 +1,54 @@ +import { createGeospatialServices } from './geospatial.js'; +import { createHttpGeospatialProvider } from './http.js'; +import { createPlaceSearch } from './placeSearch.js'; +import { createGoogleGeocoder } from './google.js'; +import { createPhotonGeocoder } from '../keylessGeocoder.js'; + +/** Google first when configured, then keyless Photon; transport stays local to setup. */ +export function createDefaultPlaceSearch({ + resolveApiKey, + fetchImpl = (...args) => fetch(...args), + signal, + endpoints = {}, + providers = {}, +} = {}) { + const forward = createPlaceSearch({ + signal, + providers: providers.geocode || [ + createGoogleGeocoder({ + request(query, { bias, signal }) { + const key = resolveApiKey?.(); + if (!key) return null; + const url = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + url.searchParams.set('address', query); + url.searchParams.set('key', key); + if (bias) url.searchParams.set('bounds', bias); + return fetchImpl(url.toString(), { signal }); + }, + }), + createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }), + ], + }); + return { + ...forward, + ...createGeospatialServices({ + signal, + providers: { + ...createHttpGeospatialProvider({ + fetchImpl, + resolveApiKey, + endpoints, + }), + ...providers, + }, + }), + }; +} + +// Compatibility for direct module callers. Application composition supplies its own instance. +export const defaultGeospatial = createDefaultPlaceSearch({ + resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__, +}); diff --git a/src/search/geospatial.js b/src/search/geospatial.js new file mode 100644 index 000000000..5c14bba5e --- /dev/null +++ b/src/search/geospatial.js @@ -0,0 +1,88 @@ +/** WGS84 coordinate pair in longitude/latitude order. */ +export function validCoordinate(pair) { + return ( + Array.isArray(pair) && + pair.length >= 2 && + Number.isFinite(pair[0]) && + Math.abs(pair[0]) <= 180 && + Number.isFinite(pair[1]) && + Math.abs(pair[1]) <= 90 + ); +} + +/** Compose independent geospatial operations; unsupported operations stay explicit. */ +export function createGeospatialServices({ + providers = {}, + signal: lifetime, +} = {}) { + const capabilities = Object.freeze( + Object.fromEntries( + ['reverseGeocode', 'textSearch', 'nearby', 'route'].map((name) => [ + name, + typeof providers[name] === 'function', + ]), + ), + ); + async function invoke(name, args, options = {}) { + const signal = AbortSignal.any( + [ + lifetime, + options.signal, + AbortSignal.timeout(name === 'route' ? 13_000 : 5_000), + ].filter(Boolean), + ); + signal.throwIfAborted(); + if (!capabilities[name]) return null; + const result = await providers[name](...args, { ...options, signal }); + signal.throwIfAborted(); + return result; + } + return { + capabilities, + signal: lifetime, + attribution: Object.freeze({ ...providers.attribution }), + reverseGeocode(latitude, longitude, options) { + if (!validCoordinate([longitude, latitude])) return Promise.resolve(null); + return invoke('reverseGeocode', [latitude, longitude], options); + }, + async textSearch(query, point, options) { + if ( + !String(query || '').trim() || + !validCoordinate([point?.longitude, point?.latitude]) + ) + return []; + return ( + (await invoke('textSearch', [String(query).trim(), point], options)) || + [] + ); + }, + async nearby(point, options) { + if (!validCoordinate([point?.longitude, point?.latitude])) return []; + return (await invoke('nearby', [point], options)) || []; + }, + async route(coordinates, profile = 'foot', options) { + if ( + !Array.isArray(coordinates) || + coordinates.length < 2 || + coordinates.length > 12 || + !coordinates.every(validCoordinate) + ) + return null; + const profiles = providers.routeProfiles || ['foot', 'car', 'bike']; + if (!profiles.includes(profile)) return null; + const result = await invoke('route', [coordinates, profile], options); + if ( + !result || + !Array.isArray(result.geometry) || + result.geometry.length < 2 || + !result.geometry.every(validCoordinate) || + !Number.isFinite(result.distanceM) || + result.distanceM < 0 || + !Number.isFinite(result.durationS) || + result.durationS < 0 + ) + return null; + return { ...result, ok: true, profile }; + }, + }; +} diff --git a/src/search/geospatial.test.mjs b/src/search/geospatial.test.mjs new file mode 100644 index 000000000..660ac11ca --- /dev/null +++ b/src/search/geospatial.test.mjs @@ -0,0 +1,239 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createGeospatialServices, + createHttpGeospatialProvider, + createDefaultPlaceSearch, +} from './index.js'; +import { installRouteMiddleware } from '../../server/providers/places/routes.js'; +import { createRegionalPlaceProvider } from '../../server/providers/regional/place.js'; + +const point = { latitude: 30, longitude: -97, radiusM: 250 }; +const coordinates = [ + [-97, 30], + [-97.001, 30.001], +]; +const route = { geometry: coordinates, distanceM: 120, durationS: 90 }; +const json = (data) => + new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' }, + }); + +test('each operation can use an independent provider without coupling forward search', async () => { + const calls = []; + const service = createDefaultPlaceSearch({ + providers: { + geocode: [ + { + geocode: async () => ({ + place: { lat: 30, lng: -97 }, + answered: true, + }), + }, + ], + reverseGeocode: async (lat, lon) => { + calls.push([lat, lon]); + return { locality: 'Town' }; + }, + textSearch: async (q) => [{ name: q, ...point }], + nearby: async (p) => [p], + route: async () => route, + routeProfiles: ['foot'], + }, + }); + assert.equal((await service.geocode('Town')).place.lat, 30); + assert.equal((await service.reverseGeocode(30, -97)).locality, 'Town'); + assert.equal((await service.textSearch('Library', point))[0].name, 'Library'); + assert.deepEqual(await service.nearby(point), [point]); + assert.deepEqual(await service.route(coordinates), { + ...route, + ok: true, + profile: 'foot', + }); + assert.equal(await service.route(coordinates, 'car'), null); + assert.deepEqual(calls, [[30, -97]]); +}); + +test('unsupported operations and invalid coordinates never initiate a request', async () => { + let calls = 0; + const service = createGeospatialServices({ + providers: { + route: () => { + calls++; + }, + }, + }); + assert.equal(service.capabilities.reverseGeocode, false); + assert.equal(await service.reverseGeocode(30, -97), null); + assert.equal( + await service.route([ + [999, 30], + [-97, 30], + ]), + null, + ); + assert.equal(await service.route(coordinates, 'spaceship'), null); + assert.equal(calls, 0); + const invalid = createGeospatialServices({ + providers: { route: async () => ({ ...route, durationS: NaN }) }, + }); + assert.equal(await invalid.route(coordinates), null); +}); + +test('application or caller cancellation rejects late provider and body results', async () => { + for (const lifetimeAbort of [true, false]) { + const lifetime = new AbortController(); + const caller = new AbortController(); + const service = createGeospatialServices({ + signal: lifetime.signal, + providers: createHttpGeospatialProvider({ + endpoints: { route: '/custom/route' }, + fetchImpl: async (_url, { signal }) => ({ + ok: true, + async json() { + (lifetimeAbort ? lifetime : caller).abort(); + assert.equal(signal.aborted, true); + return { ...route, ok: true }; + }, + }), + }), + }); + await assert.rejects( + service.route(coordinates, 'foot', { signal: caller.signal }), + { name: 'AbortError' }, + ); + } +}); + +test('compatible HTTP endpoints retain units and never place a server credential in browser requests', async () => { + const urls = []; + const service = createGeospatialServices({ + providers: createHttpGeospatialProvider({ + endpoints: { + reverse: '/geo/reverse', + textSearch: '/geo/search', + nearby: '/geo/nearby', + route: '/geo/route', + }, + fetchImpl: async (url) => { + urls.push(new URL(url, 'https://example.test')); + if (url.startsWith('/geo/reverse')) + return json({ + status: 'OK', + results: [{ formatted_address: 'Town', address_components: [] }], + }); + if (url.startsWith('/geo/route')) return json({ ok: true, ...route }); + return json({ places: [{ name: 'Library', ...point }] }); + }, + }), + }); + assert.equal( + (await service.reverseGeocode(30, -97)).formattedAddress, + 'Town', + ); + await service.textSearch('Library', point); + await service.nearby(point); + assert.equal((await service.route(coordinates)).distanceM, 120); + assert.deepEqual( + urls.map((url) => url.pathname), + ['/geo/reverse', '/geo/search', '/geo/nearby', '/geo/route'], + ); + assert.ok(urls.every((url) => !url.searchParams.has('key'))); +}); + +test('HTTP refusal cancels its response and does not retry another destination', async () => { + let cancelled = 0, + calls = 0; + const provider = createHttpGeospatialProvider({ + fetchImpl: async () => { + calls++; + return { + ok: false, + body: { + async cancel() { + cancelled++; + }, + }, + }; + }, + }); + await assert.rejects(provider.nearby(point), /unavailable/); + assert.equal(calls, 1); + assert.equal(cancelled, 1); +}); + +test('Photon configuration changes its endpoint without changing its normalized result', async () => { + const service = createDefaultPlaceSearch({ + endpoints: { photon: 'https://search.example/api/' }, + fetchImpl: async (url) => { + assert.equal(new URL(url).origin, 'https://search.example'); + return json({ + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-97, 30] }, + properties: { name: 'Town' }, + }, + ], + }); + }, + }); + assert.equal((await service.geocode('Town')).place.lat, 30); +}); + +test('routing middleware uses configured OSRM servers while retaining request bounds and profile', async () => { + let handler, + calls = 0; + installRouteMiddleware( + { + use(_path, fn) { + handler = fn; + }, + }, + { + endpoints: { foot: 'https://routes.example/walking' }, + fetchImpl: async (url, options) => { + calls++; + assert.match( + url, + /^https:\/\/routes\.example\/walking\/route\/v1\/foot\//, + ); + assert.equal(options.redirect, 'error'); + return json({ + code: 'Ok', + routes: [{ geometry: { coordinates }, distance: 120, duration: 90 }], + }); + }, + }, + ); + const request = async (query) => { + let payload; + await handler( + { url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } }, + { + writeHead() {}, + end(body) { + payload = JSON.parse(body); + }, + }, + ); + return payload; + }; + assert.equal( + (await request('profile=foot&coords=-97,30;-97.001,30.001')).distanceM, + 120, + ); + assert.equal((await request('profile=foot&coords=-97,30;97,-30')).ok, false); + assert.equal(calls, 1); +}); + +test('regional reverse geocoding uses the configured Nominatim endpoint and projection', async () => { + const lookup = createRegionalPlaceProvider({ + endpoint: 'https://places.example/reverse', + requestJson: async (url) => { + assert.equal(new URL(url).origin, 'https://places.example'); + return { address: { city: 'Town', country: 'Country' } }; + }, + }); + assert.equal((await lookup(point)).locality, 'Town'); +}); diff --git a/src/search/http.js b/src/search/http.js new file mode 100644 index 000000000..5b1642c67 --- /dev/null +++ b/src/search/http.js @@ -0,0 +1,132 @@ +import { validCoordinate } from './geospatial.js'; + +function labels(values) { + return [ + ...new Set( + values + .map((value) => + String(value || '') + .replace(/[\x00-\x1f\x7f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120), + ) + .filter(Boolean), + ), + ]; +} + +/** Normalize reverse-geocoder labels without exposing provider response objects. */ +export function normalizeGoogleReverse(data) { + if (data?.status !== 'OK' || !data.results?.length) return null; + const result = data.results[0]; + const relevant = data.results.slice(0, 12); + const components = Array.isArray(result.address_components) + ? result.address_components + : []; + const component = (type) => + components.find((item) => item.types?.includes(type))?.long_name || null; + return { + formattedAddress: result.formatted_address || null, + locality: + component('locality') || + component('postal_town') || + component('administrative_area_level_2'), + region: component('administrative_area_level_1'), + country: component('country'), + types: result.types || [], + labels: labels(relevant.map((item) => item.formatted_address)).slice(0, 12), + streetLabels: labels( + relevant.flatMap((item) => + (item.address_components || []) + .filter((entry) => entry.types?.includes('route')) + .map((entry) => entry.long_name), + ), + ).slice(0, 12), + }; +} + +/** Existing JSON protocols with application-selected endpoints and transport. */ +export function createHttpGeospatialProvider({ + fetchImpl = (...args) => fetch(...args), + resolveApiKey, + endpoints = {}, +} = {}) { + const urls = { + reverse: 'https://maps.googleapis.com/maps/api/geocode/json', + textSearch: '/api/google/text-search', + nearby: '/api/google/nearby-places', + route: '/api/route', + ...endpoints, + }; + async function json(endpoint, params, { signal } = {}) { + if (!endpoint) return null; + const separator = endpoint.includes('?') ? '&' : '?'; + const response = await fetchImpl( + `${endpoint}${separator}${new URLSearchParams(params)}`, + { signal }, + ); + signal?.throwIfAborted(); + if (!response.ok) { + await response.body?.cancel?.().catch(() => {}); + throw new Error('Geospatial service unavailable'); + } + const data = await response.json(); + signal?.throwIfAborted(); + return data; + } + const places = (data) => + (Array.isArray(data?.places) ? data.places : []) + .filter((place) => validCoordinate([place.longitude, place.latitude])) + .slice(0, 20); + return { + attribution: { + reverseGeocode: 'Google', + textSearch: 'Google', + nearby: 'Google', + route: 'OpenStreetMap / OSRM', + }, + routeProfiles: ['foot', 'car', 'bike'], + async reverseGeocode(latitude, longitude, options) { + const key = resolveApiKey?.(); + if (resolveApiKey && !key) return null; + return normalizeGoogleReverse( + await json( + urls.reverse, + { latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) }, + options, + ), + ); + }, + async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) { + return places( + await json( + urls.textSearch, + { q: query, lat: latitude, lon: longitude, radiusM }, + options, + ), + ); + }, + async nearby({ latitude, longitude, radiusM = 250 }, options) { + return places( + await json( + urls.nearby, + { lat: latitude, lon: longitude, radiusM }, + options, + ), + ); + }, + async route(coordinates, profile, options) { + const coords = coordinates + .map((pair) => + pair + .slice(0, 2) + .map((n) => n.toFixed(6)) + .join(','), + ) + .join(';'); + const data = await json(urls.route, { profile, coords }, options); + return data?.ok ? data : null; + }, + }; +} diff --git a/src/search/index.js b/src/search/index.js index 307dbaf30..8e0d4dbd1 100644 --- a/src/search/index.js +++ b/src/search/index.js @@ -1,3 +1,9 @@ export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js'; export { createGoogleGeocoder, normalizeGooglePlace } from './google.js'; +export { createGeospatialServices, validCoordinate } from './geospatial.js'; +export { + createHttpGeospatialProvider, + normalizeGoogleReverse, +} from './http.js'; export { createPhotonGeocoder } from '../keylessGeocoder.js'; +export { createDefaultPlaceSearch } from './defaults.js'; diff --git a/src/standalone/application.js b/src/standalone/application.js index f5a17ca2c..7dd9b8f44 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -12,6 +12,7 @@ let constructed = false; export function createStandaloneApplication({ googleApiKey, cesiumToken, + geospatial = {}, allowQaRegistration = false, }) { if (constructed) @@ -23,6 +24,7 @@ export function createStandaloneApplication({ return createApplication({ createScene: (context) => { placeSearch = createStandalonePlaceSearch({ + ...geospatial, resolveApiKey: () => googleApiKey, signal: context.signal, }); diff --git a/src/standalone/placeSearch.js b/src/standalone/placeSearch.js index 7b1016602..8554bb33b 100644 --- a/src/standalone/placeSearch.js +++ b/src/standalone/placeSearch.js @@ -1,32 +1 @@ -import { - createPlaceSearch, - createGoogleGeocoder, - createPhotonGeocoder, -} from '../search/index.js'; - -/** Google first when configured, then keyless Photon; transport stays local to setup. */ -export function createStandalonePlaceSearch({ - resolveApiKey, - fetchImpl = (...args) => fetch(...args), - signal, -} = {}) { - return createPlaceSearch({ - signal, - providers: [ - createGoogleGeocoder({ - request(query, { bias, signal }) { - const key = resolveApiKey?.(); - if (!key) return null; - const url = new URL( - 'https://maps.googleapis.com/maps/api/geocode/json', - ); - url.searchParams.set('address', query); - url.searchParams.set('key', key); - if (bias) url.searchParams.set('bounds', bias); - return fetchImpl(url.toString(), { signal }); - }, - }), - createPhotonGeocoder({ fetchImpl }), - ], - }); -} +export { createDefaultPlaceSearch as createStandalonePlaceSearch } from '../search/defaults.js'; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index f347a91c2..e54b7bf25 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -293,7 +293,7 @@ export class StyleManager { this._orbitIndicator = null; // Intel HUD - this.hud = new IntelHUD(viewer); + this.hud = new IntelHUD(viewer, { placeSearch }); this._recording.hud = this.hud; this._cockpitVisionMode = 'optical'; this._cockpitVisionRestore = null; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d380edd96..6d9c77fc1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { CITY_POIS, findPoiByName, flyToGlobeView, flyToLandmark, flyToPOI, flyToPresetLocation, GLOBE_VIEW, searchAndFlyTo } from '../locations.js'; import { @@ -246,10 +247,20 @@ const FRAME_TARGETS = new Map([ ['ships', 'ais-live-vessels'], ]); -const reverseGeocodeCache = new Map(); -const reverseGeocodeInFlight = new Map(); -const nearbyPlacesCache = new Map(); -const nearbyPlacesInFlight = new Map(); +const serviceCaches = new WeakMap(); +function cachesFor(service) { + service.signal?.throwIfAborted(); + let caches = serviceCaches.get(service); + if (!caches) { + caches = { reverseGeocodeCache: new Map(), reverseGeocodeInFlight: new Map(), + nearbyPlacesCache: new Map(), nearbyPlacesInFlight: new Map() }; + serviceCaches.set(service, caches); + service.signal?.addEventListener('abort', () => { + for (const cache of Object.values(caches)) cache.clear(); + }, { once: true }); + } + return caches; +} const VISIBLE_ENTITY_SHORTLIST = 64; const BASEMAP_CONTEXT_WAIT_MS = 1500; const viewTargetCache = new WeakMap(); @@ -842,7 +853,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } if (name === 'get_entity_context') { - return getEntityContext(viewer, dataManager, styleManager, args); + return getEntityContext(viewer, dataManager, styleManager, args, placeSearch); } if (name === 'get_current_view_state') { @@ -1885,7 +1896,7 @@ function collectTrackedEntities(dataManager) { return tracked; } -export async function getBasemapLabelContext(viewer) { +export async function getBasemapLabelContext(viewer, service = defaultGeospatial) { const samples = sampleViewportCartographics(viewer); const cameraHeightM = viewer.camera.positionCartographic.height; const target = getViewTargetCartographic(viewer); @@ -1899,15 +1910,15 @@ export async function getBasemapLabelContext(viewer) { const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6)); const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6)); - const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM); + const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service); const viewportPromise = cachedViewportPlaces ? Promise.resolve(cachedViewportPlaces) - : reverseGeocodeViewportSamples(samples, cameraHeightM); + : reverseGeocodeViewportSamples(samples, cameraHeightM, service); const placePromise = shouldReverseGeocode(cameraHeightM) - ? reverseGeocode(latitude, longitude) + ? reverseGeocode(latitude, longitude, service) : Promise.resolve(null); const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM) - ? fetchNearbyPlaces(latitude, longitude, cameraHeightM) + ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) : Promise.resolve([]); const [viewportPlaces, place, nearbyPlaces] = await Promise.all([ resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), @@ -2381,7 +2392,7 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = }; } -async function getEntityContext(viewer, dataManager, styleManager, args = {}) { +async function getEntityContext(viewer, dataManager, styleManager, args = {}, service = defaultGeospatial) { const startedAt = performance.now(); const scope = String(args.scope || 'auto').toLowerCase(); const layerId = normalizeLayerId(args.layerId || args.layer); @@ -2389,7 +2400,7 @@ async function getEntityContext(viewer, dataManager, styleManager, args = {}) { const selected = selectedEntityContext(dataManager); const cameraHeightM = viewer.camera.positionCartographic.height; const viewTarget = getViewTargetCartographic(viewer); - const scenePromise = getSceneContext(viewer, styleManager, dataManager, viewTarget); + const scenePromise = getSceneContext(viewer, styleManager, dataManager, viewTarget, service); const selectedWillBeReturned = selected && (scope === 'selected' || scope === 'auto'); const visible = (!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM)) ? visibleEntityContexts(viewer, dataManager, { layerId, limit, target: viewTarget }) @@ -2594,9 +2605,9 @@ function insertNearestRecord(records, candidate, limit) { records.pop(); } -async function getSceneContext(viewer, styleManager, dataManager, viewTarget = null) { +async function getSceneContext(viewer, styleManager, dataManager, viewTarget = null, service = defaultGeospatial) { const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC); - const basemap = await getBasemapContext(viewer, viewTarget); + const basemap = await getBasemapContext(viewer, viewTarget, service); const enabledLayers = dataManager.getAll() .filter((layer) => layer.enabled) .map((layer) => ({ @@ -2617,16 +2628,17 @@ async function getSceneContext(viewer, styleManager, dataManager, viewTarget = n }; } -async function getBasemapContext(viewer, viewTarget = null) { +async function getBasemapContext(viewer, viewTarget = null, service = defaultGeospatial) { + const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const target = viewTarget; const samples = sampleViewportCartographics(viewer); const cameraCartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC); const cameraHeightM = cameraCartographic.height; const viewScale = classifyViewScale(cameraHeightM); - const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM); + const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service); const viewportPlacesPromise = cachedViewportPlaces ? Promise.resolve(cachedViewportPlaces) - : reverseGeocodeViewportSamples(samples, cameraHeightM); + : reverseGeocodeViewportSamples(samples, cameraHeightM, service); if (!target) { const viewportPlaces = await resolveWithin( viewportPlacesPromise, @@ -2657,10 +2669,10 @@ async function getBasemapContext(viewer, viewTarget = null) { ? nearbyPlacesCache.get(nearbyCacheKey) : null; const placePromise = shouldReverseGeocode(cameraHeightM) && !cachedPlace - ? reverseGeocode(latitude, longitude) + ? reverseGeocode(latitude, longitude, service) : Promise.resolve(cachedPlace); const nearbyPlacesPromise = shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces - ? fetchNearbyPlaces(latitude, longitude, cameraHeightM) + ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) : Promise.resolve(cachedNearbyPlaces); const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] = await Promise.all([ resolveWithin(viewportPlacesPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), @@ -2929,44 +2941,20 @@ function inferCountry(latitude, longitude) { return region?.name || null; } -async function reverseGeocode(latitude, longitude) { - const apiKey = window.__GOOGLE_MAPS_API_KEY__; - if (!apiKey || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; +async function reverseGeocode(latitude, longitude, service) { + const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; const key = reverseGeocodeKey(latitude, longitude); if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key); if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key); const request = (async () => { try { - const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${encodeURIComponent(`${latitude},${longitude}`)}&key=${apiKey}`; - const response = await fetchWithTimeout(url, {}, 5000); - const data = await response.json(); - if (data.status !== 'OK' || !data.results?.length) { - reverseGeocodeCache.set(key, null); - return null; + const place = await service.reverseGeocode?.(latitude, longitude) || null; + if (place) { + reverseGeocodeCache.set(key, place); + if (reverseGeocodeCache.size > 256) reverseGeocodeCache.delete(reverseGeocodeCache.keys().next().value); } - - const result = data.results[0]; - const relevantResults = data.results.slice(0, 12); - const components = Array.isArray(result.address_components) ? result.address_components : []; - const component = (type) => components.find((item) => item.types?.includes(type))?.long_name || null; - const labels = uniqueStrings(relevantResults.map((item) => item.formatted_address)).slice(0, 12); - const streetLabels = uniqueStrings(relevantResults.flatMap((item) => { - const itemComponents = Array.isArray(item.address_components) ? item.address_components : []; - return itemComponents - .filter((entry) => entry.types?.includes('route')) - .map((entry) => entry.long_name); - })).slice(0, 12); - const place = { - formattedAddress: result.formatted_address || null, - locality: component('locality') || component('postal_town') || component('administrative_area_level_2'), - region: component('administrative_area_level_1'), - country: component('country'), - types: result.types || [], - labels, - streetLabels, - }; - reverseGeocodeCache.set(key, place); return place; } catch { return null; @@ -2978,14 +2966,14 @@ async function reverseGeocode(latitude, longitude) { return request; } -async function reverseGeocodeViewportSamples(samples, cameraHeightM) { +async function reverseGeocodeViewportSamples(samples, cameraHeightM, service) { if (!shouldReverseGeocodeViewport(cameraHeightM) || !samples.length) return null; // At building scale, center geocoding plus Nearby Places is more precise and // avoids three redundant Google requests. if (cameraHeightM <= 10000) return null; const prioritySamples = [samples[0], samples[1], samples[2]].filter(Boolean); const places = (await Promise.all(prioritySamples.map(async (sample) => { - const place = await reverseGeocode(sample.latitude, sample.longitude); + const place = await reverseGeocode(sample.latitude, sample.longitude, service); if (!place) return null; return { latitude: sample.latitude, @@ -3002,7 +2990,8 @@ async function reverseGeocodeViewportSamples(samples, cameraHeightM) { return summarizeViewportPlaces(places); } -function viewportPlacesFromCache(samples, cameraHeightM) { +function viewportPlacesFromCache(samples, cameraHeightM, service) { + const { reverseGeocodeCache } = cachesFor(service); if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null; const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => { const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude)); @@ -3034,7 +3023,8 @@ function summarizeViewportPlaces(places) { }; } -async function fetchNearbyPlaces(latitude, longitude, cameraHeightM) { +async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) { + const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service); const radiusM = nearbyPlacesRadiusM(cameraHeightM); const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey); @@ -3042,17 +3032,10 @@ async function fetchNearbyPlaces(latitude, longitude, cameraHeightM) { const request = (async () => { try { - const params = new URLSearchParams({ - lat: String(latitude), - lon: String(longitude), - radiusM: String(radiusM), - }); - const response = await fetchWithTimeout(`/api/google/nearby-places?${params}`, {}, 5000); - const data = await response.json().catch(() => null); - const places = response.ok && Array.isArray(data?.places) - ? data.places.filter((place) => place?.name).slice(0, 12) - : []; + const places = (await service.nearby?.({ latitude, longitude, radiusM }) || []) + .filter(place => place?.name).slice(0, 12); nearbyPlacesCache.set(cacheKey, places); + if (nearbyPlacesCache.size > 256) nearbyPlacesCache.delete(nearbyPlacesCache.keys().next().value); return places; } catch { return []; @@ -3089,16 +3072,6 @@ function sanitizeLabel(value) { return out.replace(/\s+/g, ' ').trim().slice(0, 120); } -async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) { - const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - window.clearTimeout(timeout); - } -} - function reverseGeocodeKey(latitude, longitude) { return `${latitude.toFixed(4)},${longitude.toFixed(4)}`; }