Merge pull request #166: keyless place search through providers

Preserve the original Photon geocoding and place-matching contribution, with explicit service composition, cancellation and outage-cache fixes.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 15:41:25 -07:00
commit eec4dd5cdb
30 files changed
+1621 -151

No files matched your search

+21
View File
@@ -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 \<place>" 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.
+1
View File
@@ -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 |
+13
View File
@@ -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.
+23
View File
@@ -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`.
+3 -2
View File
@@ -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"
}
}
+6 -1
View File
@@ -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"
]
+10
View File
@@ -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": []
}
}
+5 -4
View File
@@ -94,6 +94,7 @@ export function normalizeTargetKey(target) {
}
export function createAnnotationEngine({
placeSearch,
viewer,
renderer,
outlineRetryDelaysMs = OUTLINE_RETRY_DELAYS_MS,
@@ -393,7 +394,7 @@ export function createAnnotationEngine({
for (const pt of points) {
const name = pt.target ?? pt.name ?? null;
const r = await resolveTarget({
viewer,
placeSearch, viewer,
target: name,
latitude: pt.latitude,
longitude: pt.longitude,
@@ -436,7 +437,7 @@ export function createAnnotationEngine({
}
if (type === 'arrow') {
const from = await resolveTarget({
viewer,
placeSearch, viewer,
target: spec.target,
latitude: spec.latitude,
longitude: spec.longitude,
@@ -446,7 +447,7 @@ export function createAnnotationEngine({
signal,
});
const to = await resolveTarget({
viewer,
placeSearch, viewer,
target: spec.toTarget,
latitude: spec.toLatitude,
longitude: spec.toLongitude,
@@ -471,7 +472,7 @@ export function createAnnotationEngine({
}
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({
viewer,
placeSearch, viewer,
target: spec.target,
latitude: spec.latitude,
longitude: spec.longitude,
+3 -2
View File
@@ -1,3 +1,4 @@
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
// Voice-annotation resilience contract tests — pure logic, no network, no browser.
//
// Locks the 2026-07-21 field-test fixes:
@@ -385,7 +386,7 @@ test('targetKey: empty / absent targets stay null (coord and pixel specs never p
assert.equal(normalizeTargetKey(undefined), null);
});
test('outline upgrade updates the rendered element in place without remove/add', async (t) => {
test('outline upgrade updates the rendered element in place without remove/add', { timeout: 5000 }, async (t) => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
const originalWindow = globalThis.window;
@@ -466,7 +467,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
sync() {},
};
const viewer = {};
const engine = createAnnotationEngine({ viewer, renderer });
const engine = createAnnotationEngine({ viewer, renderer, placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => 'unit-test-key' }) });
const upgraded = new Promise((resolve) => engine.onOutlineEvent(resolve));
const result = await engine.annotate([{
+15 -65
View File
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
import { lookupNaturalRegionOutline, findNaturalRegion } from '../data/naturalEarthRegions.js';
import { registerDynamicCredit, NATURAL_EARTH_CREDIT } from '../data/dataCredits.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
/**
@@ -19,7 +20,6 @@ import { isPickedWorldPosition } from '../data/scenePick.js';
* raw geometry ring instead of a bounding box, which is what an outline needs.
*/
const geocodeCache = new Map();
const footprintCache = new Map();
const monumentCache = new Map(); // OSM monuments/memorials near a view center, keyed by rounded coord
const enclosingAreaCache = new Map(); // smallest enclosing named non-building polygon, keyed by ~1km coord bucket
@@ -90,6 +90,7 @@ function linkAbort(controller, externalSignal) {
* }>}
*/
export async function resolveAnnotationTarget({
placeSearch = unavailablePlaceSearch,
viewer, target, latitude, longitude, footprint = false, intent = 'the_thing',
entityKind = null, labelHint = null, deferFootprint = false, screenX, screenY, signal,
}) {
@@ -140,7 +141,7 @@ export async function resolveAnnotationTarget({
}
}
if (source !== 'places') {
const geocoded = await geocodePlace(query, viewportBias(viewer), signal);
const geocoded = await geocodePlace(query, viewportBias(viewer), signal, placeSearch);
if (geocoded) {
lat = geocoded.lat;
lon = geocoded.lon;
@@ -602,50 +603,16 @@ function ringAreaM2(ring) {
return Math.abs(area) / 2;
}
/**
* Forward-geocode a place name via Google Geocoding, biased to the current
* viewport so "the marina" resolves near where the user is looking.
*/
async function geocodePlace(query, biasRect, signal) {
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const cacheKey = `${query.toLowerCase()}|${biasRect || ''}`;
const cached = cacheRead(geocodeCache, cacheKey);
if (cached !== undefined) return cached;
let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`;
if (biasRect) url += `&bounds=${biasRect}`;
try {
const response = await fetch(url, { signal });
const data = await response.json();
if (data.status !== 'OK' || !data.results?.length) {
// ZERO_RESULTS is a definitive not-found (cacheable); OVER_QUERY_LIMIT /
// REQUEST_DENIED / UNKNOWN_ERROR are transient → don't poison the cache.
negCache(geocodeCache, cacheKey, signal, data?.status === 'ZERO_RESULTS');
return null;
}
const result = data.results[0];
const place = {
lat: result.geometry.location.lat,
lon: result.geometry.location.lng,
label: shortLabel(result.formatted_address),
// The CANONICAL name of the resolved feature (e.g. "Mission District",
// "Texas State Capitol") — used for OSM name-matching instead of the raw
// utterance, so incidental tokens ("...Texas", "...Austin") can't win.
primaryName: extractPrimaryName(result),
types: result.types || [],
// Geocode viewport (sw/ne box framing the feature), normalized to the Places
// low/high shape — sizes grounds discs and flyTo framing for geocode anchors.
viewport: normalizeGeocodeViewport(result.geometry?.bounds || result.geometry?.viewport),
};
cacheWrite(geocodeCache, cacheKey, place);
return place;
} catch {
negCache(geocodeCache, cacheKey, signal, false); // network/abort — transient
return null;
}
/** Resolve a name through the supplied service; geometry selection stays here. */
async function geocodePlace(query, biasRect, signal, placeSearch) {
const { place } = await placeSearch.geocode(query, { bias: biasRect, signal });
signal?.throwIfAborted();
if (!place) return null;
return {
lat: place.lat, lon: place.lng, label: shortLabel(place.label),
primaryName: place.name || null, types: place.types,
viewport: normalizeGeocodeViewport(place.viewport),
};
}
/** Geocoding returns {southwest:{lat,lng},northeast:{lat,lng}}; normalize to the Places
@@ -716,23 +683,6 @@ async function placesTextSearch(query, centerLat, centerLon, radiusM, signal) {
}
}
/**
* The canonical name of the geocoded feature: the address component whose own
* types match the result's feature type (e.g. the `neighborhood` component for a
* neighborhood result). Address-only responses may identify a landmark's city
* without naming the landmark itself. Return null in that case so the caller
* keeps the requested name instead of matching a nearby building in that city.
*/
function extractPrimaryName(result) {
const resultTypes = new Set((result.types || []).map((t) => String(t).toLowerCase()));
const comps = Array.isArray(result.address_components) ? result.address_components : [];
for (const c of comps) {
const ct = (c.types || []).map((t) => String(t).toLowerCase());
if (ct.some((t) => t !== 'political' && resultTypes.has(t))) return c.long_name;
}
return null;
}
/**
* Map the Google geocode `types` to a resolution SCOPE so we fetch the right
* OSM feature at the right size. Country-agnostic: scope only selects the query
@@ -1936,7 +1886,7 @@ function shortLabel(formattedAddress) {
* @param {AbortSignal} [signal]
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
*/
export async function resolveRegionRingForQuery(name, signal) {
export async function resolveRegionRingForQuery(name, signal, placeSearch = unavailablePlaceSearch) {
const q = String(name || '').trim();
if (!q) return null;
const ne = await findNaturalRegion(q).catch(() => null);
@@ -1946,7 +1896,7 @@ export async function resolveRegionRingForQuery(name, signal) {
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
if (ring?.length >= 3) return { name: ne.name, ring };
}
const geo = await geocodePlace(q, null, signal).catch(() => null);
const geo = await geocodePlace(q, null, signal, placeSearch).catch(() => null);
if (!geo) return null;
const scope = scopeFromTypes(geo.types);
if (!['country', 'state', 'county', 'city'].includes(scope)) return null;
+192 -1
View File
@@ -1,3 +1,5 @@
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
import { createPlaceSearch } from '../search/placeSearch.js';
// Footprint-selection contract tests — pure fixtures, no network, no browser.
//
// Locks the field-test-7 monument fix (docs/field-test-rootcause-2026-06-30.md §1):
@@ -12,7 +14,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
resolveAnnotationTarget,
resolveAnnotationTarget as resolveTarget,
selectFootprint,
refineScope,
isGroundsLikeAsk,
@@ -431,3 +433,192 @@ test('a genuine geocoded feature name still canonicalizes an alternate user name
const result = await resolveAnnotationTarget({ viewer: capitolViewer(), target: 'Congress meeting building', footprint: true });
assert.deepEqual(result.ring, capitol.geometry.map(p => [p.lon, p.lat]));
});
// ── Keyless geocoding ────────────────────────────────────────────────────────
//
// `geocodePlace` was Google-only: no key returned null, and so did a key whose
// Geocoding API is not enabled (Google answers HTTP 200 with REQUEST_DENIED).
// Either way the annotation silently failed to place. Both now fall through to
// Photon. These drive the second case, because it reaches the SAME fallback
// through a running Google branch — the no-key branch cannot be driven from
// `node --test`, since the key expression reads `import.meta.env`, which only
// Vite defines.
/** Photon's GeoJSON shape, trimmed to the properties the adapter consumes. */
function photonFeature({ name, lat, lon, tags = {}, extent = null, ...rest }) {
return {
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { name, ...tags, ...rest, ...(extent ? { extent } : {}) },
};
}
const GOOGLE_FOUND_NOTHING = { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
test('keyless: a key that geocodes to nothing still anchors the annotation', async (t) => {
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
features: [photonFeature({
name: 'Lady Bird Lake',
lat: 30.25,
lon: -97.73,
city: 'Austin',
state: 'Texas',
country: 'United States',
tags: { osm_key: 'natural', osm_value: 'water' },
// Photon orders extent [west, north, east, south] — a naive [w,s,e,n]
// read would invert the box and still look like a valid viewport.
extent: [-97.8, 30.28, -97.68, 30.24],
})],
}),
};
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
target: 'Lady Bird Lake',
});
assert.ok(resolved, 'a Google miss must not leave the annotation unplaced');
assert.equal(resolved.source, 'geocode');
assert.deepEqual([resolved.lat, resolved.lon], [30.25, -97.73]);
// The label is shortened the same way a Google `formatted_address` is.
assert.equal(resolved.label, 'Lady Bird Lake');
// Photon's extent reaches the framing code in the Places `{low,high}` shape,
// right way up — this is what sizes a grounds disc and the flyTo box.
assert.deepEqual(resolved.viewport, {
low: { latitude: 30.24, longitude: -97.8 },
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s words', async (t) => {
// The reason `primaryName` is carried out of Photon at all. The ask names a
// landmark AND its surroundings; the geocoded feature is called just "Tejano
// Monument". Score the OSM candidates on the raw utterance and the locality
// tokens win — this is the Thompson-Austin bug (field test 7) reached through
// the keyless path. Score them on the canonical name and the monument wins.
const decoy = squareWay(ANCHOR, -250, -250, 3000, {
building: 'yes', tourism: 'hotel', name: 'Texas Capitol Austin Visitor Center',
});
const monument = squareWay(ANCHOR, 5, 5, 300, { tourism: 'artwork', name: 'Tejano Monument' });
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
ok: true,
json: async () => ({
features: [photonFeature({
name: 'Tejano Monument',
lat: ANCHOR.lat,
lon: ANCHOR.lon,
city: 'Austin',
tags: { osm_key: 'historic', osm_value: 'memorial' },
})],
}),
};
}
assert.equal(href, '/api/overpass');
assert.equal(init?.method, 'POST');
return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ elements: [decoy, monument] }) };
});
const resolved = await resolveAnnotationTarget({
viewer: closeViewportViewer(),
target: 'the monument near the Texas Capitol, Austin',
footprint: true,
});
assert.ok(resolved.ring, 'the monument polygon is monument-scale and exactly named — it must outline');
// The anchor re-centres on the chosen polygon, so its position IS the verdict:
// the monument sits on the anchor, the decoy 354 m south-west of it.
assert.ok(
approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon) < 50,
`outlined a polygon ${Math.round(approximateMetres(resolved.lat, resolved.lon, ANCHOR.lat, ANCHOR.lon))} m from the monument`,
);
});
/** Metres between two coordinates — enough precision for a few hundred metres. */
function approximateMetres(lat1, lon1, lat2, lon2) {
const mLat = 111320;
const dy = (lat1 - lat2) * mLat;
const dx = (lon1 - lon2) * mLat * Math.cos((lat1 * Math.PI) / 180);
return Math.hypot(dx, dy);
}
test('keyless: a Photon outage is not remembered as "no such place"', async (t) => {
// Google answering ZERO_RESULTS is a verdict about GOOGLE. Photon holds
// plenty of places Google does not — that asymmetry is why this fallback
// exists — so a Google verdict plus a Photon timeout must leave the query
// open. Caching it would keep an annotation unplaceable for the rest of the
// session, on a network that has since recovered.
const photonUp = {
ok: true,
json: async () => ({
features: [photonFeature({
name: 'Waller Creek', lat: 30.2665, lon: -97.7385, city: 'Austin',
tags: { osm_key: 'waterway', osm_value: 'stream' },
})],
}),
};
let photonReachable = false;
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
const target = 'Waller Creek';
assert.equal(await resolveAnnotationTarget({ viewer: closeViewportViewer(), target }), null);
const duringOutage = requests.length;
photonReachable = true;
const retried = await resolveAnnotationTarget({ viewer: closeViewportViewer(), target });
assert.ok(retried, 'the same target must resolve once the network is back');
assert.deepEqual([retried.lat, retried.lon], [30.2665, -97.7385]);
assert.ok(
requests.length > duringOutage,
'the retry was served from a poisoned cache instead of asking again',
);
});
test('a not-found is remembered only when every source consulted gave a verdict', async () => {
// The full truth table, because the no-key row is the one the behavioural
// tests above cannot reach: `import.meta.env` only exists under Vite, so
// `node --test` can never take the branch that skips Google entirely.
const cases = [
// no key: Photon is the only source, so Photon alone decides
[{ googleConsulted: false, photonAnswered: true }, true],
[{ googleConsulted: false, photonAnswered: false }, false],
// with a key: Google must have said "no such place", AND Photon must have replied
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: true }, true],
[{ googleConsulted: true, googleSaysNoSuchPlace: true, photonAnswered: false }, false],
// Google declined (REQUEST_DENIED / OVER_QUERY_LIMIT) — never a verdict
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: true }, false],
[{ googleConsulted: true, googleSaysNoSuchPlace: false, photonAnswered: false }, false],
];
for (const [sources, expected] of cases) {
const providers = [sources.googleConsulted ? Boolean(sources.googleSaysNoSuchPlace) : true, sources.photonAnswered]
.map((answered) => ({ geocode: async () => ({ place: null, answered }) }));
assert.equal((await createPlaceSearch({ providers }).geocode('miss')).answered, expected, JSON.stringify(sources));
}
// An omitted Google verdict defaults to "did not say no such place".
});
function resolveAnnotationTarget(options) {
return resolveTarget({ placeSearch: createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ }), ...options });
}
+2 -2
View File
@@ -10,13 +10,13 @@ import { createHybridAnnotationRenderer } from './hybridAnnotationRenderer.js';
* footprints + screen-space SVG for callouts/rings/arrows. The engine, resolver,
* and voice tool wiring are identical to the other two branches.
*/
export function initAnnotations({ viewer, tileset = null }) {
export function initAnnotations({ viewer, tileset = null, placeSearch }) {
// World-space footprint draping; clamped marks can use the photoreal tiles.
if (tileset) {
try { tileset.enableCollision = true; } catch { /* older tileset */ }
}
const renderer = createHybridAnnotationRenderer(viewer);
const engine = createAnnotationEngine({ viewer, renderer });
const engine = createAnnotationEngine({ viewer, renderer, placeSearch });
window.__gevAnnotations = engine;
return engine;
}
+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:
+405
View File
@@ -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<?{lat:number, lng:number, name:string, label:string, types:string[],
* viewport:?object}>}
*/
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 }),
};
}
+446
View File
@@ -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 <place>" 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);
});
+18 -28
View File
@@ -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;
+2 -1
View File
@@ -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;
+66
View File
@@ -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 };
}
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
export { createPhotonGeocoder } from '../keylessGeocoder.js';
+65
View File
@@ -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;
},
};
}
+101
View File
@@ -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);
});
+12 -5
View File
@@ -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 }),
});
}
+5 -1
View File
@@ -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());
+32
View File
@@ -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 }),
],
});
}
+3 -1
View File
@@ -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,
+10 -1
View File
@@ -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);
}
}
+18 -32
View File
@@ -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,
+125 -2
View File
@@ -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 }); }
+2 -2
View File
@@ -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 });
+6 -1
View File
@@ -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 }); }