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

+8
View File
@@ -73,6 +73,14 @@ export const DATA_CREDITS = [
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
},
{
key: 'photon-geocoder',
html:
'Keyless place search: ' +
'<a href="https://photon.komoot.io" target="_blank" rel="noopener">Photon</a> (komoot) over ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap contributors</a> ' +
'(ODbL 1.0)',
},
{
key: 'military-installations-osm',
html:
+250
View File
@@ -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<?{lat:number, lng:number, label:string, types:string[], viewport:?object}>}
*/
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;
}
+199
View File
@@ -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);
});
+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);