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