mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor: compose configurable geocoding and route services
This commit is contained in:
1 parent
3b91feffdc
commit
8b36c28b06
25 files changed
+764
-211
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": []
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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__,
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 +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';
|
||||
@@ -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
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user