refactor: compose configurable geocoding and route services

This commit is contained in:
Sameh Khamis committed 2026-09-13 21:23:13 -07:00
1 parent 3b91feffdc
commit 8b36c28b06
25 files changed
+764 -211

No files matched your search

+7
View File
@@ -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
+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.
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.
+8
View File
@@ -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.
+5 -1
View File
@@ -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"
]
+4 -1
View File
@@ -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": []
},
+2 -2
View File
@@ -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',
+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. */
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,
+12 -5
View File
@@ -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');
+1
View File
@@ -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';
+20 -20
View File
@@ -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;
+30 -25
View File
@@ -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();
+7 -21
View File
@@ -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) {
+12 -15
View File
@@ -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<null | { lat:number, lon:number, label:string|null, distanceM:number,
* 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();
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;
}
+3 -2
View File
@@ -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) || [];
+21 -7
View File
@@ -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,
}),
};
}
+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.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/);
});
+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 { 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';
+2
View File
@@ -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,
});
+1 -32
View File
@@ -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';
+1 -1
View File
@@ -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;
+47 -74
View File
@@ -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)}`;
}