feat(search): compose Google and Photon through an explicit provider service

This commit is contained in:
Sameh Khamis committed 2026-09-12 15:38:43 -07:00
commit 57d7f9fc23
30 files changed
+1621 -151

No files matched your search

+15 -65
View File
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
import { lookupNaturalRegionOutline, findNaturalRegion } from '../data/naturalEarthRegions.js';
import { registerDynamicCredit, NATURAL_EARTH_CREDIT } from '../data/dataCredits.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/**
@@ -19,7 +20,6 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
* raw geometry ring instead of a bounding box, which is what an outline needs.
*/
const geocodeCache = new Map();
const footprintCache = new Map();
const monumentCache = new Map(); // OSM monuments/memorials near a view center, keyed by rounded coord
const enclosingAreaCache = new Map(); // smallest enclosing named non-building polygon, keyed by ~1km coord bucket
@@ -90,6 +90,7 @@ function linkAbort(controller, externalSignal) {
* }>}
*/
export async function resolveAnnotationTarget({
placeSearch = unavailablePlaceSearch,
viewer, target, latitude, longitude, footprint = false, intent = 'the_thing',
entityKind = null, labelHint = null, deferFootprint = false, screenX, screenY, signal,
}) {
@@ -140,7 +141,7 @@ export async function resolveAnnotationTarget({
}
}
if (source !== 'places') {
const geocoded = await geocodePlace(query, viewportBias(viewer), signal);
const geocoded = await geocodePlace(query, viewportBias(viewer), signal, placeSearch);
if (geocoded) {
lat = geocoded.lat;
lon = geocoded.lon;
@@ -602,50 +603,16 @@ function ringAreaM2(ring) {
return Math.abs(area) / 2;
}
/**
* Forward-geocode a place name via Google Geocoding, biased to the current
* viewport so "the marina" resolves near where the user is looking.
*/
async function geocodePlace(query, biasRect, signal) {
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const cacheKey = `${query.toLowerCase()}|${biasRect || ''}`;
const cached = cacheRead(geocodeCache, cacheKey);
if (cached !== undefined) return cached;
let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`;
if (biasRect) url += `&bounds=${biasRect}`;
try {
const response = await fetch(url, { signal });
const data = await response.json();
if (data.status !== 'OK' || !data.results?.length) {
// ZERO_RESULTS is a definitive not-found (cacheable); OVER_QUERY_LIMIT /
// REQUEST_DENIED / UNKNOWN_ERROR are transient → don't poison the cache.
negCache(geocodeCache, cacheKey, signal, data?.status === 'ZERO_RESULTS');
return null;
}
const result = data.results[0];
const place = {
lat: result.geometry.location.lat,
lon: result.geometry.location.lng,
label: shortLabel(result.formatted_address),
// The CANONICAL name of the resolved feature (e.g. "Mission District",
// "Texas State Capitol") — used for OSM name-matching instead of the raw
// utterance, so incidental tokens ("...Texas", "...Austin") can't win.
primaryName: extractPrimaryName(result),
types: result.types || [],
// Geocode viewport (sw/ne box framing the feature), normalized to the Places
// low/high shape — sizes grounds discs and flyTo framing for geocode anchors.
viewport: normalizeGeocodeViewport(result.geometry?.bounds || result.geometry?.viewport),
};
cacheWrite(geocodeCache, cacheKey, place);
return place;
} catch {
negCache(geocodeCache, cacheKey, signal, false); // network/abort — transient
return null;
}
/** Resolve a name through the supplied service; geometry selection stays here. */
async function geocodePlace(query, biasRect, signal, placeSearch) {
const { place } = await placeSearch.geocode(query, { bias: biasRect, signal });
signal?.throwIfAborted();
if (!place) return null;
return {
lat: place.lat, lon: place.lng, label: shortLabel(place.label),
primaryName: place.name || null, types: place.types,
viewport: normalizeGeocodeViewport(place.viewport),
};
}
/** Geocoding returns {southwest:{lat,lng},northeast:{lat,lng}}; normalize to the Places
@@ -716,23 +683,6 @@ async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) {
}
}
/**
* The canonical name of the geocoded feature: the address component whose own
* types match the result's feature type (e.g. the `neighborhood` component for a
* neighborhood result). Address-only responses may identify a landmark's city
* without naming the landmark itself. Return null in that case so the caller
* keeps the requested name instead of matching a nearby building in that city.
*/
function extractPrimaryName(result) {
const resultTypes = new Set((result.types || []).map((t) => String(t).toLowerCase()));
const comps = Array.isArray(result.address_components) ? result.address_components : [];
for (const c of comps) {
const ct = (c.types || []).map((t) => String(t).toLowerCase());
if (ct.some((t) => t !== 'political' && resultTypes.has(t))) return c.long_name;
}
return null;
}
/**
* Map the Google geocode `types` to a resolution SCOPE so we fetch the right
* OSM feature at the right size. Country-agnostic: scope only selects the query
@@ -1936,7 +1886,7 @@ function shortLabel(formattedAddress) {
* @param {AbortSignal} [signal]
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
*/
export async function resolveRegionRingForQuery(name, signal) {
export async function resolveRegionRingForQuery(name, signal, placeSearch = unavailablePlaceSearch) {
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
@@ -1946,7 +1896,7 @@ export async function resolveRegionRingForQuery(name, signal) {
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
}
const geo = await geocodePlace(q, null, signal).catch(() => null);
const geo = await geocodePlace(q, null, signal, placeSearch).catch(() => null);
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;