mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +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
@@ -94,6 +94,7 @@ export function normalizeTargetKey(target) {
|
||||
}
|
||||
|
||||
export function createAnnotationEngine({
|
||||
placeSearch,
|
||||
viewer,
|
||||
renderer,
|
||||
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
|
||||
@@ -393,7 +394,7 @@ export function createAnnotationEngine({
|
||||
for (const pt of points) {
|
||||
const name = pt.target ?? pt.name ?? null;
|
||||
const r = await resolveTarget({
|
||||
viewer,
|
||||
placeSearch, viewer,
|
||||
target: name,
|
||||
latitude: pt.latitude,
|
||||
longitude: pt.longitude,
|
||||
@@ -436,7 +437,7 @@ export function createAnnotationEngine({
|
||||
}
|
||||
if (type === 'arrow') {
|
||||
const from = await resolveTarget({
|
||||
viewer,
|
||||
placeSearch, viewer,
|
||||
target: spec.target,
|
||||
latitude: spec.latitude,
|
||||
longitude: spec.longitude,
|
||||
@@ -446,7 +447,7 @@ export function createAnnotationEngine({
|
||||
signal,
|
||||
});
|
||||
const to = await resolveTarget({
|
||||
viewer,
|
||||
placeSearch, viewer,
|
||||
target: spec.toTarget,
|
||||
latitude: spec.toLatitude,
|
||||
longitude: spec.toLongitude,
|
||||
@@ -471,7 +472,7 @@ export function createAnnotationEngine({
|
||||
}
|
||||
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
|
||||
return resolveTarget({
|
||||
viewer,
|
||||
placeSearch, viewer,
|
||||
target: spec.target,
|
||||
latitude: spec.latitude,
|
||||
longitude: spec.longitude,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
|
||||
//
|
||||
// Locks the 2026-07-21 field-test fixes:
|
||||
@@ -385,7 +386,7 @@ test('targetKey: empty / absent targets stay null (coord and pixel specs never p
|
||||
assert.equal(normalizeTargetKey(undefined), null);
|
||||
});
|
||||
|
||||
test('outline upgrade updates the rendered element in place without remove/add', async (t) => {
|
||||
test('outline upgrade updates the rendered element in place without remove/add', { timeout: 5000 }, async (t) => {
|
||||
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -466,7 +467,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
|
||||
sync() {},
|
||||
};
|
||||
const viewer = {};
|
||||
const engine = createAnnotationEngine({ viewer, renderer });
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
|
||||
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
|
||||
|
||||
const result = await engine.annotate([{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
|
||||
import { createPlaceSearch } from '../search/placeSearch.js';
|
||||
// Footprint-selection contract tests — pure fixtures, no network, no browser.
|
||||
//
|
||||
// Locks the field-test-7 monument fix (docs/field-test-rootcause-2026-06-30.md §1):
|
||||
@@ -12,7 +14,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveAnnotationTarget,
|
||||
resolveAnnotationTarget as resolveTarget,
|
||||
selectFootprint,
|
||||
refineScope,
|
||||
isGroundsLikeAsk,
|
||||
@@ -431,3 +433,192 @@ test('a genuine geocoded feature name still canonicalizes an alternate user name
|
||||
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true });
|
||||
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
|
||||
});
|
||||
|
||||
// ── Keyless geocoding ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `geocodePlace` was Google-only: no key returned null, and so did a key whose
|
||||
// Geocoding API is not enabled (Google answers HTTP 200 with REQUEST_DENIED).
|
||||
// Either way the annotation silently failed to place. Both now fall through to
|
||||
// Photon. These drive the second case, because it reaches the SAME fallback
|
||||
// through a running Google branch — the no-key branch cannot be driven from
|
||||
// `node --test`, since the key expression reads `import.meta.env`, which only
|
||||
// Vite defines.
|
||||
|
||||
/** Photon's GeoJSON shape, trimmed to the properties the adapter consumes. */
|
||||
function photonFeature({ name, lat, lon, tags = {}, extent = null, ...rest }) {
|
||||
return {
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: { name, ...tags, ...rest, ...(extent ? { extent } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
const GOOGLE_FOUND_NOTHING = { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
|
||||
|
||||
test('keyless: a key that geocodes to nothing still anchors the annotation', async (t) => {
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [photonFeature({
|
||||
name: 'Lady Bird Lake',
|
||||
lat: 30.25,
|
||||
lon: -97.73,
|
||||
city: 'Austin',
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
tags: { osm_key: 'natural', osm_value: 'water' },
|
||||
// Photon orders extent [west, north, east, south] — a naive [w,s,e,n]
|
||||
// read would invert the box and still look like a valid viewport.
|
||||
extent: [-97.8, 30.28, -97.68, 30.24],
|
||||
})],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: 'Lady Bird Lake',
|
||||
});
|
||||
|
||||
assert.ok(resolved, 'a Google miss must not leave the annotation unplaced');
|
||||
assert.equal(resolved.source, 'geocode');
|
||||
assert.deepEqual([resolved.lat, resolved.lon], [30.25, -97.73]);
|
||||
// The label is shortened the same way a Google `formatted_address` is.
|
||||
assert.equal(resolved.label, 'Lady Bird Lake');
|
||||
// Photon's extent reaches the framing code in the Places `{low,high}` shape,
|
||||
// right way up — this is what sizes a grounds disc and the flyTo box.
|
||||
assert.deepEqual(resolved.viewport, {
|
||||
low: { latitude: 30.24, longitude: -97.8 },
|
||||
high: { latitude: 30.28, longitude: -97.68 },
|
||||
});
|
||||
// Google is asked first and exactly once; one Photon call answers it.
|
||||
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
|
||||
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
|
||||
});
|
||||
|
||||
test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s words', async (t) => {
|
||||
// The reason `primaryName` is carried out of Photon at all. The ask names a
|
||||
// landmark AND its surroundings; the geocoded feature is called just "Tejano
|
||||
// Monument". Score the OSM candidates on the raw utterance and the locality
|
||||
// tokens win — this is the Thompson-Austin bug (field test 7) reached through
|
||||
// the keyless path. Score them on the canonical name and the monument wins.
|
||||
const decoy = squareWay(ANCHOR, -250, -250, 3000, {
|
||||
building: 'yes', tourism: 'hotel', name: 'Texas Capitol Austin Visitor Center',
|
||||
});
|
||||
const monument = squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' });
|
||||
|
||||
installGoogleMocks(t, async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
|
||||
if (href.startsWith('https://photon.komoot.io/')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [photonFeature({
|
||||
name: 'Tejano Monument',
|
||||
lat: ANCHOR.lat,
|
||||
lon: ANCHOR.lon,
|
||||
city: 'Austin',
|
||||
tags: { osm_key: 'historic', osm_value: 'memorial' },
|
||||
})],
|
||||
}),
|
||||
};
|
||||
}
|
||||
assert.equal(href, '/api/overpass');
|
||||
assert.equal(init?.method, 'POST');
|
||||
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ elements: [decoy, monument] }) };
|
||||
});
|
||||
|
||||
const resolved = await resolveAnnotationTarget({
|
||||
viewer: closeViewportViewer(),
|
||||
target: 'the monument near the Texas Capitol, Austin',
|
||||
footprint: true,
|
||||
});
|
||||
|
||||
assert.ok(resolved.ring, 'the monument polygon is monument-scale and exactly named — it must outline');
|
||||
// The anchor re-centres on the chosen polygon, so its position IS the verdict:
|
||||
// the monument sits on the anchor, the decoy 354 m south-west of it.
|
||||
assert.ok(
|
||||
approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon) < 50,
|
||||
`outlined a polygon ${Math.round(approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon))} m from the monument`,
|
||||
);
|
||||
});
|
||||
|
||||
/** Metres between two coordinates — enough precision for a few hundred metres. */
|
||||
function approximateMetres(lat1, lon1, lat2, lon2) {
|
||||
const mLat = 111320;
|
||||
const dy = (lat1 - lat2) * mLat;
|
||||
const dx = (lon1 - lon2) * mLat * Math.cos((lat1 * Math.PI) / 180);
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
test('keyless: a Photon outage is not remembered as "no such place"', async (t) => {
|
||||
// Google answering ZERO_RESULTS is a verdict about GOOGLE. Photon holds
|
||||
// plenty of places Google does not — that asymmetry is why this fallback
|
||||
// exists — so a Google verdict plus a Photon timeout must leave the query
|
||||
// open. Caching it would keep an annotation unplaceable for the rest of the
|
||||
// session, on a network that has since recovered.
|
||||
const photonUp = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [photonFeature({
|
||||
name: 'Waller Creek', lat: 30.2665, lon: -97.7385, city: 'Austin',
|
||||
tags: { osm_key: 'waterway', osm_value: 'stream' },
|
||||
})],
|
||||
}),
|
||||
};
|
||||
let photonReachable = false;
|
||||
const requests = [];
|
||||
installGoogleMocks(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
|
||||
if (!photonReachable) throw new Error('network down');
|
||||
return photonUp;
|
||||
});
|
||||
|
||||
const target = 'Waller Creek';
|
||||
assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null);
|
||||
const duringOutage = requests.length;
|
||||
|
||||
photonReachable = true;
|
||||
const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target });
|
||||
|
||||
assert.ok(retried, 'the same target must resolve once the network is back');
|
||||
assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]);
|
||||
assert.ok(
|
||||
requests.length > duringOutage,
|
||||
'the retry was served from a poisoned cache instead of asking again',
|
||||
);
|
||||
});
|
||||
|
||||
test('a not-found is remembered only when every source consulted gave a verdict', async () => {
|
||||
// The full truth table, because the no-key row is the one the behavioural
|
||||
// tests above cannot reach: `import.meta.env` only exists under Vite, so
|
||||
// `node --test` can never take the branch that skips Google entirely.
|
||||
const cases = [
|
||||
// no key: Photon is the only source, so Photon alone decides
|
||||
[{ googleConsulted: false, photonAnswered: true }, true],
|
||||
[{ googleConsulted: false, photonAnswered: false }, false],
|
||||
// with a key: Google must have said "no such place", AND Photon must have replied
|
||||
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: true }, true],
|
||||
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: false }, false],
|
||||
// Google declined (REQUEST_DENIED / OVER_QUERY_LIMIT) — never a verdict
|
||||
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: true }, false],
|
||||
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: false }, false],
|
||||
];
|
||||
for (const [sources, expected] of cases) {
|
||||
const providers = [sources.googleConsulted ? Boolean(sources.googleSaysNoSuchPlace) : true, sources.photonAnswered]
|
||||
.map((answered) => ({ geocode: async () => ({ place: null, answered }) }));
|
||||
assert.equal((await createPlaceSearch({ providers }).geocode('miss')).answered, expected, JSON.stringify(sources));
|
||||
}
|
||||
// An omitted Google verdict defaults to "did not say no such place".
|
||||
|
||||
});
|
||||
|
||||
function resolveAnnotationTarget(options) {
|
||||
return resolveTarget({ placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }), ...options });
|
||||
}
|
||||
@@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
|
||||
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
|
||||
* and voice tool wiring are identical to the other two branches.
|
||||
*/
|
||||
export function initAnnotations({ viewer, tileset = null }) {
|
||||
export function initAnnotations({ viewer, tileset = null, placeSearch }) {
|
||||
// World-space footprint draping; clamped marks can use the photoreal tiles.
|
||||
if (tileset) {
|
||||
try { tileset.enableCollision = true; } catch { /* older tileset */ }
|
||||
}
|
||||
const renderer = createHybridAnnotationRenderer(viewer);
|
||||
const engine = createAnnotationEngine({ viewer, renderer });
|
||||
const engine = createAnnotationEngine({ viewer, renderer, placeSearch });
|
||||
window.__gevAnnotations = engine;
|
||||
return engine;
|
||||
}
|
||||
Reference in new issue
Block a user