mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(search): compose Google and Photon through an explicit provider service
This commit is contained in:
commit
57d7f9fc23
30 files changed
+1621
-151
No files matched your search
+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