From f16fc2db0de213a4f45ac072005d4910fa88740b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 28 Aug 2026 18:00:01 +0700 Subject: [PATCH] feat(search): fall back to keyless Photon geocoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place search was Google-only: searchAndFlyTo threw without a key, and with a key whose Geocoding API is not enabled it returned nothing. Both cases leave the LOCATION box and the fly_to_location voice tool unable to resolve a name, even though every other layer in the app works keyless. src/keylessGeocoder.js adapts Photon (komoot, over OpenStreetMap) into the shape the Google path already produces — lat/lng, label, Google-style types, {southwest,northeast} bounds — so geocodeNavigationMode, regionFramingPlan and the region-swath cap keep running on unchanged inputs. The translation lives at the boundary and nowhere else; Google remains primary and its branch is unchanged when it answers. Detection is the empty result, not an exception: an unenabled Geocoding API answers HTTP 200 with REQUEST_DENIED. Places near-view recovery is now attempted only when a key exists, since Places is a Google service too. Three translations are load-bearing, and each was found by querying the live service rather than by reasoning about OSM tagging: - Google's `bounds` PREFERS results inside the box; Photon's `bbox` FILTERS to it. Sent as a bbox, searching "Ho Guom, Ha Noi" while looking at Austin returns features: []. Sent as Photon's lat/lon proximity bias it finds the lake, and "Sixth Street" still resolves to Austin's over Austin and to England's over London. - Photon's `extent` is [west, north, east, south]. Read as [w,s,e,n] it yields an inverted but finite box that frames the camera at nothing. - A specific OSM key=value rule must beat the coarse `type`. Photon tags a lake `water=lake` with `type: 'other'` — unmapped, that framed Hoan Kiem Lake as a precise place and put the camera 26 m over the water — and tags a town square `type: 'locality'`, which framed Times Square as if it were a city. searchAndFlyTo no longer throws when no key is present. Both callers already read a null return as not-found (ui.js renders its miss branch, gevActions reports ok: false), so the removed throw has no consumer. Verified end to end through the real search box, with a key Google refuses: one denied Google geocode, one Photon request, camera from Austin (30.2700, -97.7400) to 21.0288, 105.8524 — Hoan Kiem Lake to within metres — and "Ha Noi" framed at province scale. npm test 2604/2604; npm run build clean; npm run test:track 97/3, the same three failures unmodified main produces on this machine. --- CHANGELOG.md | 8 ++ DATA_SOURCES.md | 1 + docs/CURRENT-STATE.md | 6 + src/data/dataCredits.js | 8 ++ src/keylessGeocoder.js | 250 +++++++++++++++++++++++++++++++++++ src/keylessGeocoder.test.mjs | 199 ++++++++++++++++++++++++++++ src/locations.js | 32 +++-- 7 files changed, 496 insertions(+), 8 deletions(-) create mode 100644 src/keylessGeocoder.js create mode 100644 src/keylessGeocoder.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7398d8418..40c545a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ 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. +### 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. ## [0.1.1] — 2026-09-01 — Installation and live-data fixes 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/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a2bb9dad1..00003a260 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2181,6 +2181,12 @@ 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. +### Keyless place search (August 2026) + +- `src/keylessGeocoder.js` is a Photon (komoot/OpenStreetMap) adapter used by `searchAndFlyTo` when the Google geocode produces no result: no key configured, or a key whose Geocoding API is not enabled — Google answers HTTP 200 with `REQUEST_DENIED`, so the empty result is the detector, not an error. Google stays the primary path and is byte-unchanged when it answers, and the Places near-view recovery is only attempted when a key exists, since Places is a Google service. +- The adapter normalizes into the shape the Google path already produces — `lat`/`lng`, label, Google-style `types`, `{southwest,northeast}` bounds — so `geocodeNavigationMode`, `regionFramingPlan` and the region-swath cap keep running unchanged. Two translations are load-bearing and are pinned by tests: Photon's `extent` is `[west, north, east, south]`, and a specific OSM `key=value` rule beats the coarse `type` (Photon reports a lake as `water=lake` with `type: 'other'`, which framed at building range put the camera 26 m over the water; it reports a town square as `type: 'locality'`, which framed a plaza as a city). +- The shared `viewportBias()` string is sent as Photon's `lat`/`lon` PROXIMITY bias, never as `bbox`: Google's `bounds` prefers, Photon's `bbox` filters, and the literal translation makes every off-screen search return nothing. One result per query, memoized by query+bias (misses included) to respect komoot's fair use. + ### 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/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..9de6faea9 --- /dev/null +++ b/src/keylessGeocoder.js @@ -0,0 +1,250 @@ +/** + * Keyless place search — Photon (komoot), an OpenStreetMap geocoder. + * + * The app's place search is Google Geocoding, which needs a billing-enabled + * key. This module is the fallback for the two cases where that key produces + * nothing: no key configured at all, and a key whose Geocoding API is not + * enabled (Google answers HTTP 200 with `REQUEST_DENIED`, so the status — not + * the response code — is what decides). + * + * It is an ADAPTER, not a second search stack. Results are normalized into the + * exact shape `searchAndFlyTo` already consumes from Google — `lat`/`lng`, a + * label, Google-style `types`, and `{southwest,northeast}` bounds — so every + * downstream framing decision (`geocodeNavigationMode`, `regionFramingPlan`, + * the region-swath cap) keeps running unchanged on unchanged inputs. The + * translation lives here, at the boundary, and nowhere else. + * + * Photon is free, keyless, and CORS-open, so this runs in the browser on the + * same path Google does. It asks for one hit and caches by query+bias to stay + * inside komoot's fair-use expectations. + */ + +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; +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.map(Number); + if (![west, north, east, south].every(Number.isFinite)) return null; + 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 `searchAndFlyTo` + * consumes. Returns null for a feature without usable coordinates. + * @param {object} feature - One entry of Photon's `features` array. + * @returns {?{lat:number, lng:number, label:string, types:string[], viewport:?object}} + */ +export function normalizePhotonFeature(feature) { + 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 || {}; + return { + lat, + lng, + label: photonResultLabel(properties) || String(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}} [options] + * @returns {string} + */ +export function photonSearchUrl(query, { bias = null } = {}) { + const url = new URL(PHOTON_ENDPOINT); + url.searchParams.set('q', String(query ?? '')); + url.searchParams.set('limit', '1'); + + 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(); +} + +/** Evict the oldest memo entries until the cache is within its cap. */ +function trimPhotonCache() { + while (photonCache.size > PHOTON_CACHE_MAX) { + const oldest = photonCache.keys().next().value; + if (oldest === undefined) break; + photonCache.delete(oldest); + } +} + +/** + * Geocode a place name without any API key. Never throws: a 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, { bias = null, fetchImpl = fetch } = {}) { + const trimmed = String(query ?? '').trim(); + if (!trimmed) return null; + + const url = photonSearchUrl(trimmed, { bias }); + if (photonCache.has(url)) return photonCache.get(url); + + let result = null; + try { + const response = await fetchImpl(url, { signal: AbortSignal.timeout(PHOTON_TIMEOUT_MS) }); + if (response.ok) { + const body = await response.json(); + result = normalizePhotonFeature(body?.features?.[0]); + } + } catch { + result = null; + } + + // Misses are cached too: a typo re-issued on every keystroke should cost one + // request, not one per stroke. + photonCache.set(url, result); + trimPhotonCache(); + return result; +} diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs new file mode 100644 index 000000000..922acd9d0 --- /dev/null +++ b/src/keylessGeocoder.test.mjs @@ -0,0 +1,199 @@ +// 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 { + geocodeKeyless, + normalizePhotonFeature, + photonExtentToBounds, + photonResultLabel, + photonResultTypes, + photonSearchUrl, +} from './keylessGeocoder.js'; +import { geocodeNavigationMode } from './locations.js'; + +/** 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'); + assert.equal(url.searchParams.get('limit'), '1'); + 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); +}); diff --git a/src/locations.js b/src/locations.js index 36a12263a..20e2cfbcf 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 { geocodeKeyless } from './keylessGeocoder.js'; /** * Points of Interest per city. @@ -348,7 +349,6 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); */ 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 beforeFly = typeof options.beforeFly === 'function' ? options.beforeFly : null; const mayFly = () => beforeFly === null || beforeFly() !== false; @@ -356,13 +356,17 @@ export async function searchAndFlyTo(viewer, query, options = {}) { // 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(); + let result = null; + + if (apiKey) { + let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; + if (bias) url += `&bounds=${bias}`; + const response = await fetch(url); + const data = await response.json(); + result = (data.status === 'OK' && data.results?.length) ? data.results[0] : null; + } - 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; @@ -372,7 +376,10 @@ export async function searchAndFlyTo(viewer, query, options = {}) { // 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); + // Places is a Google service, so it is only worth asking when Google answered. + const recovered = apiKey + ? await placesNearViewRecovery(viewer, query, result ? { lat, lon: lng } : null) + : null; if (recovered) { lat = recovered.lat; lng = recovered.lon; @@ -380,7 +387,16 @@ export async function searchAndFlyTo(viewer, query, options = {}) { types = recovered.types || []; viewport = placesViewportToBounds(recovered.viewport) || viewport; } else if (!result) { - return null; + // Keyless fallback. Reached when no key is configured, and also when a key + // exists but Google declined — an unenabled Geocoding API answers HTTP 200 + // with REQUEST_DENIED, so the miss above is what detects it, not an error. + const keyless = await geocodeKeyless(query, { bias }); + if (!keyless) return null; + lat = keyless.lat; + lng = keyless.lng; + label = keyless.label || query; + types = keyless.types; + viewport = keyless.viewport; } const requestedRange = finitePositive(options.range);