mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
30 files changed
+1621
-151
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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([{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
|
||||
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
|
||||
export { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
@@ -0,0 +1,65 @@
|
||||
/** A missing geocoder returns an unavailable outcome without making requests. */
|
||||
export const unavailablePlaceSearch = Object.freeze({
|
||||
async geocode(_query, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
return { place: null, answered: false };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Compose ordered geocoders into one bounded service. Each returns
|
||||
* { place: { lat, lng, name, label, types, viewport } | null, answered: boolean }.
|
||||
* Only definitive results are cached. Caller cancellation stops the whole chain.
|
||||
*/
|
||||
export function createPlaceSearch({
|
||||
providers,
|
||||
signal: lifetime,
|
||||
timeoutMs = 12_000,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const cache = new Map();
|
||||
return {
|
||||
async geocode(query, { bias = null, signal } = {}) {
|
||||
const combined = AbortSignal.any(
|
||||
[lifetime, signal, AbortSignal.timeout(timeoutMs)].filter(Boolean),
|
||||
);
|
||||
combined.throwIfAborted();
|
||||
const text = String(query ?? '').trim();
|
||||
if (!text) return { place: null, answered: true };
|
||||
const key = `${bias || ''}\n${text.toLowerCase()}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.expires > now()) return cached.result;
|
||||
cache.delete(key);
|
||||
let result = { place: null, answered: providers.length > 0 };
|
||||
try {
|
||||
for (const [index, provider] of providers.entries()) {
|
||||
combined.throwIfAborted();
|
||||
const next = await provider.geocode(text, { bias, signal: combined });
|
||||
combined.throwIfAborted();
|
||||
result = {
|
||||
place: next.place,
|
||||
answered: Boolean(next.place) || (result.answered && next.answered),
|
||||
};
|
||||
if (result.place) {
|
||||
result.fallbackUsed = index > 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lifetime?.throwIfAborted();
|
||||
signal?.throwIfAborted();
|
||||
if (!combined.aborted) throw error;
|
||||
return { place: null, answered: false };
|
||||
}
|
||||
combined.throwIfAborted();
|
||||
if (result.answered) {
|
||||
cache.set(key, {
|
||||
result,
|
||||
expires: now() + (result.place ? 300_000 : 30_000),
|
||||
});
|
||||
while (cache.size > 64) cache.delete(cache.keys().next().value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
Vendored
+5
-1
@@ -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());
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 }); }
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }); }
|
||||
Reference in new issue
Block a user