refactor: compose configurable geocoding and route services

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

No files matched your search

+47 -74
View File
@@ -1,3 +1,4 @@
import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium';
import { CITY_POIS, findPoiByName, flyToGlobeView, flyToLandmark, flyToPOI, flyToPresetLocation, GLOBE_VIEW, searchAndFlyTo } from '../locations.js';
import {
@@ -246,10 +247,20 @@ const FRAME_TARGETS = new Map([
['ships', 'ais-live-vessels'],
]);
const reverseGeocodeCache = new Map();
const reverseGeocodeInFlight = new Map();
const nearbyPlacesCache = new Map();
const nearbyPlacesInFlight = new Map();
const serviceCaches = new WeakMap();
function cachesFor(service) {
service.signal?.throwIfAborted();
let caches = serviceCaches.get(service);
if (!caches) {
caches = { reverseGeocodeCache: new Map(), reverseGeocodeInFlight: new Map(),
nearbyPlacesCache: new Map(), nearbyPlacesInFlight: new Map() };
serviceCaches.set(service, caches);
service.signal?.addEventListener('abort', () => {
for (const cache of Object.values(caches)) cache.clear();
}, { once: true });
}
return caches;
}
const VISIBLE_ENTITY_SHORTLIST = 64;
const BASEMAP_CONTEXT_WAIT_MS = 1500;
const viewTargetCache = new WeakMap();
@@ -842,7 +853,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
}
if (name === 'get_entity_context') {
return getEntityContext(viewer, dataManager, styleManager, args);
return getEntityContext(viewer, dataManager, styleManager, args, placeSearch);
}
if (name === 'get_current_view_state') {
@@ -1885,7 +1896,7 @@ function collectTrackedEntities(dataManager) {
return tracked;
}
export async function getBasemapLabelContext(viewer) {
export async function getBasemapLabelContext(viewer, service = defaultGeospatial) {
const samples = sampleViewportCartographics(viewer);
const cameraHeightM = viewer.camera.positionCartographic.height;
const target = getViewTargetCartographic(viewer);
@@ -1899,15 +1910,15 @@ export async function getBasemapLabelContext(viewer) {
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6));
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM);
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service);
const viewportPromise = cachedViewportPlaces
? Promise.resolve(cachedViewportPlaces)
: reverseGeocodeViewportSamples(samples, cameraHeightM);
: reverseGeocodeViewportSamples(samples, cameraHeightM, service);
const placePromise = shouldReverseGeocode(cameraHeightM)
? reverseGeocode(latitude, longitude)
? reverseGeocode(latitude, longitude, service)
: Promise.resolve(null);
const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM)
? fetchNearbyPlaces(latitude, longitude, cameraHeightM)
? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
: Promise.resolve([]);
const [viewportPlaces, place, nearbyPlaces] = await Promise.all([
resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces),
@@ -2381,7 +2392,7 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector =
};
}
async function getEntityContext(viewer, dataManager, styleManager, args = {}) {
async function getEntityContext(viewer, dataManager, styleManager, args = {}, service = defaultGeospatial) {
const startedAt = performance.now();
const scope = String(args.scope || 'auto').toLowerCase();
const layerId = normalizeLayerId(args.layerId || args.layer);
@@ -2389,7 +2400,7 @@ async function getEntityContext(viewer, dataManager, styleManager, args = {}) {
const selected = selectedEntityContext(dataManager);
const cameraHeightM = viewer.camera.positionCartographic.height;
const viewTarget = getViewTargetCartographic(viewer);
const scenePromise = getSceneContext(viewer, styleManager, dataManager, viewTarget);
const scenePromise = getSceneContext(viewer, styleManager, dataManager, viewTarget, service);
const selectedWillBeReturned = selected && (scope === 'selected' || scope === 'auto');
const visible = (!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM))
? visibleEntityContexts(viewer, dataManager, { layerId, limit, target: viewTarget })
@@ -2594,9 +2605,9 @@ function insertNearestRecord(records, candidate, limit) {
records.pop();
}
async function getSceneContext(viewer, styleManager, dataManager, viewTarget = null) {
async function getSceneContext(viewer, styleManager, dataManager, viewTarget = null, service = defaultGeospatial) {
const cartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
const basemap = await getBasemapContext(viewer, viewTarget);
const basemap = await getBasemapContext(viewer, viewTarget, service);
const enabledLayers = dataManager.getAll()
.filter((layer) => layer.enabled)
.map((layer) => ({
@@ -2617,16 +2628,17 @@ async function getSceneContext(viewer, styleManager, dataManager, viewTarget = n
};
}
async function getBasemapContext(viewer, viewTarget = null) {
async function getBasemapContext(viewer, viewTarget = null, service = defaultGeospatial) {
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const target = viewTarget;
const samples = sampleViewportCartographics(viewer);
const cameraCartographic = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
const cameraHeightM = cameraCartographic.height;
const viewScale = classifyViewScale(cameraHeightM);
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM);
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service);
const viewportPlacesPromise = cachedViewportPlaces
? Promise.resolve(cachedViewportPlaces)
: reverseGeocodeViewportSamples(samples, cameraHeightM);
: reverseGeocodeViewportSamples(samples, cameraHeightM, service);
if (!target) {
const viewportPlaces = await resolveWithin(
viewportPlacesPromise,
@@ -2657,10 +2669,10 @@ async function getBasemapContext(viewer, viewTarget = null) {
? nearbyPlacesCache.get(nearbyCacheKey)
: null;
const placePromise = shouldReverseGeocode(cameraHeightM) && !cachedPlace
? reverseGeocode(latitude, longitude)
? reverseGeocode(latitude, longitude, service)
: Promise.resolve(cachedPlace);
const nearbyPlacesPromise = shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces
? fetchNearbyPlaces(latitude, longitude, cameraHeightM)
? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
: Promise.resolve(cachedNearbyPlaces);
const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] = await Promise.all([
resolveWithin(viewportPlacesPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces),
@@ -2929,44 +2941,20 @@ function inferCountry(latitude, longitude) {
return region?.name || null;
}
async function reverseGeocode(latitude, longitude) {
const apiKey = window.__GOOGLE_MAPS_API_KEY__;
if (!apiKey || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
async function reverseGeocode(latitude, longitude, service) {
const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
const key = reverseGeocodeKey(latitude, longitude);
if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key);
if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key);
const request = (async () => {
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${encodeURIComponent(`${latitude},${longitude}`)}&key=${apiKey}`;
const response = await fetchWithTimeout(url, {}, 5000);
const data = await response.json();
if (data.status !== 'OK' || !data.results?.length) {
reverseGeocodeCache.set(key, null);
return null;
const place = await service.reverseGeocode?.(latitude, longitude) || null;
if (place) {
reverseGeocodeCache.set(key, place);
if (reverseGeocodeCache.size > 256) reverseGeocodeCache.delete(reverseGeocodeCache.keys().next().value);
}
const result = data.results[0];
const relevantResults = data.results.slice(0, 12);
const components = Array.isArray(result.address_components) ? result.address_components : [];
const component = (type) => components.find((item) => item.types?.includes(type))?.long_name || null;
const labels = uniqueStrings(relevantResults.map((item) => item.formatted_address)).slice(0, 12);
const streetLabels = uniqueStrings(relevantResults.flatMap((item) => {
const itemComponents = Array.isArray(item.address_components) ? item.address_components : [];
return itemComponents
.filter((entry) => entry.types?.includes('route'))
.map((entry) => entry.long_name);
})).slice(0, 12);
const place = {
formattedAddress: result.formatted_address || null,
locality: component('locality') || component('postal_town') || component('administrative_area_level_2'),
region: component('administrative_area_level_1'),
country: component('country'),
types: result.types || [],
labels,
streetLabels,
};
reverseGeocodeCache.set(key, place);
return place;
} catch {
return null;
@@ -2978,14 +2966,14 @@ async function reverseGeocode(latitude, longitude) {
return request;
}
async function reverseGeocodeViewportSamples(samples, cameraHeightM) {
async function reverseGeocodeViewportSamples(samples, cameraHeightM, service) {
if (!shouldReverseGeocodeViewport(cameraHeightM) || !samples.length) return null;
// At building scale, center geocoding plus Nearby Places is more precise and
// avoids three redundant Google requests.
if (cameraHeightM <= 10000) return null;
const prioritySamples = [samples[0], samples[1], samples[2]].filter(Boolean);
const places = (await Promise.all(prioritySamples.map(async (sample) => {
const place = await reverseGeocode(sample.latitude, sample.longitude);
const place = await reverseGeocode(sample.latitude, sample.longitude, service);
if (!place) return null;
return {
latitude: sample.latitude,
@@ -3002,7 +2990,8 @@ async function reverseGeocodeViewportSamples(samples, cameraHeightM) {
return summarizeViewportPlaces(places);
}
function viewportPlacesFromCache(samples, cameraHeightM) {
function viewportPlacesFromCache(samples, cameraHeightM, service) {
const { reverseGeocodeCache } = cachesFor(service);
if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null;
const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => {
const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude));
@@ -3034,7 +3023,8 @@ function summarizeViewportPlaces(places) {
};
}
async function fetchNearbyPlaces(latitude, longitude, cameraHeightM) {
async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) {
const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service);
const radiusM = nearbyPlacesRadiusM(cameraHeightM);
const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM);
if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey);
@@ -3042,17 +3032,10 @@ async function fetchNearbyPlaces(latitude, longitude, cameraHeightM) {
const request = (async () => {
try {
const params = new URLSearchParams({
lat: String(latitude),
lon: String(longitude),
radiusM: String(radiusM),
});
const response = await fetchWithTimeout(`/api/google/nearby-places?${params}`, {}, 5000);
const data = await response.json().catch(() => null);
const places = response.ok && Array.isArray(data?.places)
? data.places.filter((place) => place?.name).slice(0, 12)
: [];
const places = (await service.nearby?.({ latitude, longitude, radiusM }) || [])
.filter(place => place?.name).slice(0, 12);
nearbyPlacesCache.set(cacheKey, places);
if (nearbyPlacesCache.size > 256) nearbyPlacesCache.delete(nearbyPlacesCache.keys().next().value);
return places;
} catch {
return [];
@@ -3089,16 +3072,6 @@ function sanitizeLabel(value) {
return out.replace(/\s+/g, ' ').trim().slice(0, 120);
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
window.clearTimeout(timeout);
}
}
function reverseGeocodeKey(latitude, longitude) {
return `${latitude.toFixed(4)},${longitude.toFixed(4)}`;
}