Merge pull request #447 from bilawalsidhu/refactor/geospatial-services

Compose configurable geocoding, place search and route services
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-13 21:39:04 -07:00
commit 0ba00f4662
25 files changed
+764 -211

No files matched your search

+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## ALPR camera locations
- Label the loaded camera count as nearby, show a purple-dot legend, and add - Label the loaded camera count as nearby, show a purple-dot legend, and add
+49
View File
@@ -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. changing the startup contract or moving standalone imports into the package.
Run the unit suite, package boundary gate, build, tracking and first-run browser Run the unit suite, package boundary gate, build, tracking and first-run browser
checks when changing this wiring. 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.
+8
View File
@@ -1,5 +1,13 @@
# God's Eye View Current State # 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, The optional **ALPR Cameras** layer shows community-mapped OpenStreetMap locations,
not camera footage or plate records. City-scale queries use the existing Overpass not camera footage or plate records. City-scale queries use the existing Overpass
provider, with capped results, retry, cached-data and incomplete-coverage notices. provider, with capped results, retry, cached-data and incomplete-coverage notices.
+5 -1
View File
@@ -532,5 +532,9 @@
"src/mapStackChips.test.mjs", "src/mapStackChips.test.mjs",
"src/mapStackUnavailableReason.test.mjs", "src/mapStackUnavailableReason.test.mjs",
"src/mapStartup.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"
] ]
+4 -1
View File
@@ -263,7 +263,10 @@
"src/search/index.js", "src/search/index.js",
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/search/google.js", "src/search/google.js",
"src/keylessGeocoder.js" "src/keylessGeocoder.js",
"src/search/geospatial.js",
"src/search/http.js",
"src/search/defaults.js"
], ],
"external": [] "external": []
}, },
+2 -2
View File
@@ -59,7 +59,7 @@ function sendOverpassResponse(res, payload, cacheStatus = 'MISS') {
* *
* @returns {import('vite').Plugin} * @returns {import('vite').Plugin}
*/ */
function overpassProxy() { function overpassProxy({ routing = {} } = {}) {
const installMiddleware = (server) => { const installMiddleware = (server) => {
server.middlewares.use('/api/overpass', async (req, res) => { server.middlewares.use('/api/overpass', async (req, res) => {
// Hoisted out of the try so the catch's serve-stale lookup can see it // 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 { return {
name: 'overpass-proxy', name: 'overpass-proxy',
+10 -4
View File
@@ -45,6 +45,8 @@ export function validatePlacesCoordinates(searchParams) {
/** Nearby place labels and view-biased text search, with request-time key resolution. */ /** Nearby place labels and view-biased text search, with request-time key resolution. */
export function googlePlacesContextProxy({ export function googlePlacesContextProxy({
resolveApiKey = googleServerApiKey, resolveApiKey = googleServerApiKey,
fetchImpl = (...args) => fetch(...args),
endpoints = {},
} = {}) { } = {}) {
function install(middlewares) { function install(middlewares) {
middlewares.use('/api/google/nearby-places', async (req, res) => { middlewares.use('/api/google/nearby-places', async (req, res) => {
@@ -96,10 +98,12 @@ export function googlePlacesContextProxy({
); );
try { try {
const response = await fetch( const response = await fetchImpl(
'https://places.googleapis.com/v1/places:searchNearby', endpoints.nearby ||
'https://places.googleapis.com/v1/places:searchNearby',
{ {
method: 'POST', method: 'POST',
redirect: 'error',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey, 'X-Goog-Api-Key': apiKey,
@@ -214,10 +218,12 @@ export function googlePlacesContextProxy({
); );
try { try {
const response = await fetch( const response = await fetchImpl(
'https://places.googleapis.com/v1/places:searchText', endpoints.textSearch ||
'https://places.googleapis.com/v1/places:searchText',
{ {
method: 'POST', method: 'POST',
redirect: 'error',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey, 'X-Goog-Api-Key': apiKey,
+12 -5
View File
@@ -9,8 +9,6 @@ import {
/** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */ /** OSM routing (FOSSGIS OSRM) cache: profile|coords -> { payload, cachedAt }. */
const ROUTE_CACHE_MS = 600000; const ROUTE_CACHE_MS = 600000;
const _routeCache = new Map();
/** Hard cap on the OSRM route response we will buffer. */ /** Hard cap on the OSRM route response we will buffer. */
const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB const ROUTE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB
@@ -25,7 +23,12 @@ const _routeRateLimiter = makeRateLimiter({
globalMax: 200, 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). // Real OSM routing via the public FOSSGIS OSRM servers (foot/car/bike).
// GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...] // GET /api/route?profile=foot|car|bike&coords=lon,lat;lon,lat[;...]
middlewares.use('/api/route', async (req, res) => { middlewares.use('/api/route', async (req, res) => {
@@ -96,13 +99,17 @@ export function installRouteMiddleware(middlewares) {
res.end(JSON.stringify(cached.payload)); res.end(JSON.stringify(cached.payload));
return; 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 controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000); const timer = setTimeout(() => controller.abort(), 12000);
let osrm; let osrm;
try { try {
const upstreamRes = await fetch(upstream, { const upstreamRes = await fetchImpl(upstream, {
signal: controller.signal, signal: controller.signal,
redirect: 'error',
headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, headers: { 'User-Agent': 'gods-eye-view/dev (local)' },
}); });
if (!upstreamRes.ok) return fail('no route found'); if (!upstreamRes.ok) return fail('no route found');
+1
View File
@@ -4,3 +4,4 @@ export {
} from './regional/briefing.js'; } from './regional/briefing.js';
export { weatherEffectsProxy } from './regional/weather-effects.js'; export { weatherEffectsProxy } from './regional/weather-effects.js';
export { validRegionalPoint } from './regional/query.js'; export { validRegionalPoint } from './regional/query.js';
export { createRegionalPlaceProvider } from './regional/place.js';
+20 -20
View File
@@ -14,33 +14,33 @@ const REGIONAL_BRIEF_STALE_MS = 60 * 60_000;
const REGIONAL_BRIEF_MAX_CACHE = 120; 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. */ /** True when at least one regional source produced usable data. */
function regionalBriefHasAnySource({ place, weather, news } = {}) { function regionalBriefHasAnySource({ place, weather, news } = {}) {
return Boolean(place || weather || (news && news.status !== 'unavailable')); 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) { async function refresh(point, key) {
const [placeResult, weatherResult] = await Promise.allSettled([ const [placeResult, weatherResult] = await Promise.allSettled([
fetchRegionalPlace(point), placeProvider(point),
fetchRegionalWeather(point), fetchRegionalWeather(point),
]); ]);
const place = placeResult.status === 'fulfilled' ? placeResult.value : null; const place = placeResult.status === 'fulfilled' ? placeResult.value : null;
+30 -25
View File
@@ -1,37 +1,42 @@
import { fetchRegionalJson } from './http.js'; import { fetchRegionalJson } from './http.js';
import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.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) { function fetchRegionalPlace(point) {
const task = _nominatimQueue.then(async () => { const task = _nominatimQueue.then(async () => {
const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt)); const waitMs = Math.max(0, 1100 - (Date.now() - _nominatimLastRequestAt));
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
_nominatimLastRequestAt = Date.now(); _nominatimLastRequestAt = Date.now();
const params = new URLSearchParams({ const params = new URLSearchParams({
format: 'jsonv2', format: 'jsonv2',
lat: point.latitude.toFixed(5), lat: point.latitude.toFixed(5),
lon: point.longitude.toFixed(5), lon: point.longitude.toFixed(5),
zoom: '10', zoom: '10',
addressdetails: '1', addressdetails: '1',
'accept-language': 'en', 'accept-language': 'en',
}); });
const payload = await fetchRegionalJson( const payload = await requestJson(`${endpoint}?${params}`, {
`https://nominatim.openstreetmap.org/reverse?${params}`,
{
headers: { headers: {
'User-Agent': 'User-Agent':
'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)', 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)',
Referer: 'https://github.com/bilawalsidhu/gods-eye-view', Referer: 'https://github.com/bilawalsidhu/gods-eye-view',
}, },
}, });
); return normalizeRegionalPlace(payload);
return normalizeRegionalPlace(payload); });
}); _nominatimQueue = task.catch(() => null);
_nominatimQueue = task.catch(() => null); return task;
return task; }
return fetchRegionalPlace;
} }
export { fetchRegionalPlace }; export const fetchRegionalPlace = createRegionalPlaceProvider();
+7 -21
View File
@@ -1,3 +1,4 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js'; import { isRateLimitedOutcome, resolveAnnotationTarget } from './annotationResolver.js';
@@ -94,7 +95,7 @@ export function normalizeTargetKey(target) {
} }
export function createAnnotationEngine({ export function createAnnotationEngine({
placeSearch, placeSearch = defaultGeospatial,
viewer, viewer,
renderer, renderer,
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS, outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
@@ -418,7 +419,7 @@ export function createAnnotationEngine({
} }
// Real street-following route (OSM/OSRM), mode-aware. // Real street-following route (OSM/OSRM), mode-aware.
const mode = normalizeMode(spec.mode); 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) { if (routed) {
return { return {
path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })), path: routed.geometry.map(([lon, lat]) => ({ lon, lat, height: 0 })),
@@ -1101,25 +1102,10 @@ function normalizeMode(m) {
return 'foot'; return 'foot';
} }
/** Fetch a real street-following route from the /api/route proxy (OSM/OSRM). */ /** Route failure remains an explicitly labelled direct line in the caller. */
async function fetchRoute(coordPairs, mode, externalSignal) { async function fetchRoute(coordPairs, mode, signal, service) {
const controller = new AbortController(); try { return await service.route?.(coordPairs, mode, { signal }) || null; }
const onAbort = () => controller.abort(); catch { return null; }
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;
} }
function greatCircleM(a, b) { function greatCircleM(a, b) {
+12 -15
View File
@@ -1,3 +1,4 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
import { lookupNaturalRegionOutline, findNaturalRegion } from '../data/naturalEarthRegions.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: // 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. // 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')) { 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) { if (placeHit) {
trace.places = `${placeHit.lat.toFixed(5)},${placeHit.lon.toFixed(5)}`; trace.places = `${placeHit.lat.toFixed(5)},${placeHit.lon.toFixed(5)}`;
if (placeHit.distanceM <= PLACES_MAX_DISTANCE_M) { if (placeHit.distanceM <= PLACES_MAX_DISTANCE_M) {
@@ -163,7 +164,7 @@ export async function resolveAnnotationTarget({
geocodeFar = true; geocodeFar = true;
} }
if (geocodeFar) { 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) { if (placeHit && placeHit.distanceM <= PLACES_MAX_DISTANCE_M) {
lat = placeHit.lat; lat = placeHit.lat;
lon = placeHit.lon; 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 * 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<null | { lat:number, lon:number, label:string|null, distanceM:number, * @returns {Promise<null | { lat:number, lon:number, label:string|null, distanceM:number,
* viewport:{low:{latitude:number,longitude:number},high:{latitude:number,longitude:number}}|null }>} * viewport:{low:{latitude:number,longitude:number},high:{latitude:number,longitude:number}}|null }>}
*/ */
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(); const q = String(query || '').trim();
if (!q || !Number.isFinite(centerLat) || !Number.isFinite(centerLon)) return null; 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); const cached = cacheRead(placesCache, cacheKey);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const params = new URLSearchParams({
q,
lat: String(centerLat),
lon: String(centerLon),
radiusM: String(radiusM),
});
try { try {
const response = await fetch(`/api/google/text-search?${params}`, { signal }); const data = { places: await service.textSearch?.(q, {
if (!response.ok) { negCache(placesCache, cacheKey, signal, false); return null; } // transient latitude: centerLat, longitude: centerLon, radiusM,
const data = await response.json(); }, { signal }) };
const hit = Array.isArray(data?.places) const hit = Array.isArray(data?.places)
? data.places.find((p) => Number.isFinite(p?.latitude) && Number.isFinite(p?.longitude)) ? data.places.find((p) => Number.isFinite(p?.latitude) && Number.isFinite(p?.longitude))
: null; : null;
@@ -1788,13 +1785,13 @@ export function viewportBias(viewer) {
* plain geocode path. Returns the Places hit * plain geocode path. Returns the Places hit
* ({ lat, lon, label, types, viewport, distanceM, … }) or null. * ({ 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); const center = pickWorldFromScreen(viewer, 0.5, 0.5) || viewportProximity(viewer);
if (!center) return null; if (!center) return null;
const geocodeFar = !geocoded const geocodeFar = !geocoded
|| approximateDistanceM(center.lat, center.lon, geocoded.lat, geocoded.lon) / 1000 > MIN_DRIFT_FLOOR_KM; || approximateDistanceM(center.lat, center.lon, geocoded.lat, geocoded.lon) / 1000 > MIN_DRIFT_FLOOR_KM;
if (!geocodeFar) return null; 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; return (hit && hit.distanceM <= PLACES_MAX_DISTANCE_M) ? hit : null;
} }
+3 -2
View File
@@ -66,7 +66,8 @@ export class IntelHUD {
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation. * camera telemetry and coordinate derivation.
*/ */
constructor(viewer) { constructor(viewer, { placeSearch } = {}) {
this.placeSearch = placeSearch;
this.viewer = viewer; this.viewer = viewer;
this._visible = false; this._visible = false;
this._autoMode = true; // auto show/hide based on style this._autoMode = true; // auto show/hide based on style
@@ -700,7 +701,7 @@ export class IntelHUD {
} }
async _summaryContext() { async _summaryContext() {
const labels = await getBasemapLabelContext(this.viewer); const labels = await getBasemapLabelContext(this.viewer, this.placeSearch);
const enabledLayers = this._dataManager?.getAll?.() const enabledLayers = this._dataManager?.getAll?.()
?.filter((layer) => layer.enabled) ?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || []; .map((layer) => layer.name) || [];
+21 -7
View File
@@ -199,9 +199,9 @@ export function normalizePhotonFeature(feature) {
*/ */
export function photonSearchUrl( export function photonSearchUrl(
query, 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('q', String(query ?? ''));
url.searchParams.set('limit', String(limit)); url.searchParams.set('limit', String(limit));
@@ -296,13 +296,19 @@ function trimPhotonCache(cache) {
*/ */
export async function geocodeKeylessWithOutcome( export async function geocodeKeylessWithOutcome(
query, query,
{ bias = null, fetchImpl = fetch, signal, cache = photonCache } = {}, {
bias = null,
fetchImpl = fetch,
signal,
cache = photonCache,
endpoint = PHOTON_ENDPOINT,
} = {},
) { ) {
signal?.throwIfAborted(); signal?.throwIfAborted();
const trimmed = String(query ?? '').trim(); const trimmed = String(query ?? '').trim();
if (!trimmed) return { place: null, answered: true }; 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. // Only answered outcomes are memoised, so a hit is always an answer.
const cached = cache.get(memoKey); const cached = cache.get(memoKey);
if (cached && cached.expires > Date.now()) if (cached && cached.expires > Date.now())
@@ -341,14 +347,16 @@ export async function geocodeKeylessWithOutcome(
let feature = null; let feature = null;
let everyAskAnswered = true; let everyAskAnswered = true;
if (bias) { if (bias) {
const biased = await ask(photonSearchUrl(trimmed, { bias })); const biased = await ask(photonSearchUrl(trimmed, { bias, endpoint }));
if (biased === null) everyAskAnswered = false; if (biased === null) everyAskAnswered = false;
else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); else feature = selectPhotonFeature(biased, normalizeToponym(trimmed));
} }
signal?.throwIfAborted(); signal?.throwIfAborted();
if (!feature) { if (!feature) {
const anywhere = await ask(photonSearchUrl(trimmed, { bias: null })); const anywhere = await ask(
photonSearchUrl(trimmed, { bias: null, endpoint }),
);
if (anywhere === null) everyAskAnswered = false; if (anywhere === null) everyAskAnswered = false;
else { else {
// "Hoan Kiem Lake, Hanoi" names a place and then the region holding it, the // "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. */ /** Construct an independent Photon provider with a bounded query cache. */
export function createPhotonGeocoder({ export function createPhotonGeocoder({
fetchImpl = (...args) => fetch(...args), fetchImpl = (...args) => fetch(...args),
endpoint = PHOTON_ENDPOINT,
} = {}) { } = {}) {
const cache = new Map(); const cache = new Map();
return { return {
geocode: (query, options = {}) => geocode: (query, options = {}) =>
geocodeKeylessWithOutcome(query, { ...options, fetchImpl, cache }), geocodeKeylessWithOutcome(query, {
...options,
fetchImpl,
cache,
endpoint,
}),
}; };
} }
+3 -1
View File
@@ -314,7 +314,9 @@ test('forward geocoding is composed once and consumers do not call a source dire
assert.match(source, /placeSearch\.geocode\(/); assert.match(source, /placeSearch\.geocode\(/);
assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/); 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, /createGoogleGeocoder/);
assert.match(setup, /createPhotonGeocoder/); assert.match(setup, /createPhotonGeocoder/);
}); });
+54
View File
@@ -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__,
});
+88
View File
@@ -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 };
},
};
}
+239
View File
@@ -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');
});
+132
View File
@@ -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;
},
};
}
+6
View File
@@ -1,3 +1,9 @@
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js'; export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
export { createGoogleGeocoder, normalizeGooglePlace } from './google.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 { createPhotonGeocoder } from '../keylessGeocoder.js';
export { createDefaultPlaceSearch } from './defaults.js';
+2
View File
@@ -12,6 +12,7 @@ let constructed = false;
export function createStandaloneApplication({ export function createStandaloneApplication({
googleApiKey, googleApiKey,
cesiumToken, cesiumToken,
geospatial = {},
allowQaRegistration = false, allowQaRegistration = false,
}) { }) {
if (constructed) if (constructed)
@@ -23,6 +24,7 @@ export function createStandaloneApplication({
return createApplication({ return createApplication({
createScene: (context) => { createScene: (context) => {
placeSearch = createStandalonePlaceSearch({ placeSearch = createStandalonePlaceSearch({
...geospatial,
resolveApiKey: () => googleApiKey, resolveApiKey: () => googleApiKey,
signal: context.signal, signal: context.signal,
}); });
+1 -32
View File
@@ -1,32 +1 @@
import { export { createDefaultPlaceSearch as createStandalonePlaceSearch } from '../search/defaults.js';
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 }),
],
});
}
+1 -1
View File
@@ -293,7 +293,7 @@ export class StyleManager {
this._orbitIndicator = null; this._orbitIndicator = null;
// Intel HUD // Intel HUD
this.hud = new IntelHUD(viewer); this.hud = new IntelHUD(viewer, { placeSearch });
this._recording.hud = this.hud; this._recording.hud = this.hud;
this._cockpitVisionMode = 'optical'; this._cockpitVisionMode = 'optical';
this._cockpitVisionRestore = null; this._cockpitVisionRestore = null;
+47 -74
View File
@@ -1,3 +1,4 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { CITY_POIS, findPoiByName, flyToGlobeView, flyToLandmark, flyToPOI, flyToPresetLocation, GLOBE_VIEW, searchAndFlyTo } from '../locations.js'; import { CITY_POIS, findPoiByName, flyToGlobeView, flyToLandmark, flyToPOI, flyToPresetLocation, GLOBE_VIEW, searchAndFlyTo } from '../locations.js';
import { import {
@@ -246,10 +247,20 @@ const FRAME_TARGETS = new Map([
['ships', 'ais-live-vessels'], ['ships', 'ais-live-vessels'],
]); ]);
const reverseGeocodeCache = new Map(); const serviceCaches = new WeakMap();
const reverseGeocodeInFlight = new Map(); function cachesFor(service) {
const nearbyPlacesCache = new Map(); service.signal?.throwIfAborted();
const nearbyPlacesInFlight = new Map(); 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 VISIBLE_ENTITY_SHORTLIST = 64;
const BASEMAP_CONTEXT_WAIT_MS = 1500; const BASEMAP_CONTEXT_WAIT_MS = 1500;
const viewTargetCache = new WeakMap(); const viewTargetCache = new WeakMap();
@@ -842,7 +853,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
} }
if (name === 'get_entity_context') { if (name === 'get_entity_context') {
return getEntityContext(viewer, dataManager, styleManager, args); return getEntityContext(viewer, dataManager, styleManager, args, placeSearch);
} }
if (name === 'get_current_view_state') { if (name === 'get_current_view_state') {
@@ -1885,7 +1896,7 @@ function collectTrackedEntities(dataManager) {
return tracked; return tracked;
} }
export async function getBasemapLabelContext(viewer) { export async function getBasemapLabelContext(viewer, service = defaultGeospatial) {
const samples = sampleViewportCartographics(viewer); const samples = sampleViewportCartographics(viewer);
const cameraHeightM = viewer.camera.positionCartographic.height; const cameraHeightM = viewer.camera.positionCartographic.height;
const target = getViewTargetCartographic(viewer); const target = getViewTargetCartographic(viewer);
@@ -1899,15 +1910,15 @@ export async function getBasemapLabelContext(viewer) {
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6)); const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
const longitude = Number(Cesium.Math.toDegrees(target.longitude).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 const viewportPromise = cachedViewportPlaces
? Promise.resolve(cachedViewportPlaces) ? Promise.resolve(cachedViewportPlaces)
: reverseGeocodeViewportSamples(samples, cameraHeightM); : reverseGeocodeViewportSamples(samples, cameraHeightM, service);
const placePromise = shouldReverseGeocode(cameraHeightM) const placePromise = shouldReverseGeocode(cameraHeightM)
? reverseGeocode(latitude, longitude) ? reverseGeocode(latitude, longitude, service)
: Promise.resolve(null); : Promise.resolve(null);
const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM) const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM)
? fetchNearbyPlaces(latitude, longitude, cameraHeightM) ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
: Promise.resolve([]); : Promise.resolve([]);
const [viewportPlaces, place, nearbyPlaces] = await Promise.all([ const [viewportPlaces, place, nearbyPlaces] = await Promise.all([
resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), 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 startedAt = performance.now();
const scope = String(args.scope || 'auto').toLowerCase(); const scope = String(args.scope || 'auto').toLowerCase();
const layerId = normalizeLayerId(args.layerId || args.layer); const layerId = normalizeLayerId(args.layerId || args.layer);
@@ -2389,7 +2400,7 @@ async function getEntityContext(viewer, dataManager, styleManager, args = {}) {
const selected = selectedEntityContext(dataManager); const selected = selectedEntityContext(dataManager);
const cameraHeightM = viewer.camera.positionCartographic.height; const cameraHeightM = viewer.camera.positionCartographic.height;
const viewTarget = getViewTargetCartographic(viewer); 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 selectedWillBeReturned = selected && (scope === 'selected' || scope === 'auto');
const visible = (!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM)) const visible = (!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM))
? visibleEntityContexts(viewer, dataManager, { layerId, limit, target: viewTarget }) ? visibleEntityContexts(viewer, dataManager, { layerId, limit, target: viewTarget })
@@ -2594,9 +2605,9 @@ function insertNearestRecord(records, candidate, limit) {
records.pop(); 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 cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
const basemap = await getBasemapContext(viewer, viewTarget); const basemap = await getBasemapContext(viewer, viewTarget, service);
const enabledLayers = dataManager.getAll() const enabledLayers = dataManager.getAll()
.filter((layer) => layer.enabled) .filter((layer) => layer.enabled)
.map((layer) => ({ .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 target = viewTarget;
const samples = sampleViewportCartographics(viewer); const samples = sampleViewportCartographics(viewer);
const cameraCartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC); const cameraCartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
const cameraHeightM = cameraCartographic.height; const cameraHeightM = cameraCartographic.height;
const viewScale = classifyViewScale(cameraHeightM); const viewScale = classifyViewScale(cameraHeightM);
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM); const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service);
const viewportPlacesPromise = cachedViewportPlaces const viewportPlacesPromise = cachedViewportPlaces
? Promise.resolve(cachedViewportPlaces) ? Promise.resolve(cachedViewportPlaces)
: reverseGeocodeViewportSamples(samples, cameraHeightM); : reverseGeocodeViewportSamples(samples, cameraHeightM, service);
if (!target) { if (!target) {
const viewportPlaces = await resolveWithin( const viewportPlaces = await resolveWithin(
viewportPlacesPromise, viewportPlacesPromise,
@@ -2657,10 +2669,10 @@ async function getBasemapContext(viewer, viewTarget = null) {
? nearbyPlacesCache.get(nearbyCacheKey) ? nearbyPlacesCache.get(nearbyCacheKey)
: null; : null;
const placePromise = shouldReverseGeocode(cameraHeightM) && !cachedPlace const placePromise = shouldReverseGeocode(cameraHeightM) && !cachedPlace
? reverseGeocode(latitude, longitude) ? reverseGeocode(latitude, longitude, service)
: Promise.resolve(cachedPlace); : Promise.resolve(cachedPlace);
const nearbyPlacesPromise = shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces const nearbyPlacesPromise = shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces
? fetchNearbyPlaces(latitude, longitude, cameraHeightM) ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
: Promise.resolve(cachedNearbyPlaces); : Promise.resolve(cachedNearbyPlaces);
const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] = await Promise.all([ const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] = await Promise.all([
resolveWithin(viewportPlacesPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), resolveWithin(viewportPlacesPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces),
@@ -2929,44 +2941,20 @@ function inferCountry(latitude, longitude) {
return region?.name || null; return region?.name || null;
} }
async function reverseGeocode(latitude, longitude) { async function reverseGeocode(latitude, longitude, service) {
const apiKey = window.__GOOGLE_MAPS_API_KEY__; const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service);
if (!apiKey || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
const key = reverseGeocodeKey(latitude, longitude); const key = reverseGeocodeKey(latitude, longitude);
if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key); if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key);
if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key); if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key);
const request = (async () => { const request = (async () => {
try { try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${encodeURIComponent(`${latitude},${longitude}`)}&key=${apiKey}`; const place = await service.reverseGeocode?.(latitude, longitude) || null;
const response = await fetchWithTimeout(url, {}, 5000); if (place) {
const data = await response.json(); reverseGeocodeCache.set(key, place);
if (data.status !== 'OK' || !data.results?.length) { if (reverseGeocodeCache.size > 256) reverseGeocodeCache.delete(reverseGeocodeCache.keys().next().value);
reverseGeocodeCache.set(key, null);
return null;
} }
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; return place;
} catch { } catch {
return null; return null;
@@ -2978,14 +2966,14 @@ async function reverseGeocode(latitude, longitude) {
return request; return request;
} }
async function reverseGeocodeViewportSamples(samples, cameraHeightM) { async function reverseGeocodeViewportSamples(samples, cameraHeightM, service) {
if (!shouldReverseGeocodeViewport(cameraHeightM) || !samples.length) return null; if (!shouldReverseGeocodeViewport(cameraHeightM) || !samples.length) return null;
// At building scale, center geocoding plus Nearby Places is more precise and // At building scale, center geocoding plus Nearby Places is more precise and
// avoids three redundant Google requests. // avoids three redundant Google requests.
if (cameraHeightM <= 10000) return null; if (cameraHeightM <= 10000) return null;
const prioritySamples = [samples[0], samples[1], samples[2]].filter(Boolean); const prioritySamples = [samples[0], samples[1], samples[2]].filter(Boolean);
const places = (await Promise.all(prioritySamples.map(async (sample) => { 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; if (!place) return null;
return { return {
latitude: sample.latitude, latitude: sample.latitude,
@@ -3002,7 +2990,8 @@ async function reverseGeocodeViewportSamples(samples, cameraHeightM) {
return summarizeViewportPlaces(places); 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; if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null;
const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => { const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => {
const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude)); 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 radiusM = nearbyPlacesRadiusM(cameraHeightM);
const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM);
if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey); if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey);
@@ -3042,17 +3032,10 @@ async function fetchNearbyPlaces(latitude, longitude, cameraHeightM) {
const request = (async () => { const request = (async () => {
try { try {
const params = new URLSearchParams({ const places = (await service.nearby?.({ latitude, longitude, radiusM }) || [])
lat: String(latitude), .filter(place => place?.name).slice(0, 12);
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)
: [];
nearbyPlacesCache.set(cacheKey, places); nearbyPlacesCache.set(cacheKey, places);
if (nearbyPlacesCache.size > 256) nearbyPlacesCache.delete(nearbyPlacesCache.keys().next().value);
return places; return places;
} catch { } catch {
return []; return [];
@@ -3089,16 +3072,6 @@ function sanitizeLabel(value) {
return out.replace(/\s+/g, ' ').trim().slice(0, 120); 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) { function reverseGeocodeKey(latitude, longitude) {
return `${latitude.toFixed(4)},${longitude.toFixed(4)}`; return `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
} }