feat(search): compose Google and Photon through an explicit provider service

This commit is contained in:
Sameh Khamis committed 2026-09-12 15:38:43 -07:00
commit 57d7f9fc23
30 files changed
+1621 -151

No files matched your search

+18 -32
View File
@@ -17,6 +17,7 @@ import militaryAwarenessLayer, {
import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js';
import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
@@ -287,7 +288,7 @@ export function readLayerLifecycleSummary(dataManager, layerId, { fallbackEnable
};
}
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) {
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) {
installViewTargetPrewarm(viewer);
initCameraVerbs(viewer, getViewTargetCartesian);
return async function runGevAction(name, rawArgs = {}, runOptions = {}) {
@@ -510,7 +511,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
};
const nearest = await createAnalystEngine(analystProviders(viewer, dataManager, {
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER },
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER }, placeSearch,
})).query({
layers: [layerId],
scope: { kind: 'view' },
@@ -769,6 +770,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
if (name === 'fly_to_location') {
return flyToRequestedLocation(viewer, args, {
placeSearch, signal: runOptions.signal,
runImmediate: typeof styleManager?.runImmediateLocationNavigation === 'function'
? (navigate) => styleManager.runImmediateLocationNavigation(navigate)
: null,
@@ -814,7 +816,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
}
if (name === 'analyst_query') {
return runAnalystQuery(viewer, dataManager, args);
return runAnalystQuery(viewer, dataManager, args, placeSearch);
}
if (name === 'move_camera') {
@@ -899,7 +901,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene
}
if (name === 'control_radio') {
return controlRadio(viewer, dataManager, args, runOptions);
return controlRadio(viewer, dataManager, args, { ...runOptions, placeSearch });
}
if (name === 'track_entity') {
@@ -1264,30 +1266,12 @@ async function resolveRadioLocation(args = {}, coordinates = radioCoordinatePair
const known = knownRadioLocation(query, args.locationId);
if (known) return known;
if (!query) return null;
const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) throw new Error('No Google Maps API key available for Radio location search');
const controller = new AbortController();
const cancelFromTurn = () => controller.abort();
if (options.signal?.aborted) throw radioAbortError();
options.signal?.addEventListener('abort', cancelFromTurn, { once: true });
const timer = setTimeout(() => controller.abort(), 6000);
try {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`;
const response = await fetch(url, { signal: controller.signal });
const body = await response.json();
if (!radioActionIsCurrent(options)) throw radioAbortError();
const result = body.status === 'OK' ? body.results?.[0] : null;
if (!result?.geometry?.location) return null;
return {
lat: result.geometry.location.lat,
lon: result.geometry.location.lng,
label: result.formatted_address || query,
country: '',
};
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', cancelFromTurn);
}
const { placeSearch = unavailablePlaceSearch, signal } = options;
const { place } = await placeSearch.geocode(query, { signal });
if (!radioActionIsCurrent(options)) throw radioAbortError();
if (!place) return null;
// Localized provider country labels must not become station country filters.
return { lat: place.lat, lon: place.lng, label: place.label || query, country: '' };
}
/** Voice Radio controls over the Radio layer's public player surface. */
@@ -2206,6 +2190,7 @@ function normalizeStyle(value) {
}
async function flyToRequestedLocation(viewer, args, {
placeSearch = unavailablePlaceSearch, signal,
onStart = null,
runImmediate = null,
beginDeferred = null,
@@ -2321,6 +2306,7 @@ async function flyToRequestedLocation(viewer, args, {
if (generation === false) return cancelled(query);
const managedDeferred = typeof reassertDeferred === 'function';
const destination = await searchAndFlyTo(viewer, query, {
placeSearch, signal,
...(rangeM ? { range: rangeM } : {}),
forceClose: args.viewMode === 'close',
// 'overview' frames the geocode viewport even for precise-place results —
@@ -3310,7 +3296,7 @@ function activeContactsWindow() {
}
}
function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {}) {
function analystProviders(viewer, dataManager, { recordLimitByLayer = null, placeSearch = unavailablePlaceSearch } = {}) {
return {
getRecords(layerKey) {
const layer = dataManager.layers.get(layerKey);
@@ -3322,7 +3308,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {
? (mod.getAnalystRecords(requestedLimit) || [])
: (mod.getAnalystRecords() || []);
},
resolveRegionRing: (name) => resolveRegionRingForQuery(name),
resolveRegionRing: (name) => resolveRegionRingForQuery(name, undefined, placeSearch),
/**
* The active Contacts subject, when there is one — the centre the operator
* is reasoning about while Contacts is up. Null whenever Contacts is off,
@@ -3356,8 +3342,8 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null } = {
};
}
async function runAnalystQuery(viewer, dataManager, args = {}) {
if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager));
async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = unavailablePlaceSearch) {
if (!_analystEngine) _analystEngine = createAnalystEngine(analystProviders(viewer, dataManager, { placeSearch }));
const result = await _analystEngine.query({
layers: Array.isArray(args.layers) ? args.layers : undefined,
scope: args.scope,
+125 -2
View File
@@ -1,3 +1,4 @@
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
@@ -6,11 +7,12 @@ import { getContextStore, registerEntityContext } from '../data/contextStore.js'
import { DataLayerManager } from '../data/manager.js';
import { getActiveCameraMotion, interruptCameraMotion, moveCamera } from '../cameraVerbs.js';
import { reassertNavigationHandoff, runExplicitNavigation } from '../navigationPolicy.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
import {
controlCctv,
controlRadio,
createGevActionRunner,
controlRadio as runControlRadio,
createGevActionRunner as createActionRunner,
cctvVoiceFocusOutcome,
formatTrackedEntityLabel,
knownRadioLocation,
@@ -3017,3 +3019,124 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it',
assert.equal(result.window.centeredOn, 'N546PC');
});
});
// ── Keyless Radio location ───────────────────────────────────────────────────
//
// `resolveRadioLocation` used to be Google-only: no key threw, and a key that
// geocoded to nothing returned null, which the caller reports as "Could not
// resolve Radio location". Both now fall through to Photon. These drive the
// second case, because it reaches the SAME fallback through a running Google
// branch — the no-key branch cannot be driven here, since the key expression
// reads `import.meta.env`, which only Vite defines.
/** Photon's GeoJSON shape, trimmed to the properties the adapter consumes. */
function photonFeature({ name, lat, lon, city = '', country = '' }) {
return {
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { name, city, country, osm_key: 'place', osm_value: 'city' },
};
}
/** A Radio layer that records what it was asked to select. */
function radioSelectionHarness() {
let enabled = false;
const calls = [];
const state = {
stationCount: 4, filter: 'all', selected: null,
audioState: 'stopped', volume: 0.8, voiceDucked: false,
};
const radio = {
getUIState: () => ({ ...state }),
selectRequestedStation(criteria, options) {
calls.push({ criteria, options });
state.selected = { id: 'kl-1', name: 'Keyless FM' };
return state.selected;
},
};
return {
calls,
dataManager: {
layers: new Map([['radio', { module: radio }]]),
isEnabled: () => enabled,
async setEnabled(_id, value) { enabled = value; },
},
};
}
/** Install a Google key plus a fetch stub, restoring both afterwards. */
function installKeyedFetch(t, handler) {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const priorKey = globalThis.window.__GOOGLE_MAPS_API_KEY__;
const priorFetch = globalThis.fetch;
globalThis.window.__GOOGLE_MAPS_API_KEY__ = 'unit-test-key';
globalThis.fetch = handler;
t.after(() => {
globalThis.fetch = priorFetch;
if (priorKey === undefined) delete globalThis.window.__GOOGLE_MAPS_API_KEY__;
else globalThis.window.__GOOGLE_MAPS_API_KEY__ = priorKey;
});
}
test('voice Radio: a key that geocodes to nothing still places the station, keylessly', async (t) => {
const { calls, dataManager } = radioSelectionHarness();
const requests = [];
installKeyedFetch(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
}
assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/);
return {
ok: true,
json: async () => ({
features: [photonFeature({
name: 'Hạ Long Bay', lat: 20.9101, lon: 107.1839, city: 'Hạ Long', country: 'Việt Nam',
})],
}),
};
});
const result = await controlRadio({}, dataManager, {
action: 'select', locationQuery: 'Hạ Long Bay',
});
// Before the fallback existed this was `ok: false, "Could not resolve Radio location"`.
assert.equal(result.ok, true);
assert.equal(result.requestedLocation, 'Hạ Long Bay, Hạ Long, Việt Nam');
assert.equal(calls.length, 1);
assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9);
assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9);
// Google is asked first and exactly once; Photon answers unbiased, in one call.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/);
assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased');
});
test('voice Radio: the keyless path applies no country filter the keyed path would not', async (t) => {
// Photon reports the country in the feature's own language ("Việt Nam"), and
// `rankRadioStationsForRequest` fails CLOSED on a country it cannot map —
// returning NO stations. Forwarding it would make a keyless install answer
// "No Radio station matched" for exactly the places it just resolved, while a
// keyed install placed a station. The label may carry it; the filter may not.
const { calls, dataManager } = radioSelectionHarness();
installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/')
? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }
: {
ok: true,
json: async () => ({
features: [photonFeature({ name: 'Kraków', lat: 50.0614, lon: 19.9366, country: 'Polska' })],
}),
}));
const result = await controlRadio({}, dataManager, { action: 'select', locationQuery: 'Kraków' });
assert.equal(result.ok, true);
assert.equal(calls[0].criteria.country, '', 'a localized country name must never reach the station filter');
assert.equal(normalizeRadioCountryInput('Polska').valid, false, 'and this is why: it would match nothing');
assert.equal(result.requestedLocation, 'Kraków, Polska', 'the label still names the country honestly');
});
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }
+2 -2
View File
@@ -194,11 +194,11 @@ export function silenceRadioForVoice({ duckRadio, pauseRadio } = {}) {
*/
const SUPERSEDED_RESPONSE_MEMORY = 8;
export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null }) {
export function initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch }) {
if (window.__gevVoiceCommands && typeof window.__gevVoiceCommands.stop === 'function') {
window.__gevVoiceCommands.stop({ removeUi: true });
}
const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations });
const runner = createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector, annotations, placeSearch });
const ui = createVoiceControl({ reset: true });
const radioLayer = dataManager?.layers?.get('radio')?.module || null;
const controller = new GevRealtimeController({ runner, ui, radioLayer, dataManager });
+6 -1
View File
@@ -4,7 +4,8 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataLayerManager } from '../data/manager.js';
import { controlRadio, createGevActionRunner } from './gevActions.js';
import { controlRadio as runControlRadio, createGevActionRunner as createActionRunner } from './gevActions.js';
import { createStandalonePlaceSearch } from '../standalone/placeSearch.js';
import {
computeDownscale,
renderFreshCesiumFrame,
@@ -3669,3 +3670,7 @@ test('a genuinely different refused call still gets its own output', async () =>
await controller.handleRealtimeEvent(lateToolItemEvent('resp_old', 'call_two', 'item_two'));
assert.deepEqual(outputs, ['call_one', 'call_two'], 'each distinct call is answered');
});
const testPlaceSearch = () => createStandalonePlaceSearch({ resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ });
function createGevActionRunner(options) { return createActionRunner({ placeSearch: testPlaceSearch(), ...options }); }
function controlRadio(viewer, manager, args, options) { return runControlRadio(viewer, manager, args, { placeSearch: testPlaceSearch(), ...options }); }