feat(search): fall back to keyless Photon geocoding

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.
This commit is contained in:
daikaginza committed 2026-09-05 09:46:04 +07:00
1 parent 3b6b3f1007
commit f16fc2db0d
7 files changed
+496 -8

No files matched your search

+24 -8
View File
@@ -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);