mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(search): compose Google and Photon through an explicit provider service
This commit is contained in:
commit
57d7f9fc23
30 files changed
+1621
-151
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user