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

+5 -4
View File
@@ -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,
+3 -2
View File
@@ -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([{
+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;
+192 -1
View File
@@ -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 });
}
+2 -2
View File
@@ -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;
}