diff --git a/CHANGELOG.md b/CHANGELOG.md index cffb5a7b4..ce777c00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,27 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md - Existing cached refusals are now ignored immediately, including during stale-data fallback. Concurrent identical requests share the same last-good fallback when all mirrors refuse, without duplicating upstream requests. +- A keyless place lookup no longer remembers a network failure as "no such + place". A blip while Photon was answering used to be memoized for the rest of + the session, so the query kept returning not-found from memory on a network + that had since recovered. A miss is now cached only when every source + consulted actually returned a verdict. + +### Added + +- Keyless place search. The LOCATION search box and the `fly_to_location` voice + tool now resolve place names through Photon (komoot, over OpenStreetMap) when + no Google Maps key is configured — previously the lookup threw. Google stays + the primary path and is unchanged when it answers; the fallback also covers a + key whose Geocoding API is not enabled, which Google reports as HTTP 200 with + `REQUEST_DENIED`, so an empty result is the detector rather than an error. +- The same keyless fallback now covers the remaining two place lookups: map + annotations ("annotate the botanical garden") and the Radio layer's + "near \" selection. Radio previously threw without a key, which + surfaced as a failed voice turn rather than as a station it could not place; + annotations silently failed to anchor. Annotation footprints match OSM on the + resolved feature's canonical name, so locality words in the request cannot + pull the outline onto a neighbouring building. - Refresh vulnerable transitive dependencies and update browser/image tooling to Puppeteer 25.10.0 and Sharp 0.35.4. Cesium remains on 1.138.0. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index faa351dca..650d3fe6b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -27,6 +27,7 @@ How to read this: | **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" | | **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) | +| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" + "© OpenStreetMap contributors" | | **OpenStreetMap (Nominatim)** | Reverse-geocoded place label in the cockpit Local Info page | ODbL 1.0 + Nominatim usage policy | "© OpenStreetMap contributors" | | **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data | | **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain | diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 0811dba85..19a2f0fb8 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -169,3 +169,16 @@ accepts an optional `annotationGuidance` paragraph. Neither factory starts acquisition on import. Setup retains its pre-environment-load provenance capture and development-only registration. Package checks enumerate every owned module and reject browser imports of these Node entries. + +## Browser place search + +`gods-eye-view/search` exports an explicit geocoding service and Google/Photon +adapters. The entry owns normalization, bounded caches, deadlines and fallback +sequencing. It imports no application state, environment configuration, rendering +or Node server code. Google transport is supplied by its caller. + +`src/standalone/placeSearch.js` constructs the configured Google request and +keyless Photon fallback. The application passes this service to location +controls, annotation resolution and voice/radio actions. Those consumers retain +framing, landmark recovery, footprint matching and playback decisions. Existing +reverse geocoding and nearby/text-search routes remain separate. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0789e5623..2e4371357 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2494,6 +2494,29 @@ silently demoting every later lookup for the session. - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +### Place-search providers + +Location search/fly-to, annotations and Radio location lookup receive one +`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes +Google first when configured and Photon/OpenStreetMap as fallback, including +Google transport failures or declined requests. The portable `./search` export +provides the service and adapters; it reads no environment or application state. +Existing browser/server key setup is unchanged. + +Providers normalize coordinates, canonical name, label, place types and optional +bounds. Camera framing, nearby landmark recovery and footprint selection remain +in their consumers, including the Capitol identity/containment safeguards. +Radio keeps localized country names in labels rather than station filters. +Reverse geocoding and nearby/text-search endpoints retain their existing behavior. + +Only valid answers and definitive misses enter bounded caches; malformed replies, +HTTP refusals and outages remain retryable. Searches share a 12-second total +deadline, with Photon requests capped at six seconds each. Caller/application +cancellation stops retries and late cache writes. Replacing a location search +cancels the previous lookup. Photon uses a soft proximity bias, up to five +candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +are omitted rather than framing the wrong part of the globe. + ### Map Stack Switcher (June 2026) - `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. diff --git a/package.json b/package.json index 315c4c156..f726046e3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gods-eye-view", "private": true, "version": "0.1.1", - "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", + "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", "author": "Bilawal Sidhu", @@ -117,6 +117,7 @@ }, "./server/standalone/key-setup": { "node": "./server/standalone/key-setup.js" - } + }, + "./search": "./src/search/index.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index be9e3a6ba..97f2fa8d3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -114,5 +114,10 @@ "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", "server/standalone/key-setup.js", - "src/tooling/localServices.test.mjs" + "src/tooling/localServices.test.mjs", + "src/search/index.js", + "src/search/placeSearch.js", + "src/search/google.js", + "src/keylessGeocoder.js", + "src/standalone/placeSearch.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4e44a9f84..f9dd22c93 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -253,5 +253,15 @@ "src/keySetupHardening.mjs" ], "external": [] + }, + "place-search": { + "exports": ["./search"], + "modules": [ + "src/search/index.js", + "src/search/placeSearch.js", + "src/search/google.js", + "src/keylessGeocoder.js" + ], + "external": [] } } diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index d17d6b238..3c964c402 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -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, diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 411ad796b..92909c048 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -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([{ diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index fa557abcc..87b6514ae 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -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; diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index af67a2c0c..452d6997c 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -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 }); +} diff --git a/src/annotations/index.js b/src/annotations/index.js index 1a1d73606..a8047c700 100644 --- a/src/annotations/index.js +++ b/src/annotations/index.js @@ -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; } diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index b35da9e00..009ff6d01 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -73,6 +73,14 @@ export const DATA_CREDITS = [ '© OpenStreetMap contributors ' + '(ODbL 1.0)', }, + { + key: 'photon-geocoder', + html: + 'Keyless place search: ' + + 'Photon (komoot) over ' + + '© OpenStreetMap contributors ' + + '(ODbL 1.0)', + }, { key: 'military-installations-osm', html: diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js new file mode 100644 index 000000000..614fa5adc --- /dev/null +++ b/src/keylessGeocoder.js @@ -0,0 +1,405 @@ +/** Photon/OpenStreetMap place-search adapter. */ +const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/'; + +/** Photon is a courtesy service; fail fast rather than hold the search open. */ +const PHOTON_TIMEOUT_MS = 6000; + +/** Bounded memo — a search box re-issues the same query on every keystroke. */ +const PHOTON_CACHE_MAX = 64; + +/** + * Candidates requested per pass. One is not enough: proximity bias reorders the + * list, so the name actually asked for can sit below a nearer near-miss. + */ +const PHOTON_RESULT_LIMIT = 5; +const photonCache = new Map(); + +/** + * Photon's own coarse class → the Google Place Type `geocodeNavigationMode` + * reads. Only types that function consults are worth emitting; anything else + * falls through to `precise-place`, which is the correct default for a house + * or a POI. + */ +const PHOTON_TYPE_TO_GOOGLE = Object.freeze({ + country: 'country', + state: 'administrative_area_level_1', + county: 'administrative_area_level_2', + city: 'locality', + district: 'sublocality', + locality: 'locality', + street: 'route', +}); + +/** + * OSM `key=value` → Google Place Type. `null` means "this tag is a precise + * place" and, like any other entry, stops the coarse class from being consulted. + * + * Two failure directions matter, and both were found by querying the live + * service rather than by reasoning about OSM tagging: + * + * - Too coarse: a park or lake that arrives without an area type frames at + * building range, which is the exact bug `geocodeNavigationMode`'s area types + * exist to prevent — searching "Ho Guom" put the camera 26 m over the water. + * - Too broad: Photon classifies a town square as `type: 'locality'`, so + * deferring to the coarse class frames Times Square as if it were a city. + * The explicit `null` below is what stops that. + */ +const OSM_TAG_TO_GOOGLE = Object.freeze({ + 'leisure=park': 'park', + 'leisure=nature_reserve': 'park', + 'leisure=garden': 'park', + 'leisure=stadium': 'stadium', + 'boundary=national_park': 'park', + 'boundary=protected_area': 'park', + 'tourism=zoo': 'zoo', + 'tourism=theme_park': 'amusement_park', + 'amenity=university': 'university', + 'amenity=college': 'university', + 'amenity=grave_yard': 'cemetery', + 'landuse=cemetery': 'cemetery', + 'landuse=forest': 'natural_feature', + 'aeroway=aerodrome': 'airport', + 'shop=mall': 'shopping_mall', + 'place=region': 'natural_feature', + 'place=suburb': 'sublocality', + 'place=neighbourhood': 'sublocality', + 'place=quarter': 'sublocality', + 'place=borough': 'sublocality', + 'place=square': null, +}); + +/** + * OSM keys whose features are areas whatever their value — lakes, rivers, + * peaks, woods, protected land. Photon reports every one of these with + * `type: 'other'`, so the key is the only signal that they are not buildings. + */ +const AREA_OSM_KEYS = Object.freeze( + new Set(['natural', 'water', 'waterway', 'landuse']), +); + +/** + * Google-style types for one Photon feature's properties. + * + * A specific `key=value` mapping is authoritative and ends the lookup; the + * coarse `type` is consulted only when no tag rule matched. Deferring to the + * coarse class after a tag rule is what mis-framed town squares as cities. + * @param {object} properties - Photon feature `properties`. + * @returns {string[]} Google Place Types, possibly empty (a precise place). + */ +export function photonResultTypes(properties) { + const key = String(properties?.osm_key || ''); + const value = String(properties?.osm_value || ''); + const tag = `${key}=${value}`; + + if (Object.prototype.hasOwnProperty.call(OSM_TAG_TO_GOOGLE, tag)) { + const mapped = OSM_TAG_TO_GOOGLE[tag]; + return mapped ? [mapped] : []; + } + if (AREA_OSM_KEYS.has(key)) return ['natural_feature']; + if (key === 'highway') return ['route']; + + const coarse = PHOTON_TYPE_TO_GOOGLE[String(properties?.type || '')]; + return coarse ? [coarse] : []; +} + +/** + * Photon `extent` → the `{southwest,northeast}` bounds the flight code frames. + * Photon orders it [west, north, east, south]; a naive [w,s,e,n] read produces + * an inverted box that still looks like a valid viewport. + * @param {number[]} extent - Photon `properties.extent`. + * @returns {?{southwest:{lat:number,lng:number}, northeast:{lat:number,lng:number}}} + */ +export function photonExtentToBounds(extent) { + if (!Array.isArray(extent) || extent.length !== 4) return null; + const [west, north, east, south] = extent; + if (![west, north, east, south].every(Number.isFinite)) return null; + if (west > east) return null; // Wrapped bounds are omitted until framing supports them. + if (Math.abs(north) > 90 || Math.abs(south) > 90) return null; + if (Math.abs(west) > 180 || Math.abs(east) > 180) return null; + return { + southwest: { lat: Math.min(north, south), lng: Math.min(west, east) }, + northeast: { lat: Math.max(north, south), lng: Math.max(west, east) }, + }; +} + +/** + * Human label for a Photon feature — its name plus the coarsest containing + * places that are not already in the name, mirroring the single-line address + * Google's `formatted_address` provides. + * @param {object} properties - Photon feature `properties`. + * @returns {string} + */ +export function photonResultLabel(properties) { + const name = String(properties?.name || '').trim(); + const parts = [name]; + for (const field of ['district', 'city', 'state', 'country']) { + const part = String(properties?.[field] || '').trim(); + if (part && !parts.includes(part)) parts.push(part); + } + return parts.filter(Boolean).join(', '); +} + +/** + * Photon GeoJSON feature → the normalized geocode result its callers consume. + * Returns null for a feature without usable coordinates. + * + * `name` is carried alongside the composed `label` because two call sites want + * different halves of the same answer: the search box shows the full label, + * while the annotation resolver matches OSM features on the canonical name + * alone. Deriving it here keeps one shape rather than two parsers of the same + * string. + * + * `properties.country` is deliberately NOT re-exported. Photon answers in the + * feature's own language ("Việt Nam", "日本", "Россия"), which no consumer in + * this app can match; the composed label is the only honest use for it. + * @param {object} feature - One entry of Photon's `features` array. + * @returns {?{lat:number, lng:number, name:string, label:string, + * types:string[], viewport:?object}} + */ +export function normalizePhotonFeature(feature) { + if ( + !Array.isArray(feature?.geometry?.coordinates) || + feature.geometry.type !== 'Point' + ) + return null; + const [lng, lat] = feature.geometry.coordinates; + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null; + + const properties = feature.properties || {}; + const name = String(properties.name || ''); + return { + lat, + lng, + name, + label: photonResultLabel(properties) || name, + types: photonResultTypes(properties), + viewport: photonExtentToBounds(properties.extent), + }; +} + +/** + * Photon request URL for one query. + * + * `bias` is the SAME `"swLat,swLng|neLat,neLng"` string `viewportBias()` builds + * for the Google call, so the two paths cannot drift apart — but it is sent as + * Photon's `lat`/`lon` PROXIMITY bias, not as `bbox`. + * + * That distinction is the whole correctness of this function. Google's `bounds` + * prefers results inside the box and still returns ones outside it; Photon's + * `bbox` is a hard filter. Translating the rectangle literally makes every + * search for somewhere off-screen return nothing — searching "Ho Guom, Ha Noi" + * while looking at Austin answers `features: []`. Sent as `lat`/`lon` it finds + * Hoan Kiem Lake, while "Sixth Street" still resolves to Austin's over Austin + * and to England's over London. Soft bias is the semantics Google gives, so + * soft bias is what the adapter must produce. + * @param {string} query - Free-text place name. + * @param {{bias?: ?string, limit?: number}} [options] + * @returns {string} + */ +export function photonSearchUrl( + query, + { bias = null, limit = PHOTON_RESULT_LIMIT } = {}, +) { + const url = new URL(PHOTON_ENDPOINT); + url.searchParams.set('q', String(query ?? '')); + url.searchParams.set('limit', String(limit)); + + const corners = String(bias ?? '').split('|'); + if (corners.length === 2) { + const [swLat, swLng] = corners[0].split(',').map(Number); + const [neLat, neLng] = corners[1].split(',').map(Number); + if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) { + url.searchParams.set('lat', String((swLat + neLat) / 2)); + url.searchParams.set('lon', String((swLng + neLng) / 2)); + } + } + return url.toString(); +} + +/** + * Place name reduced to comparable form: unaccented, lowercased, punctuation + * collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a + * user typing either means the same city. + * @param {string} text + * @returns {string} + */ +export function normalizeToponym(text) { + return String(text ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +/** + * Pick the candidate whose name actually answers `target`. + * + * Matching leads rather than merely contains: "Hue" must select `Huế` and not + * `Nguyen Hue Road`, which contains the word but names a different thing. + * `allowContains` relaxes that to a substring match, used only as a second + * choice once no candidate leads with the name. + * @param {object[]} features - Photon features, in the order returned. + * @param {string} target - Normalized name being looked for. + * @param {{allowContains?: boolean}} [options] + * @returns {?object} + */ +export function selectPhotonFeature( + features, + target, + { allowContains = false } = {}, +) { + if (!target) return null; + const names = (Array.isArray(features) ? features : []).map((feature) => [ + feature, + normalizeToponym(feature?.properties?.name), + ]); + + for (const [feature, name] of names) { + if (name === target || name.startsWith(`${target} `)) return feature; + } + if (!allowContains) return null; + for (const [feature, name] of names) { + if (name.includes(target)) return feature; + } + return null; +} + +/** Evict the oldest memo entries until the cache is within its cap. */ +function trimPhotonCache(cache) { + while (cache.size > PHOTON_CACHE_MAX) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} + +/** + * Geocode a place name without any API key, reporting whether the service + * ANSWERED as well as what it found. + * + * A caller that remembers negative results needs both halves. "Photon has no + * such place" is a verdict and may be cached; "Photon did not reply" is a + * network blip, and caching THAT keeps the app answering "not found" from + * memory long after the network came back — a whole session, for a key that + * would now resolve. Conflating the two is the defect this app already had to + * repair once in the Overpass disk cache, where refusals were persisted with + * normal data TTLs. + * + * Caller cancellation throws; provider failure, timeout and empty results yield a null + * `place`, which the caller reads exactly as it reads a Google miss. + * @param {string} query - Free-text place name. + * @param {{bias?: ?string, fetchImpl?: Function}} [options] + * @returns {Promise<{place: ?{lat:number, lng:number, name:string, label:string, + * types:string[], viewport:?object}, answered:boolean}>} + */ +export async function geocodeKeylessWithOutcome( + query, + { bias = null, fetchImpl = fetch, signal, cache = photonCache } = {}, +) { + signal?.throwIfAborted(); + const trimmed = String(query ?? '').trim(); + if (!trimmed) return { place: null, answered: true }; + + const memoKey = `${bias ?? ''}\n${trimmed}`; + // Only answered outcomes are memoised, so a hit is always an answer. + const cached = cache.get(memoKey); + if (cached && cached.expires > Date.now()) + return { place: cached.place, answered: true }; + cache.delete(memoKey); + + /** One request: the feature array Photon returned, or null if it did not answer. */ + const ask = async (url) => { + try { + signal?.throwIfAborted(); + const deadline = AbortSignal.timeout(PHOTON_TIMEOUT_MS); + const requestSignal = signal + ? AbortSignal.any([signal, deadline]) + : deadline; + const response = await fetchImpl(url, { signal: requestSignal }); + if (!response.ok) return null; + const data = await response.json(); + requestSignal.throwIfAborted(); + if (!Array.isArray(data?.features)) return null; + if (data.features.some((feature) => !normalizePhotonFeature(feature))) + return null; + return data.features; + } catch { + signal?.throwIfAborted(); + return null; + } + }; + + // Bias decides WHICH match wins, never WHAT counts as a match. Photon scores + // proximity against the name, so from Austin "Huế" came back as Hutto, Texas + // and "Hạ Long" as Long Branch — near misses beating the exact name by + // distance alone. Measured over twelve Vietnamese place names, three resolved + // to the wrong continent. So a biased answer is only accepted when it leads + // with the name asked for; otherwise the same query runs unbiased, which is + // what a user searching for somewhere off-screen meant in the first place. + let feature = null; + let everyAskAnswered = true; + if (bias) { + const biased = await ask(photonSearchUrl(trimmed, { bias })); + if (biased === null) everyAskAnswered = false; + else feature = selectPhotonFeature(biased, normalizeToponym(trimmed)); + } + + signal?.throwIfAborted(); + if (!feature) { + const anywhere = await ask(photonSearchUrl(trimmed, { bias: null })); + if (anywhere === null) everyAskAnswered = false; + else { + // "Hoan Kiem Lake, Hanoi" names a place and then the region holding it, the + // convention every geocoder's free-text field follows. The head segment is + // the thing being searched for; the tail only says where to look. + const head = normalizeToponym(trimmed.split(',')[0]); + feature = + selectPhotonFeature(anywhere, head, { allowContains: true }) || + anywhere[0]; + } + } + + signal?.throwIfAborted(); + const place = normalizePhotonFeature(feature); + // A found place is self-evidently an answer; a null one only counts as an + // answer when every request it rests on actually came back. + const answered = Boolean(place) || everyAskAnswered; + + // Answered misses are cached too: a typo re-issued on every keystroke should + // cost one request, not one per stroke. Unanswered ones are not, so the next + // attempt retries instead of replaying a network blip. + if (answered) { + cache.set(memoKey, { + place, + expires: Date.now() + (place ? 300_000 : 30_000), + }); + trimPhotonCache(cache); + } + return { place, answered }; +} + +/** + * Geocode a place name without any API key. Cancellation throws; network failure, a + * timeout, or an empty result all resolve to null, which the caller reads as + * "not found" exactly as it reads a Google miss. + * @param {string} query - Free-text place name. + * @param {{bias?: ?string, fetchImpl?: Function}} [options] + * @returns {Promise} + */ +export async function geocodeKeyless(query, options) { + return (await geocodeKeylessWithOutcome(query, options)).place; +} + +/** Construct an independent Photon provider with a bounded query cache. */ +export function createPhotonGeocoder({ + fetchImpl = (...args) => fetch(...args), +} = {}) { + const cache = new Map(); + return { + geocode: (query, options = {}) => + geocodeKeylessWithOutcome(query, { ...options, fetchImpl, cache }), + }; +} diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs new file mode 100644 index 000000000..142cb1cdb --- /dev/null +++ b/src/keylessGeocoder.test.mjs @@ -0,0 +1,446 @@ +// KEYLESS GEOCODER — the Photon adapter that keeps place search working +// without a Google key. +// +// The whole value of this module is that its output is INDISTINGUISHABLE to +// everything downstream: `geocodeNavigationMode` and the framing code must keep +// making the same decisions they make on a Google result. So these tests check +// the translation, not the network — three things that go wrong silently: +// +// 1. Photon's `extent` is [west, north, east, south]. Read as [w,s,e,n] it +// yields an inverted-but-plausible box, and the camera frames nonsense. +// 2. Photon calls a national park `type: 'other'`. Dropped, a park geocodes +// as a precise place and the camera lands on a random patch of grass — +// the exact bug geocodeNavigationMode's area types exist to prevent. +// 3. A miss must be `null`, never a throw: both callers read null as +// "not found" and a throw would surface as a broken search box. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + geocodeKeyless, + geocodeKeylessWithOutcome, + normalizePhotonFeature, + normalizeToponym, + photonExtentToBounds, + photonResultLabel, + photonResultTypes, + photonSearchUrl, + selectPhotonFeature, +} from './keylessGeocoder.js'; +import { geocodeNavigationMode } from './locations.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Photon's real answer for "Hanoi", trimmed to the fields we consume. */ +const HANOI = { + geometry: { type: 'Point', coordinates: [105.854041, 21.0283334] }, + properties: { + osm_key: 'place', + osm_value: 'city', + type: 'city', + name: 'Hà Nội', + country: 'Việt Nam', + countrycode: 'VN', + extent: [105.2889615, 21.3854176, 106.0200407, 20.5645154], + }, +}; + +test('a city normalizes into the shape the Google path produces', () => { + const result = normalizePhotonFeature(HANOI); + + assert.equal(result.lat, 21.0283334); + assert.equal(result.lng, 105.854041); + assert.equal(result.label, 'Hà Nội, Việt Nam'); + assert.deepEqual(result.types, ['locality']); + assert.equal(geocodeNavigationMode(result.types), 'city-overview'); +}); + +test('the extent is read as [west, north, east, south], not as [w,s,e,n]', () => { + const bounds = photonExtentToBounds(HANOI.properties.extent); + + // Hanoi spans roughly 20.56..21.39 N and 105.29..106.02 E. A [w,s,e,n] read + // would put north at 21.385 and south at 106.02 — a box that still has four + // finite numbers and would frame the camera at nothing. + assert.equal(bounds.southwest.lat, 20.5645154); + assert.equal(bounds.northeast.lat, 21.3854176); + assert.equal(bounds.southwest.lng, 105.2889615); + assert.equal(bounds.northeast.lng, 106.0200407); + assert.ok(bounds.northeast.lat > bounds.southwest.lat, 'north must exceed south'); + assert.ok(bounds.northeast.lng > bounds.southwest.lng, 'east must exceed west'); +}); + +test('a malformed or out-of-range extent yields no bounds rather than a bad box', () => { + assert.equal(photonExtentToBounds(undefined), null); + assert.equal(photonExtentToBounds([1, 2, 3]), null); + assert.equal(photonExtentToBounds([0, 95, 10, -95]), null, 'latitude past the pole'); + assert.equal(photonExtentToBounds([-200, 10, 10, 0]), null, 'longitude past the antimeridian'); +}); + +test('area features keep an area type so the camera frames them, not a rooftop', () => { + // Every row is a real Photon answer, captured from the live service. The + // lake rows are why: Photon tags water as `water=lake`, not `natural=water`, + // and the guess cost a camera 26 m over Hoan Kiem Lake. + const cases = [ + [{ type: 'other', osm_key: 'water', osm_value: 'lake' }, 'natural_feature', 'area-overview'], + [{ type: 'other', osm_key: 'leisure', osm_value: 'park' }, 'park', 'area-overview'], + [{ type: 'other', osm_key: 'place', osm_value: 'region' }, 'natural_feature', 'area-overview'], + [{ type: 'other', osm_key: 'natural', osm_value: 'peak' }, 'natural_feature', 'area-overview'], + [{ type: 'other', osm_key: 'waterway', osm_value: 'river' }, 'natural_feature', 'area-overview'], + [{ type: 'house', osm_key: 'aeroway', osm_value: 'aerodrome' }, 'airport', 'area-overview'], + [{ type: 'house', osm_key: 'amenity', osm_value: 'university' }, 'university', 'area-overview'], + [{ type: 'street', osm_key: 'highway', osm_value: 'motorway' }, 'route', 'street-corridor'], + [{ type: 'country', osm_key: 'place', osm_value: 'country' }, 'country', 'region-overview'], + [{ type: 'state', osm_key: 'place', osm_value: 'state' }, 'administrative_area_level_1', 'region-overview'], + [{ type: 'district', osm_key: 'place', osm_value: 'suburb' }, 'sublocality', 'neighborhood-close'], + ]; + + for (const [properties, expectedType, expectedMode] of cases) { + const types = photonResultTypes(properties); + assert.ok( + types.includes(expectedType), + `${properties.osm_key}=${properties.osm_value} must carry ${expectedType}, got ${JSON.stringify(types)}`, + ); + assert.equal(geocodeNavigationMode(types), expectedMode); + } +}); + +test('a tag rule wins over the coarse class — a town square is not a city', () => { + // Photon reports Times Square and Quang truong Ba Dinh as place=square with + // type: 'locality'. Falling through to the coarse class framed a plaza as if + // it were a whole city. + const square = { type: 'locality', osm_key: 'place', osm_value: 'square' }; + assert.deepEqual(photonResultTypes(square), []); + assert.equal(geocodeNavigationMode(photonResultTypes(square)), 'precise-place'); +}); + +test('an ordinary building stays a precise place', () => { + const types = photonResultTypes({ type: 'house', osm_key: 'building', osm_value: 'yes' }); + assert.equal(geocodeNavigationMode(types), 'precise-place'); +}); + +test('the label names the place, then only containers it does not already name', () => { + assert.equal( + photonResultLabel({ name: 'Hoàn Kiếm Lake', district: 'Hoàn Kiếm', city: 'Hà Nội', country: 'Việt Nam' }), + 'Hoàn Kiếm Lake, Hoàn Kiếm, Hà Nội, Việt Nam', + ); + assert.equal( + photonResultLabel({ name: 'Hà Nội', city: 'Hà Nội', country: 'Việt Nam' }), + 'Hà Nội, Việt Nam', + 'a city must not repeat its own name', + ); +}); + +test('the Google bias becomes a SOFT proximity bias, never a hard bbox', () => { + const url = new URL(photonSearchUrl('Sixth Street', { bias: '30.2000,-97.8000|30.3000,-97.7000' })); + + assert.equal(url.searchParams.get('q'), 'Sixth Street'); + // Five, not one: proximity reorders the list, so the name actually asked for + // can sit below a nearer near-miss and must still be reachable. + assert.equal(url.searchParams.get('limit'), '5'); + assert.equal(url.searchParams.get('lat'), '30.25', 'centre of the biased rectangle'); + assert.equal(url.searchParams.get('lon'), '-97.75'); + + // Measured against the live service: Photon's bbox FILTERS, Google's bounds + // PREFERS. Sending the rectangle as a bbox makes every off-screen search + // return features: [] — "Ho Guom, Ha Noi" while looking at Austin finds + // nothing at all. This assertion is the guard against that regression. + assert.equal(url.searchParams.get('bbox'), null, 'a bbox would filter out every off-screen place'); +}); + +test('an absent or unparsable bias drops the bias instead of sending a broken one', () => { + for (const bias of [undefined, 'nonsense', 'a,b|c,d']) { + const url = new URL(photonSearchUrl('Hanoi', bias === undefined ? {} : { bias })); + assert.equal(url.searchParams.get('lat'), null); + assert.equal(url.searchParams.get('lon'), null); + assert.equal(url.searchParams.get('bbox'), null); + } +}); + +test('a network failure resolves to null — callers read null as not-found', async () => { + const rejecting = () => Promise.reject(new Error('offline')); + assert.equal(await geocodeKeyless('Hanoi', { fetchImpl: rejecting }), null); + + const notOk = () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }); + assert.equal(await geocodeKeyless('somewhere else', { fetchImpl: notOk }), null); + + const empty = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [] }) }); + assert.equal(await geocodeKeyless('third distinct query', { fetchImpl: empty }), null); +}); + +test('an empty query never reaches the network', async () => { + let calls = 0; + const counting = () => { calls += 1; return Promise.reject(new Error('should not run')); }; + + assert.equal(await geocodeKeyless(' ', { fetchImpl: counting }), null); + assert.equal(await geocodeKeyless(null, { fetchImpl: counting }), null); + assert.equal(calls, 0); +}); + +test('repeat queries are served from the memo, misses included', async () => { + let calls = 0; + const counting = () => { + calls += 1; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [HANOI] }) }); + }; + + const first = await geocodeKeyless('Ha Noi memo probe', { fetchImpl: counting }); + const second = await geocodeKeyless('Ha Noi memo probe', { fetchImpl: counting }); + assert.equal(calls, 1, 'the second lookup must not hit the network'); + assert.deepEqual(second, first); + + const missing = () => { + calls += 1; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [] }) }); + }; + await geocodeKeyless('typo memo probe', { fetchImpl: missing }); + await geocodeKeyless('typo memo probe', { fetchImpl: missing }); + assert.equal(calls, 2, 'a miss must be memoized too, or a typo costs one request per keystroke'); +}); + +test('a feature without usable coordinates is rejected', () => { + assert.equal(normalizePhotonFeature(undefined), null); + assert.equal(normalizePhotonFeature({ properties: { name: 'Nowhere' } }), null); + assert.equal(normalizePhotonFeature({ geometry: { coordinates: [200, 10] } }), null); + assert.equal(normalizePhotonFeature({ geometry: { coordinates: [10, 91] } }), null); +}); + +// ── Bias may choose among matches; it may not change what counts as one ────── + +/** Photon's real answers for "Huế": Austin-biased, then unbiased. */ +const HUTTO = { + geometry: { type: 'Point', coordinates: [-97.6842, 30.5427] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hutto', country: 'United States' }, +}; +const HUE = { + geometry: { type: 'Point', coordinates: [107.5908, 16.4674] }, + properties: { + osm_key: 'place', osm_value: 'city', type: 'city', name: 'Huế', country: 'Việt Nam', + extent: [107.3763, 16.7739, 108.0518, 16.1276], + }, +}; + +/** Answers biased requests with `nearby`, unbiased ones with `anywhere`. */ +function photonPair(nearby, anywhere) { + const urls = []; + const impl = (url) => { + urls.push(url); + const biased = new URL(url).searchParams.has('lat'); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: biased ? nearby : anywhere }) }); + }; + impl.urls = urls; + return impl; +} + +test('a biased near-miss is refused, and the unbiased answer is taken instead', async () => { + // Measured from Austin against the live service: "Huế" scored Hutto, Texas + // above the city, and "Hạ Long" scored Long Branch. Distance alone must not + // outrank the name — three of twelve Vietnamese names crossed a continent. + const fetchImpl = photonPair([HUTTO], [HUE]); + + const result = await geocodeKeyless('Huế', { bias: '30.20,-97.80|30.30,-97.70', fetchImpl }); + + assert.equal(result.label.startsWith('Huế'), true, `got ${result.label}`); + assert.equal(Math.round(result.lat), 16); + assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request'); +}); + +test('a biased match that leads with the name is taken without a second request', async () => { + // The other half of the rule, and the reason bias exists at all: "Sixth + // Street" over Austin must stay Austin's. + const sixth = { + geometry: { type: 'Point', coordinates: [-97.7431, 30.2672] }, + properties: { osm_key: 'highway', osm_value: 'residential', type: 'street', name: 'Sixth Street', country: 'United States' }, + }; + const fetchImpl = photonPair([sixth], [HUE]); + + const result = await geocodeKeyless('Sixth Street', { bias: '30.20,-97.80|30.30,-97.70', fetchImpl }); + + assert.equal(result.label.startsWith('Sixth Street'), true); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('a name is matched at its head, so a street that merely contains it loses', () => { + const road = { properties: { name: 'Nguyen Hue Road' } }; + const city = { properties: { name: 'Huế' } }; + + assert.equal(selectPhotonFeature([road, city], 'hue'), city); + // Relaxing to substring is a second choice only, never a first one. + assert.equal(selectPhotonFeature([road], 'hue'), null); + assert.equal(selectPhotonFeature([road], 'hue', { allowContains: true }), road); + assert.equal(selectPhotonFeature([city], ''), null); + assert.equal(selectPhotonFeature(undefined, 'hue'), null); +}); + +test('a "place, region" query is matched on the place, not the region', async () => { + const lake = { + geometry: { type: 'Point', coordinates: [105.8524, 21.0287] }, + properties: { osm_key: 'natural', osm_value: 'water', water: 'lake', name: 'Hoàn Kiếm Lake', country: 'Việt Nam' }, + }; + const street = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { name: 'Hoan Kiem District Road' } }; + const fetchImpl = photonPair([], [street, lake]); + + const result = await geocodeKeyless('Hoan Kiem Lake, Hanoi', { fetchImpl }); + + assert.equal(result.label.startsWith('Hoàn Kiếm Lake'), true, `got ${result.label}`); +}); + +test('accents and punctuation do not change what a name compares as', () => { + assert.equal(normalizeToponym('Huế'), 'hue'); + assert.equal(normalizeToponym('Hạ Long Bay'), 'ha long bay'); + assert.equal(normalizeToponym(" St. John's-Ravenscourt "), 'st john s ravenscourt'); + assert.equal(normalizeToponym(undefined), ''); +}); + +// ── The seam inventory ─────────────────────────────────────────────────────── +// +// A fallback is only worth having where the app actually geocodes, and the app +// geocodes in more than one place: the LOCATION search box, the Radio layer's +// "near " lookup, and the annotation resolver. Fixing one and leaving +// the others is the failure this pins — the first version of this work fixed +// the search box alone, so a keyless install still threw on "play radio near +// Hanoi" while the search box beside it worked. +// +// It reads the source rather than the behaviour on purpose. The defect is not +// that a particular function is wrong; it is that a NEW call site can be added +// without one, and no behavioural test of the existing three would notice. + +test('forward geocoding is composed once and consumers do not call a source directly', () => { + for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) { + const source = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8'); + assert.match(source, /placeSearch\.geocode\(/); + assert.doesNotMatch(source, /geocodeKeyless|geocode\/json\?address/); + } + const setup = fs.readFileSync(path.join(ROOT, 'src/standalone/placeSearch.js'), 'utf8'); + assert.match(setup, /createGoogleGeocoder/); + assert.match(setup, /createPhotonGeocoder/); +}); + +test('a missing key is never a thrown error on the client', () => { + // It was one, in the Radio layer: a keyless install surfaced "play radio near + // Hanoi" as a failed voice turn rather than as a station it could not place. + for (const file of ['locations.js', 'annotations/annotationResolver.js', 'voice/gevActions.js']) { + const body = fs.readFileSync(path.join(ROOT, 'src', file), 'utf8'); + assert.doesNotMatch(body, /throw new Error\([^)]*No Google Maps API key/, `${file} throws on a missing key`); + } +}); + +// ── The shape the three call sites share ───────────────────────────────────── + +test('the normalized result carries the canonical name its callers need', () => { + // Two consumers want different halves of one answer: the search box shows + // `label`, while the annotation resolver matches OSM features on `name` + // alone — a canonical name beats a parsed label there, because "Hà Nội, + // Việt Nam" would let the country token win the footprint scoring. + const result = normalizePhotonFeature(HANOI); + + assert.equal(result.name, 'Hà Nội'); + assert.equal(result.label.startsWith('Hà Nội'), true); + assert.equal(result.label.includes(result.name), true, 'the label is built from the name, not beside it'); +}); + +test('a feature with no name yields an empty string, never undefined', () => { + const bare = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { osm_key: 'place', osm_value: 'city' } }; + const result = normalizePhotonFeature(bare); + + assert.equal(result.name, ''); + assert.equal(typeof result.label, 'string'); +}); + +test("Photon's own country name stays inside the label — it is not a field", () => { + // Photon answers in the feature's LANGUAGE: this fixture is the live service's + // real reply for "Hanoi", and it says "Việt Nam", not "Vietnam". Tokyo answers + // "日本", Moscow "Россия". Re-exporting that as a `country` field invites a + // consumer to match on it, and the one consumer that would — the Radio + // layer's `normalizeRadioCountryInput` — fails CLOSED on a name it cannot + // map, turning "radio near Ha Long" into an empty station list. The composed + // label is the only honest use for a localized country name. + assert.equal(HANOI.properties.country, 'Việt Nam'); + const result = normalizePhotonFeature(HANOI); + + assert.equal('country' in result, false, 'a localized country name must not become a matchable field'); + assert.match(result.label, /Việt Nam$/); +}); + +// ── An answer and a silence are not the same miss ──────────────────────────── +// +// Both arrive as `null`, and remembering the wrong one is the defect this app +// has already had to repair once: the Overpass disk cache persisted refusals +// with normal data TTLs, so an install that hit a bad minute kept serving the +// refusal for a month. A geocoder that memoises a network blip does the same +// thing for a session — every later keystroke replays it without asking. + +test('an unanswered request is never memoized, so the next attempt really retries', async () => { + let attempts = 0; + const offline = () => { attempts += 1; return Promise.reject(new Error('offline')); }; + const online = () => { + attempts += 1; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [HANOI] }) }); + }; + + assert.equal(await geocodeKeyless('blip probe', { fetchImpl: offline }), null); + assert.equal(attempts, 1); + + const recovered = await geocodeKeyless('blip probe', { fetchImpl: online }); + assert.equal(attempts, 2, 'the retry must reach the network, not a memoized blip'); + assert.equal(recovered.name, 'Hà Nội'); +}); + +test('an HTTP refusal counts as silence, not as "no such place"', async () => { + let attempts = 0; + const refusing = () => { + attempts += 1; + return Promise.resolve({ ok: false, status: 429, json: () => Promise.resolve({}) }); + }; + + await geocodeKeyless('throttle probe', { fetchImpl: refusing }); + await geocodeKeyless('throttle probe', { fetchImpl: refusing }); + assert.equal(attempts, 2, 'a 429 must not be remembered as a verdict'); +}); + +test('the outcome reports which of the two a null result was', async () => { + const answeredMiss = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [] }) }); + const failure = () => Promise.reject(new Error('offline')); + const hit = () => Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [HANOI] }) }); + + assert.deepEqual( + await geocodeKeylessWithOutcome('outcome probe a', { fetchImpl: answeredMiss }), + { place: null, answered: true }, + ); + assert.deepEqual( + await geocodeKeylessWithOutcome('outcome probe b', { fetchImpl: failure }), + { place: null, answered: false }, + ); + + const found = await geocodeKeylessWithOutcome('outcome probe c', { fetchImpl: hit }); + assert.equal(found.answered, true); + assert.equal(found.place.name, 'Hà Nội'); + + // A memo hit is by definition an answer — only answers are stored. + assert.deepEqual( + await geocodeKeylessWithOutcome('outcome probe a', { fetchImpl: failure }), + { place: null, answered: true }, + ); +}); + +test('a biased pass that never answered leaves the whole lookup unanswered', async () => { + // The unbiased retry can report an honest empty while the biased request that + // preceded it timed out. The lookup as a whole still rests on a request that + // never came back, so its null is not a verdict. + const urls = []; + const halfDown = (url) => { + urls.push(url); + return new URL(url).searchParams.has('lat') + ? Promise.reject(new Error('timeout')) + : Promise.resolve({ ok: true, json: () => Promise.resolve({ features: [] }) }); + }; + + const outcome = await geocodeKeylessWithOutcome('half-down probe', { + bias: '30.20,-97.80|30.30,-97.70', fetchImpl: halfDown, + }); + assert.deepEqual(outcome, { place: null, answered: false }); + assert.equal(urls.length, 2); +}); diff --git a/src/locations.js b/src/locations.js index 36a12263a..31c587596 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { viewportBias, placesNearViewRecovery } from './annotations/annotationResolver.js'; +import { unavailablePlaceSearch } from './search/placeSearch.js'; /** * Points of Interest per city. @@ -342,46 +343,35 @@ export function findPoiByName(query) { export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); /** - * Geocode a place name using Google Geocoding API, then fly there at a scale + * Geocode a place name through the supplied service, then fly there at a scale * appropriate to the request. Countries and cities use their viewport by * default; precise landmarks/buildings use close landmark framing. */ export async function searchAndFlyTo(viewer, query, options = {}) { - const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY; - if (!apiKey) throw new Error('No Google Maps API key available for geocoding'); - + const { placeSearch = unavailablePlaceSearch, signal } = options; + signal?.throwIfAborted(); const beforeFly = typeof options.beforeFly === 'function' ? options.beforeFly : null; - const mayFly = () => beforeFly === null || beforeFly() !== false; - - // Viewport-biased geocode — the same bias annotationResolver's geocodePlace uses: - // "Sixth Street" spoken over Austin must prefer the Sixth Street on screen, not a - // same-named road in another city (or the wrong end of town — the W 6th vs E 6th bug). - let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; - const bias = viewportBias(viewer); - if (bias) url += `&bounds=${bias}`; - const response = await fetch(url); - const data = await response.json(); - - const result = (data.status === 'OK' && data.results?.length) ? data.results[0] : null; - let lat = result?.geometry.location.lat; - let lng = result?.geometry.location.lng; - let label = result ? result.formatted_address : null; + const mayFly = () => !signal?.aborted && (beforeFly === null || beforeFly() !== false); + const outcome = await placeSearch.geocode(query, { bias: viewportBias(viewer), signal }); + signal?.throwIfAborted(); + const result = outcome.place; + let lat = result?.lat; + let lng = result?.lng; + let label = result?.label || query; let types = result?.types || []; - let viewport = result ? (result.geometry.bounds || result.geometry.viewport) : null; + let viewport = result?.viewport || null; - // Places-near-view recovery (annotationResolver's twin): a missed geocode, or one - // that landed implausibly far from the view centre, snaps back to a view-biased - // Places hit within the trust bound — "the Capitol" means the one on screen. - const recovered = await placesNearViewRecovery(viewer, query, result ? { lat, lon: lng } : null); + // Nearby landmark recovery retains precedence over a fallback geocoder hit. + const recovered = await placesNearViewRecovery(viewer, query, + result && !outcome.fallbackUsed ? { lat, lon: lng } : null, signal); + signal?.throwIfAborted(); if (recovered) { lat = recovered.lat; lng = recovered.lon; - label = recovered.label || label || query; + label = recovered.label || label; types = recovered.types || []; viewport = placesViewportToBounds(recovered.viewport) || viewport; - } else if (!result) { - return null; - } + } else if (!result) return null; const requestedRange = finitePositive(options.range); const duration = finitePositive(options.duration) || 3.0; diff --git a/src/locations.test.mjs b/src/locations.test.mjs index 7b8244ccf..54da6bd47 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -1,3 +1,4 @@ +import { createStandalonePlaceSearch } from './standalone/placeSearch.js'; // Camera-framing mode contract for fly_to_location (field test 8 + rootcause doc §3): // parks/lakes/campuses and streets are NOT precise POIs — flying to "Zilker Park" at // building range (250 m) lands on a random rooftop. Pure mapping tests, no network. @@ -62,7 +63,7 @@ async function runSearch(viewer, options, { result = AUSTIN_RESULT, query = 'aus json: async () => ({ status: 'OK', results: [result] }), }); try { - return await searchAndFlyTo(viewer, query, options); + return await searchAndFlyTo(viewer, query, { placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }), ...options }); } finally { globalThis.fetch = priorFetch; if (hadWindow) globalThis.window = priorWindow; diff --git a/src/search/google.js b/src/search/google.js new file mode 100644 index 000000000..e1c2a556a --- /dev/null +++ b/src/search/google.js @@ -0,0 +1,66 @@ +/** Normalize a Google forward-geocode result without leaking its address shape. */ +export function normalizeGooglePlace(result) { + const lat = result?.geometry?.location?.lat; + const lng = result?.geometry?.location?.lng; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lng) || + Math.abs(lat) > 90 || + Math.abs(lng) > 180 + ) + return null; + const types = Array.isArray(result.types) ? result.types : []; + const resultTypes = new Set(types.map((type) => String(type).toLowerCase())); + const components = Array.isArray(result.address_components) + ? result.address_components + : []; + // Address-only landmark results must retain the requested landmark identity. + const canonical = components.find( + (component) => + Array.isArray(component.types) && + component.types.some( + (type) => + type !== 'political' && resultTypes.has(String(type).toLowerCase()), + ), + ); + return { + lat, + lng, + name: canonical?.long_name || '', + label: result.formatted_address || '', + types, + viewport: result.geometry.bounds || result.geometry.viewport || null, + }; +} + +/** Construct Google geocoding with caller-owned transport and key selection. */ +export function createGoogleGeocoder({ request }) { + return { + async geocode(query, { bias = null, signal } = {}) { + signal?.throwIfAborted(); + try { + const response = await request(query, { bias, signal }); + signal?.throwIfAborted(); + // An unconfigured provider did not contribute a negative verdict. + if (!response) return { place: null, answered: true }; + if (response.ok === false) return { place: null, answered: false }; + const data = await response.json(); + signal?.throwIfAborted(); + if ( + data?.status === 'ZERO_RESULTS' && + Array.isArray(data.results) && + data.results.length === 0 + ) + return { place: null, answered: true }; + const place = + data?.status === 'OK' + ? normalizeGooglePlace(data.results?.[0]) + : null; + return { place, answered: Boolean(place) }; + } catch { + signal?.throwIfAborted(); + return { place: null, answered: false }; + } + }, + }; +} diff --git a/src/search/index.js b/src/search/index.js new file mode 100644 index 000000000..307dbaf30 --- /dev/null +++ b/src/search/index.js @@ -0,0 +1,3 @@ +export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js'; +export { createGoogleGeocoder, normalizeGooglePlace } from './google.js'; +export { createPhotonGeocoder } from '../keylessGeocoder.js'; diff --git a/src/search/placeSearch.js b/src/search/placeSearch.js new file mode 100644 index 000000000..664e5f9af --- /dev/null +++ b/src/search/placeSearch.js @@ -0,0 +1,65 @@ +/** A missing geocoder returns an unavailable outcome without making requests. */ +export const unavailablePlaceSearch = Object.freeze({ + async geocode(_query, { signal } = {}) { + signal?.throwIfAborted(); + return { place: null, answered: false }; + }, +}); + +/** + * Compose ordered geocoders into one bounded service. Each returns + * { place: { lat, lng, name, label, types, viewport } | null, answered: boolean }. + * Only definitive results are cached. Caller cancellation stops the whole chain. + */ +export function createPlaceSearch({ + providers, + signal: lifetime, + timeoutMs = 12_000, + now = Date.now, +}) { + const cache = new Map(); + return { + async geocode(query, { bias = null, signal } = {}) { + const combined = AbortSignal.any( + [lifetime, signal, AbortSignal.timeout(timeoutMs)].filter(Boolean), + ); + combined.throwIfAborted(); + const text = String(query ?? '').trim(); + if (!text) return { place: null, answered: true }; + const key = `${bias || ''}\n${text.toLowerCase()}`; + const cached = cache.get(key); + if (cached && cached.expires > now()) return cached.result; + cache.delete(key); + let result = { place: null, answered: providers.length > 0 }; + try { + for (const [index, provider] of providers.entries()) { + combined.throwIfAborted(); + const next = await provider.geocode(text, { bias, signal: combined }); + combined.throwIfAborted(); + result = { + place: next.place, + answered: Boolean(next.place) || (result.answered && next.answered), + }; + if (result.place) { + result.fallbackUsed = index > 0; + break; + } + } + } catch (error) { + lifetime?.throwIfAborted(); + signal?.throwIfAborted(); + if (!combined.aborted) throw error; + return { place: null, answered: false }; + } + combined.throwIfAborted(); + if (result.answered) { + cache.set(key, { + result, + expires: now() + (result.place ? 300_000 : 30_000), + }); + while (cache.size > 64) cache.delete(cache.keys().next().value); + } + return result; + }, + }; +} diff --git a/src/search/placeSearch.test.mjs b/src/search/placeSearch.test.mjs new file mode 100644 index 000000000..e609f9da9 --- /dev/null +++ b/src/search/placeSearch.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPlaceSearch, createGoogleGeocoder, createPhotonGeocoder } from './index.js'; +import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; +import { photonExtentToBounds } from '../keylessGeocoder.js'; +const feature = { geometry: { type: 'Point', coordinates: [105.85, 21.03] }, properties: { name: 'Hà Nội', type: 'city' } }; +const hit = () => Response.json({ features: [feature] }); + +test('malformed successful Photon responses remain retryable', async () => { + for (const malformed of [{ error: 'temporary failure' }, { features: {} }, { features: [{ geometry: { coordinates: [null, 1] } }] }]) { + let calls = 0; + const provider = createPhotonGeocoder({ fetchImpl: async () => ++calls === 1 ? Response.json(malformed) : hit() }); + assert.deepEqual(await provider.geocode('Hanoi'), { place: null, answered: false }); + assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội'); + assert.equal(calls, 2); + } +}); + +test('standalone geocoding falls back after Google connection, JSON and refusal failures', async () => { + for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) { + const urls = []; + const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => { + urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit(); + } }); + const result = await service.geocode('Hanoi'); + assert.equal(result.place.name, 'Hà Nội'); + assert.equal(result.fallbackUsed, true); + assert.equal(urls.length, 2); + } +}); + +test('a keyless service does not issue any Google request', async () => { + const service = createStandalonePlaceSearch({ fetchImpl: async (url) => { + assert.equal(new URL(url).hostname, 'photon.komoot.io'); return hit(); + } }); + assert.equal((await service.geocode('Hanoi')).place.lat, 21.03); +}); + +test('cancellation before lookup makes no request', async () => { + const controller = new AbortController(); controller.abort(); + let calls = 0; + const provider = createPhotonGeocoder({ fetchImpl: async () => { calls++; return hit(); } }); + await assert.rejects(provider.geocode('Hanoi', { signal: controller.signal }), { name: 'AbortError' }); + assert.equal(calls, 0); +}); + +test('cancellation stops Photon retries and does not cache a late response', async () => { + const controller = new AbortController(); + let calls = 0; + const provider = createPhotonGeocoder({ fetchImpl: async () => { + calls++; if (calls === 1) controller.abort(); return hit(); + } }); + await assert.rejects(provider.geocode('Hanoi', { bias: '1,1|2,2', signal: controller.signal }), { name: 'AbortError' }); + assert.equal(calls, 1); + assert.equal((await provider.geocode('Hanoi')).place.name, 'Hà Nội'); + assert.equal(calls, 2); +}); + +test('a Google request cancelled by its caller never starts the fallback', async () => { + const controller = new AbortController(); let fallbackCalls = 0; + const service = createPlaceSearch({ providers: [ + createGoogleGeocoder({ request: async () => { controller.abort(); return Response.json({ status: 'ZERO_RESULTS', results: [] }); } }), + { geocode: async () => { fallbackCalls++; return { place: null, answered: true }; } }, + ] }); + await assert.rejects(service.geocode('missing', { signal: controller.signal }), { name: 'AbortError' }); + assert.equal(fallbackCalls, 0); +}); + +test('service lifetime cancellation also rejects cache hits', async () => { + const controller = new AbortController(); + const service = createPlaceSearch({ signal: controller.signal, providers: [createPhotonGeocoder({ fetchImpl: async () => hit() })] }); + assert.ok((await service.geocode('Hanoi')).place); + controller.abort(); + await assert.rejects(service.geocode('Hanoi'), { name: 'AbortError' }); +}); + +test('one cancelled caller cannot cancel another caller of the same service', async () => { + const controller = new AbortController(); + const service = createPlaceSearch({ providers: [{ async geocode(_query, { signal }) { + await new Promise((resolve) => setImmediate(resolve)); signal.throwIfAborted(); + return { place: { lat: 1, lng: 2 }, answered: true }; + } }] }); + const first = service.geocode('same', { signal: controller.signal }); + const second = service.geocode('same'); + controller.abort(); + await assert.rejects(first, { name: 'AbortError' }); + assert.equal((await second).place.lat, 1); +}); + +test('definitive misses expire while outage outcomes are never cached', async () => { + let now = 0, calls = 0, answered = true; + const service = createPlaceSearch({ now: () => now, providers: [{ async geocode() { calls++; return { place: null, answered }; } }] }); + await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 1); + now = 30_001; answered = false; + await service.geocode('miss'); await service.geocode('miss'); assert.equal(calls, 3); +}); + +test('wrapped and nonnumeric Photon bounds are omitted', () => { + assert.equal(photonExtentToBounds([170, 10, -170, -10]), null); + assert.equal(photonExtentToBounds([null, 10, 20, -10]), null); +}); diff --git a/src/standalone/application.js b/src/standalone/application.js index 4ae94cde7..f5a17ca2c 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -1,3 +1,4 @@ +import { createStandalonePlaceSearch } from './placeSearch.js'; import { createApplication } from '../app/application.js'; import { createStandaloneScene } from './scene.js'; import { createStandaloneControls } from './controls.js'; @@ -18,19 +19,25 @@ export function createStandaloneApplication({ constructed = true; const loadingScreen = document.getElementById('loading-screen'); const loaderStatus = loadingScreen.querySelector('.loader-status'); + let placeSearch; return createApplication({ - createScene: (context) => - createStandaloneScene({ + createScene: (context) => { + placeSearch = createStandalonePlaceSearch({ + resolveApiKey: () => googleApiKey, + signal: context.signal, + }); + return createStandaloneScene({ ...context, googleApiKey, cesiumToken, loaderStatus, - }), + }); + }, createControls: (context) => - createStandaloneControls({ ...context, loaderStatus }), + createStandaloneControls({ ...context, loaderStatus, placeSearch }), createData: (context) => createStandaloneData({ ...context, allowQaRegistration }), createTools: (context) => - createStandaloneTools({ ...context, loadingScreen }), + createStandaloneTools({ ...context, loadingScreen, placeSearch }), }); } diff --git a/src/standalone/controls.js b/src/standalone/controls.js index 75e4e3432..96dd33f4c 100644 --- a/src/standalone/controls.js +++ b/src/standalone/controls.js @@ -6,10 +6,14 @@ import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; export function createStandaloneControls({ scene: { viewer, mapStackController }, loaderStatus, + placeSearch, defer, }) { // Initialize the style manager (post-processing, HUD, locations, share links) - const styleManager = new StyleManager(viewer, { mapStackController }); + const styleManager = new StyleManager(viewer, { + mapStackController, + placeSearch, + }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); defer(() => styleManager.dispose()); diff --git a/src/standalone/placeSearch.js b/src/standalone/placeSearch.js new file mode 100644 index 000000000..7b1016602 --- /dev/null +++ b/src/standalone/placeSearch.js @@ -0,0 +1,32 @@ +import { + createPlaceSearch, + createGoogleGeocoder, + createPhotonGeocoder, +} from '../search/index.js'; + +/** Google first when configured, then keyless Photon; transport stays local to setup. */ +export function createStandalonePlaceSearch({ + resolveApiKey, + fetchImpl = (...args) => fetch(...args), + signal, +} = {}) { + return createPlaceSearch({ + signal, + providers: [ + createGoogleGeocoder({ + request(query, { bias, signal }) { + const key = resolveApiKey?.(); + if (!key) return null; + const url = new URL( + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + url.searchParams.set('address', query); + url.searchParams.set('key', key); + if (bias) url.searchParams.set('bounds', bias); + return fetchImpl(url.toString(), { signal }); + }, + }), + createPhotonGeocoder({ fetchImpl }), + ], + }); +} diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 534509448..e4fd39568 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -17,6 +17,7 @@ export function createStandaloneTools({ controls, data, loadingScreen, + placeSearch, signal, defer, }) { @@ -25,7 +26,7 @@ export function createStandaloneTools({ const { dataManager } = data; const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); defer(() => sceneDirector.destroy()); - const annotations = initAnnotations({ viewer, tileset }); + const annotations = initAnnotations({ viewer, tileset, placeSearch }); defer(() => { if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; annotations.destroy(); @@ -99,6 +100,7 @@ export function createStandaloneTools({ if (window.__godsEyeView === debug) delete window.__godsEyeView; }); const voiceCommands = initGevVoiceCommands({ + placeSearch, viewer, styleManager, dataManager, diff --git a/src/ui.js b/src/ui.js index b2840fff5..7a1b911d6 100644 --- a/src/ui.js +++ b/src/ui.js @@ -2189,9 +2189,10 @@ export class StyleManager { * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] */ - constructor(viewer, { mapStackController = null } = {}) { + constructor(viewer, { mapStackController = null, placeSearch } = {}) { this.viewer = viewer; this.mapStackController = mapStackController; + this.placeSearch = placeSearch; this.stages = {}; this.activeStyle = 'normal'; document.documentElement.dataset.gevStyle = this.activeStyle; @@ -4235,6 +4236,7 @@ export class StyleManager { }; if (panelId === 'control-panel') { this._cancelMapSourceFocus?.(); + this._locationSearchController?.abort(); this._cancelMapSourceFocus = cancelMapSourceFocus; } @@ -9497,9 +9499,14 @@ export class StyleManager { return; } this._activeLocationSearchGeneration = generation; + this._locationSearchController?.abort(); + const searchController = new AbortController(); + this._locationSearchController = searchController; this._locationSearch.classList.add('searching'); try { const destination = await searchAndFlyTo(this.viewer, query, { + placeSearch: this.placeSearch, + signal: searchController.signal, beforeFly: () => this._reassertNavigationHandoff(generation), }); if (this._disposed || generation !== this._navigationGeneration) return; @@ -9523,10 +9530,12 @@ export class StyleManager { this._showToast('Location not found'); } } catch (err) { + if (searchController.signal.aborted) return; console.error('[Search] Geocoding failed:', err); if (this._disposed || generation !== this._navigationGeneration) return; this._showToast('Search failed'); } finally { + if (this._locationSearchController === searchController) this._locationSearchController = null; this._settleLocationSearchUi(generation); } } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a56c840e7..53c74ec79 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -17,6 +17,7 @@ import militaryAwarenessLayer, { import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js'; import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; +import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; @@ -287,7 +288,7 @@ export function readLayerLifecycleSummary(dataManager, layerId, { fallbackEnable }; } -export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) { +export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) { installViewTargetPrewarm(viewer); initCameraVerbs(viewer, getViewTargetCartesian); return async function runGevAction(name, rawArgs = {}, runOptions = {}) { @@ -510,7 +511,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene }; const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, { - recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, + recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch, })).query({ layers: [layerId], scope: { kind: 'view' }, @@ -769,6 +770,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene if (name === 'fly_to_location') { return flyToRequestedLocation(viewer, args, { + placeSearch, signal: runOptions.signal, runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function' ? (navigate) => styleManager.runImmediateLocationNavigation(navigate) : null, @@ -814,7 +816,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } if (name === 'analyst_query') { - return runAnalystQuery(viewer, dataManager, args); + return runAnalystQuery(viewer, dataManager, args, placeSearch); } if (name === 'move_camera') { @@ -899,7 +901,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } if (name === 'control_radio') { - return controlRadio(viewer, dataManager, args, runOptions); + return controlRadio(viewer, dataManager, args, { ...runOptions, placeSearch }); } if (name === 'track_entity') { @@ -1264,30 +1266,12 @@ async function resolveRadioLocation(args = {}, coordinates = radioCoordinatePair const known = knownRadioLocation(query, args.locationId); if (known) return known; if (!query) return null; - const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY; - if (!apiKey) throw new Error('No Google Maps API key available for Radio location search'); - const controller = new AbortController(); - const cancelFromTurn = () => controller.abort(); - if (options.signal?.aborted) throw radioAbortError(); - options.signal?.addEventListener('abort', cancelFromTurn, { once: true }); - const timer = setTimeout(() => controller.abort(), 6000); - try { - const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; - const response = await fetch(url, { signal: controller.signal }); - const body = await response.json(); - if (!radioActionIsCurrent(options)) throw radioAbortError(); - const result = body.status === 'OK' ? body.results?.[0] : null; - if (!result?.geometry?.location) return null; - return { - lat: result.geometry.location.lat, - lon: result.geometry.location.lng, - label: result.formatted_address || query, - country: '', - }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener('abort', cancelFromTurn); - } + const { placeSearch = unavailablePlaceSearch, signal } = options; + const { place } = await placeSearch.geocode(query, { signal }); + if (!radioActionIsCurrent(options)) throw radioAbortError(); + if (!place) return null; + // Localized provider country labels must not become station country filters. + return { lat: place.lat, lon: place.lng, label: place.label || query, country: '' }; } /** Voice Radio controls over the Radio layer's public player surface. */ @@ -2206,6 +2190,7 @@ function normalizeStyle(value) { } async function flyToRequestedLocation(viewer, args, { + placeSearch = unavailablePlaceSearch, signal, onStart = null, runImmediate = null, beginDeferred = null, @@ -2321,6 +2306,7 @@ async function flyToRequestedLocation(viewer, args, { if (generation === false) return cancelled(query); const managedDeferred = typeof reassertDeferred === 'function'; const destination = await searchAndFlyTo(viewer, query, { + placeSearch, signal, ...(rangeM ? { range: rangeM } : {}), forceClose: args.viewMode === 'close', // 'overview' frames the geocode viewport even for precise-place results — @@ -3310,7 +3296,7 @@ function activeContactsWindow() { } } -function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {}) { +function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch } = {}) { return { getRecords(layerKey) { const layer = dataManager.layers.get(layerKey); @@ -3322,7 +3308,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = { ? (mod.getAnalystRecords(requestedLimit) || []) : (mod.getAnalystRecords() || []); }, - resolveRegionRing: (name) => resolveRegionRingForQuery(name), + resolveRegionRing: (name) => resolveRegionRingForQuery(name, undefined, placeSearch), /** * The active Contacts subject, when there is one — the centre the operator * is reasoning about while Contacts is up. Null whenever Contacts is off, @@ -3356,8 +3342,8 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = { }; } -async function runAnalystQuery(viewer, dataManager, args = {}) { - if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager)); +async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = unavailablePlaceSearch) { + if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch })); const result = await _analystEngine.query({ layers: Array.isArray(args.layers) ? args.layers : undefined, scope: args.scope, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 5fbd7cff3..5fa93514e 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -1,3 +1,4 @@ +import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; @@ -6,11 +7,12 @@ import { getContextStore, registerEntityContext } from '../data/contextStore.js' import { DataLayerManager } from '../data/manager.js'; import { getActiveCameraMotion, interruptCameraMotion, moveCamera } from '../cameraVerbs.js'; import { reassertNavigationHandoff, runExplicitNavigation } from '../navigationPolicy.js'; +import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; import { controlCctv, - controlRadio, - createGevActionRunner, + controlRadio as runControlRadio, + createGevActionRunner as createActionRunner, cctvVoiceFocusOutcome, formatTrackedEntityLabel, knownRadioLocation, @@ -3017,3 +3019,124 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it', assert.equal(result.window.centeredOn, 'N546PC'); }); }); + +// ── Keyless Radio location ─────────────────────────────────────────────────── +// +// `resolveRadioLocation` used to be Google-only: no key threw, and a key that +// geocoded to nothing returned null, which the caller reports as "Could not +// resolve Radio location". 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 here, 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, city = '', country = '' }) { + return { + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { name, city, country, osm_key: 'place', osm_value: 'city' }, + }; +} + +/** A Radio layer that records what it was asked to select. */ +function radioSelectionHarness() { + let enabled = false; + const calls = []; + const state = { + stationCount: 4, filter: 'all', selected: null, + audioState: 'stopped', volume: 0.8, voiceDucked: false, + }; + const radio = { + getUIState: () => ({ ...state }), + selectRequestedStation(criteria, options) { + calls.push({ criteria, options }); + state.selected = { id: 'kl-1', name: 'Keyless FM' }; + return state.selected; + }, + }; + return { + calls, + dataManager: { + layers: new Map([['radio', { module: radio }]]), + isEnabled: () => enabled, + async setEnabled(_id, value) { enabled = value; }, + }, + }; +} + +/** Install a Google key plus a fetch stub, restoring both afterwards. */ +function installKeyedFetch(t, handler) { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const priorKey = globalThis.window.__GOOGLE_MAPS_API_KEY__; + const priorFetch = globalThis.fetch; + globalThis.window.__GOOGLE_MAPS_API_KEY__ = 'unit-test-key'; + globalThis.fetch = handler; + t.after(() => { + globalThis.fetch = priorFetch; + if (priorKey === undefined) delete globalThis.window.__GOOGLE_MAPS_API_KEY__; + else globalThis.window.__GOOGLE_MAPS_API_KEY__ = priorKey; + }); +} + +test('voice Radio: a key that geocodes to nothing still places the station, keylessly', async (t) => { + const { calls, dataManager } = radioSelectionHarness(); + const requests = []; + installKeyedFetch(t, async (url) => { + requests.push(String(url)); + if (String(url).startsWith('https://maps.googleapis.com/')) { + return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; + } + assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/); + return { + ok: true, + json: async () => ({ + features: [photonFeature({ + name: 'Hạ Long Bay', lat: 20.9101, lon: 107.1839, city: 'Hạ Long', country: 'Việt Nam', + })], + }), + }; + }); + + const result = await controlRadio({}, dataManager, { + action: 'select', locationQuery: 'Hạ Long Bay', + }); + + // Before the fallback existed this was `ok: false, "Could not resolve Radio location"`. + assert.equal(result.ok, true); + assert.equal(result.requestedLocation, 'Hạ Long Bay, Hạ Long, Việt Nam'); + assert.equal(calls.length, 1); + assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9); + assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9); + // Google is asked first and exactly once; Photon answers unbiased, in one call. + assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); + assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/); + assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased'); +}); + +test('voice Radio: the keyless path applies no country filter the keyed path would not', async (t) => { + // Photon reports the country in the feature's own language ("Việt Nam"), and + // `rankRadioStationsForRequest` fails CLOSED on a country it cannot map — + // returning NO stations. Forwarding it would make a keyless install answer + // "No Radio station matched" for exactly the places it just resolved, while a + // keyed install placed a station. The label may carry it; the filter may not. + const { calls, dataManager } = radioSelectionHarness(); + installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/') + ? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) } + : { + ok: true, + json: async () => ({ + features: [photonFeature({ name: 'Kraków', lat: 50.0614, lon: 19.9366, country: 'Polska' })], + }), + })); + + const result = await controlRadio({}, dataManager, { action: 'select', locationQuery: 'Kraków' }); + + assert.equal(result.ok, true); + assert.equal(calls[0].criteria.country, '', 'a localized country name must never reach the station filter'); + assert.equal(normalizeRadioCountryInput('Polska').valid, false, 'and this is why: it would match nothing'); + assert.equal(result.requestedLocation, 'Kraków, Polska', 'the label still names the country honestly'); +}); + +const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); +function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } +function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); } diff --git a/src/voice/gevRealtime.js b/src/voice/gevRealtime.js index 45ebed526..6ba143bf2 100644 --- a/src/voice/gevRealtime.js +++ b/src/voice/gevRealtime.js @@ -194,11 +194,11 @@ export function silenceRadioForVoice({ duckRadio, pauseRadio } = {}) { */ const SUPERSEDED_RESPONSE_MEMORY = 8; -export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) { +export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch }) { if (window.__gevVoiceCommands && typeof window.__gevVoiceCommands.stop === 'function') { window.__gevVoiceCommands.stop({ removeUi: true }); } - const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations }); + const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations, placeSearch }); const ui = createVoiceControl({ reset: true }); const radioLayer = dataManager?.layers?.get('radio')?.module || null; const controller = new GevRealtimeController({ runner, ui, radioLayer, dataManager }); diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index a07efbe2b..d76fe60a6 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -4,7 +4,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { DataLayerManager } from '../data/manager.js'; -import { controlRadio, createGevActionRunner } from './gevActions.js'; +import { controlRadio as runControlRadio, createGevActionRunner as createActionRunner } from './gevActions.js'; +import { createStandalonePlaceSearch } from '../standalone/placeSearch.js'; import { computeDownscale, renderFreshCesiumFrame, @@ -3669,3 +3670,7 @@ test('a genuinely different refused call still gets its own output', async () => await controller.handleRealtimeEvent(lateToolItemEvent('resp_old', 'call_two', 'item_two')); assert.deepEqual(outputs, ['call_one', 'call_two'], 'each distinct call is answered'); }); + +const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }); +function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); } +function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }