mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Review follow-ups for HAL-722. A Next or Previous that only selects a station now leaves nothing of the previous stream behind: - Selecting another station without playing stops a paused or failed stream, so the panel, Stop, voice summaries and the audio element agree on "stopped". A fallback the caller armed for the new selection is kept. - A silent UI step arms no broadcaster fallback, and a user Play of the selected station drops one left by a cancelled voice step. Voice steps pass `fallback: true` because voice starts the prepared playback later. - A user step to another station emits a `select` playback control, and the realtime voice handoff treats it like Pause and Stop. - A tuner release onto a station the directory no longer has stops the stream the drag paused, publishing one state with the gesture's origin, so the button reads PLAY and a voice handoff is cancelled. - The transport buttons offer Pause while a stream connects, matching what a click does. - The privacy note names every action that connects to a broadcaster (play, finishing tuning, choosing a station on the globe) in all locales. The Next/Previous layer tests live in src/data/radioNextState.test.mjs.
5095 lines
164 KiB
JavaScript
5095 lines
164 KiB
JavaScript
import { interruptGlobeRotation } from '../cameraVerbs.js';
|
|
import {
|
|
layerSnapshot,
|
|
feedProvenanceEnvelope,
|
|
} from '../data/layerSnapshot.js';
|
|
import { readLayerLifecycleSummary } from './layerSummary.js';
|
|
import { SPEECH_BUILDERS, attachVoiceResult } from './speech.js';
|
|
import { resolveDeicticArgs } from './deixis.js';
|
|
export { readLayerLifecycleSummary } from './layerSummary.js';
|
|
import { defaultGeospatial } from '../search/defaults.js';
|
|
import { msg } from '../i18n/index.js';
|
|
import * as Cesium from 'cesium';
|
|
import { CITY_POIS } from '../cityPois.js';
|
|
import { greatCircleKm } from '../geoDistance.js';
|
|
import {
|
|
hudViewportSamples,
|
|
placeLookupView,
|
|
placeLookupViewChanged,
|
|
} from '../hudPlaceLookup.js';
|
|
import {
|
|
findPoiByName,
|
|
flyToGlobeView,
|
|
flyToLandmark,
|
|
flyToPOI,
|
|
flyToPresetLocation,
|
|
GLOBE_VIEW,
|
|
searchAndFlyTo,
|
|
} from '../locations.js';
|
|
import {
|
|
getContextStore,
|
|
getSelectedEntityContext,
|
|
isContextRecordActive,
|
|
} from '../data/contextStore.js';
|
|
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
|
import {
|
|
createAnalystEngine,
|
|
normalizeAnalystSpec,
|
|
selectAnalystRows,
|
|
} from '../data/analystEngine.js';
|
|
import { voiceLayer, voiceLayerAliasMap } from './layerManifest.js';
|
|
import { layerFeedState } from '../data/feedState.js';
|
|
import {
|
|
initCameraVerbs,
|
|
moveCamera,
|
|
flyRoute,
|
|
interruptCameraMotion,
|
|
adjustOrbitRange,
|
|
} from '../cameraVerbs.js';
|
|
import * as defaultFloorServices from '../data/groundFloor.js';
|
|
import { isPickedWorldPosition } from '../data/scenePick.js';
|
|
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
|
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
|
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
|
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
|
import {
|
|
getWorldPacket,
|
|
withContextModeVocabulary,
|
|
activeContactsWindow,
|
|
TRACKABLE_FAMILIES,
|
|
} from '../agent/worldPacket.js';
|
|
|
|
const ALLOWED_STYLES = new Set([
|
|
'normal',
|
|
'retro',
|
|
'surveillance',
|
|
'thermal',
|
|
'anime',
|
|
'noir',
|
|
'snow',
|
|
]);
|
|
const PANEL_ALIASES = new Map([
|
|
['data', 'data-panel'],
|
|
['data layers', 'data-panel'],
|
|
['layers', 'data-panel'],
|
|
['layer menu', 'data-panel'],
|
|
['data layer menu', 'data-panel'],
|
|
['locations', 'location-bar'],
|
|
['location', 'location-bar'],
|
|
['styles', 'control-panel'],
|
|
['filters', 'control-panel'],
|
|
['visual styles', 'control-panel'],
|
|
['visual preset', 'control-panel'],
|
|
['visual presets', 'control-panel'],
|
|
['provider settings', 'provider-settings'],
|
|
['cctv', 'cctv-panel'],
|
|
['cameras', 'cctv-panel'],
|
|
['voice settings', 'voice-settings'],
|
|
['voice-settings', 'voice-settings'],
|
|
['voice preview', 'voice-preview'],
|
|
['voice-preview', 'voice-preview'],
|
|
['voice control', 'voice-preview'],
|
|
['voice controls', 'voice-preview'],
|
|
['radio', 'radio-panel'],
|
|
['internet radio', 'radio-panel'],
|
|
['radio stations', 'radio-panel'],
|
|
['context', 'global-context-panel'],
|
|
['context panel', 'global-context-panel'],
|
|
['global context', 'global-context-panel'],
|
|
['right context', 'global-context-panel'],
|
|
['context right panel', 'global-context-panel'],
|
|
['scenes', 'scene-panel'],
|
|
['scene', 'scene-panel'],
|
|
['post processing', 'pp-toggles'],
|
|
['hud controls', 'pp-toggles'],
|
|
['map stack', 'control-panel'],
|
|
['stack', 'control-panel'],
|
|
['basemap', 'control-panel'],
|
|
['map sources', 'control-panel'],
|
|
['sources', 'control-panel'],
|
|
['weather', 'weather-panel'],
|
|
['weather panel', 'weather-panel'],
|
|
['recent imagery', 'recent-imagery-panel'],
|
|
['imagery panel', 'recent-imagery-panel'],
|
|
]);
|
|
|
|
const PANEL_IDS = new Set([
|
|
'data-panel',
|
|
'location-bar',
|
|
'control-panel',
|
|
'cctv-panel',
|
|
'radio-panel',
|
|
'global-context-panel',
|
|
'scene-panel',
|
|
'pp-toggles',
|
|
'weather-panel',
|
|
'recent-imagery-panel',
|
|
'provider-settings',
|
|
]);
|
|
const CONTEXT_MODE_ALIASES = new Map([
|
|
['off', 'off'],
|
|
['none', 'off'],
|
|
['clear', 'off'],
|
|
['contacts', 'flights'],
|
|
['contact', 'flights'],
|
|
['flights', 'flights'],
|
|
['space missions', 'space-missions'],
|
|
['space-mission', 'space-missions'],
|
|
['space mission', 'space-missions'],
|
|
['space-missions', 'space-missions'],
|
|
['missions', 'space-missions'],
|
|
]);
|
|
const COCKPIT_ACTION_ALIASES = new Map([
|
|
['next', 'next'],
|
|
['previous', 'previous'],
|
|
['prev', 'previous'],
|
|
['enter', 'enter'],
|
|
['exit', 'exit'],
|
|
['status', 'status'],
|
|
['state', 'status'],
|
|
['next military', 'next'],
|
|
['next military aircraft', 'next'],
|
|
['next helicopter', 'next'],
|
|
['next closest', 'next'],
|
|
['next closest helicopter', 'next'],
|
|
['next closest military', 'next'],
|
|
['go to next', 'next'],
|
|
]);
|
|
const COCKPIT_TARGET_LAYERS = new Set([
|
|
'flights',
|
|
'military',
|
|
'ais-live-vessels',
|
|
'military-installations',
|
|
]);
|
|
|
|
// Spoken layer names come from the voice layer manifest, the same source the
|
|
// Realtime tool enums are generated from.
|
|
const LAYER_ALIASES = voiceLayerAliasMap();
|
|
|
|
const CITY_ALIASES = new Map([
|
|
['new york', 'nyc'],
|
|
['new york city', 'nyc'],
|
|
['san francisco', 'sf'],
|
|
['washington', 'dc'],
|
|
['washington dc', 'dc'],
|
|
['washington d.c.', 'dc'],
|
|
]);
|
|
|
|
// Basemap stack vocabulary. Switching requires an explicit stack name
|
|
// ("Bing aerial", "road map", "OSM", "Google 3D") — any "satellite(s)"
|
|
// phrasing ALWAYS means the satellites DATA LAYER, never a basemap; the
|
|
// session instructions carry the decision table.
|
|
//
|
|
// Road phrasings resolve to OSM, the one shipped road basemap. Every alias
|
|
// must name a live `MAP_STACKS` id: an alias for a retired stack would resolve
|
|
// cleanly and then fail at the controller with "Unknown map stack", which reads
|
|
// to the operator as a broken command rather than a retired source.
|
|
const STACK_ALIASES = new Map([
|
|
['photoreal', 'photoreal'],
|
|
['google 3d', 'photoreal'],
|
|
['google', 'photoreal'],
|
|
['3d', 'photoreal'],
|
|
['photorealistic', 'photoreal'],
|
|
['bing-aerial', 'bing-aerial'],
|
|
['bing aerial', 'bing-aerial'],
|
|
['bing-labels', 'bing-labels'],
|
|
['bing labels', 'bing-labels'],
|
|
['labels', 'bing-labels'],
|
|
['aerial with labels', 'bing-labels'],
|
|
['esri-imagery', 'esri-imagery'],
|
|
['esri', 'esri-imagery'],
|
|
['esri imagery', 'esri-imagery'],
|
|
['esri satellite', 'esri-imagery'],
|
|
['osm', 'osm'],
|
|
['openstreetmap', 'osm'],
|
|
['open street map', 'osm'],
|
|
['road', 'osm'],
|
|
['roads', 'osm'],
|
|
['road map', 'osm'],
|
|
]);
|
|
|
|
const FRAME_TARGETS = new Map([
|
|
['flights', 'flights'],
|
|
['planes', 'flights'],
|
|
['aircraft', 'flights'],
|
|
['military', 'military'],
|
|
['military flights', 'military'],
|
|
['satellites', 'satellites'],
|
|
['vessels', 'ais-live-vessels'],
|
|
['ships', 'ais-live-vessels'],
|
|
]);
|
|
|
|
const serviceCaches = new WeakMap();
|
|
function cachesFor(service) {
|
|
service.signal?.throwIfAborted();
|
|
let caches = serviceCaches.get(service);
|
|
if (!caches) {
|
|
caches = {
|
|
reverseGeocodeCache: new Map(),
|
|
reverseGeocodeInFlight: new Map(),
|
|
nearbyPlacesCache: new Map(),
|
|
nearbyPlacesInFlight: new Map(),
|
|
};
|
|
serviceCaches.set(service, caches);
|
|
service.signal?.addEventListener(
|
|
'abort',
|
|
() => {
|
|
for (const cache of Object.values(caches)) cache.clear();
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
return caches;
|
|
}
|
|
const VISIBLE_ENTITY_SHORTLIST = 64;
|
|
const BASEMAP_CONTEXT_WAIT_MS = 1500;
|
|
const viewTargetCache = new WeakMap();
|
|
|
|
/** Create application actions over the supplied scene and services. */
|
|
export function createGevActionRunner({
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
sceneDirector = null,
|
|
annotations = null,
|
|
placeSearch = unavailablePlaceSearch,
|
|
floorServices = defaultFloorServices,
|
|
annotationResolver = defaultAnnotationResolver,
|
|
searchNavigation = searchAndFlyTo,
|
|
speechBuilders = null,
|
|
deixis = null,
|
|
readVoicePanelOperation = null,
|
|
}) {
|
|
// Voice enable times and analyst follow-up memory belong to this runner.
|
|
// Speech builders turn results into say/display envelopes; callers extend
|
|
// them (e.g. an analyst_query builder) without replacing the defaults.
|
|
const builders = speechBuilders
|
|
? { ...SPEECH_BUILDERS, ...speechBuilders }
|
|
: SPEECH_BUILDERS;
|
|
const _layerEnabledAt = new Map();
|
|
let analystEngine;
|
|
const resolveRegionRing = (name, signal) =>
|
|
annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch);
|
|
const ensureAnalystEngine = () =>
|
|
(analystEngine ||= createAnalystEngine(
|
|
analystProviders(viewer, dataManager, {
|
|
placeSearch,
|
|
resolveRegionRing,
|
|
isWarming: (layerKey) => layerIsWarming(_layerEnabledAt, layerKey),
|
|
}),
|
|
));
|
|
installViewTargetPrewarm(viewer);
|
|
initCameraVerbs(viewer, getViewTargetCartesian, {
|
|
isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active),
|
|
});
|
|
// Nested calls (select_nearest_aircraft composing layer + flight) use the
|
|
// raw runner; only the outer call gets a voice envelope.
|
|
const voiceUnavailable = (action) => ({
|
|
ok: false,
|
|
action,
|
|
applied: false,
|
|
unavailable: true,
|
|
error: 'Voice panel controls are unavailable in this host.',
|
|
say: 'Voice panel controls are unavailable in this host.',
|
|
});
|
|
async function showVoicePanel(
|
|
view,
|
|
open,
|
|
options,
|
|
operation = readVoicePanelOperation?.(),
|
|
) {
|
|
const current = () =>
|
|
!options.signal?.aborted &&
|
|
(!options.isCurrent || options.isCurrent()) &&
|
|
operation?.isCurrent();
|
|
if (!operation) return voiceUnavailable('set_panel_open');
|
|
if (!current())
|
|
return {
|
|
ok: false,
|
|
action: 'set_panel_open',
|
|
applied: false,
|
|
cancelled: true,
|
|
};
|
|
let exit = null;
|
|
if (open) {
|
|
if (typeof styleManager?.getCockpitState !== 'function')
|
|
return voiceUnavailable('set_panel_open');
|
|
if (styleManager.getCockpitState()?.active) {
|
|
if (typeof styleManager.controlCockpit !== 'function')
|
|
return voiceUnavailable('set_panel_open');
|
|
exit = await styleManager.controlCockpit('exit');
|
|
if (styleManager.getCockpitState()?.active)
|
|
return {
|
|
ok: false,
|
|
action: 'set_panel_open',
|
|
applied: false,
|
|
exit,
|
|
error: exit?.error || 'Cockpit did not exit.',
|
|
};
|
|
}
|
|
}
|
|
if (!current())
|
|
return {
|
|
ok: false,
|
|
action: 'set_panel_open',
|
|
applied: false,
|
|
cancelled: true,
|
|
exit,
|
|
cockpitExited: Boolean(exit && !styleManager.getCockpitState()?.active),
|
|
say:
|
|
exit && !styleManager.getCockpitState()?.active
|
|
? 'Cockpit exited; the voice panel was left as you last set it.'
|
|
: 'The voice panel was left as you last set it.',
|
|
};
|
|
const result = operation.show(view, open);
|
|
return {
|
|
...result,
|
|
action: 'set_panel_open',
|
|
panelId: view === 'settings' ? 'voice-settings' : 'voice-preview',
|
|
exit,
|
|
state: styleManager?.getCockpitState?.() ?? null,
|
|
say: !result.ok
|
|
? 'The voice panel was not changed.'
|
|
: result.settingsOpen
|
|
? 'Voice Settings is open.'
|
|
: result.panelOpen
|
|
? result.previewEnabled
|
|
? 'Voice Preview is open.'
|
|
: 'Voice control is open; Preview remains off.'
|
|
: 'The voice panel is closed.',
|
|
};
|
|
}
|
|
const runGevAction = async function runGevAction(
|
|
name,
|
|
rawArgs = {},
|
|
runOptions = {},
|
|
) {
|
|
const args = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
|
|
const current = () =>
|
|
!runOptions.signal?.aborted &&
|
|
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
|
// Step reports for voice narration and the voice card; never fatal.
|
|
const reportProgress = (step, label = '') => {
|
|
try {
|
|
runOptions.progress?.({ step, label: String(label || '') });
|
|
} catch {
|
|
/* Progress is advisory. */
|
|
}
|
|
};
|
|
|
|
// Navigation tools interrupt any continuous camera motion (spec §1.1) —
|
|
// checked FIRST because each handler returns.
|
|
if (name === 'zoom_to_globe') {
|
|
interruptCameraMotion(`nav:${name}`);
|
|
}
|
|
// Explicit navigation while TRACKING supersedes the follow camera —
|
|
// otherwise the tracker drags the view back and "I flew there but can't
|
|
// do anything" (field finding). track_entity manages its own handoff.
|
|
if (name === 'zoom_to_globe' && viewer.trackedEntity) {
|
|
stopAllTracking(viewer, dataManager);
|
|
}
|
|
|
|
// Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) —
|
|
// a straight camera move would be snapped back by the per-frame lookAt.
|
|
if (name === 'adjust_camera_zoom') {
|
|
interruptGlobeRotation('voice-zoom-intent');
|
|
const zoomOut = String(args.direction || '').toLowerCase() === 'out';
|
|
const amt = String(args.amount || 'medium').toLowerCase();
|
|
const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6;
|
|
if (adjustOrbitRange(zoomOut ? factor : 1 / factor)) {
|
|
return {
|
|
ok: true,
|
|
action: 'adjust_camera_zoom',
|
|
direction: zoomOut ? 'out' : 'in',
|
|
amount: amt,
|
|
orbitRadiusAdjusted: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (name === 'set_layer_visibility') {
|
|
const layerId = normalizeLayerId(args.layerId);
|
|
if (!layerId) {
|
|
throw new Error(`Unknown data layer: ${args.layerId || 'missing'}`);
|
|
}
|
|
if (!dataManager.layers.has(layerId)) {
|
|
if (layerId === 'radio') {
|
|
return {
|
|
ok: false,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
error: 'Radio layer unavailable',
|
|
...readLayerLifecycleSummary(dataManager, layerId),
|
|
};
|
|
}
|
|
throw new Error(`Unknown data layer: ${args.layerId || 'missing'}`);
|
|
}
|
|
const enabled = Boolean(args.enabled);
|
|
if (enabled) {
|
|
const pending = dataManager.getAll?.().find((l) => l.id === layerId);
|
|
reportProgress('layer', pending?.name || layerId);
|
|
}
|
|
const changeOptions = { origin: 'voice' };
|
|
if (runOptions.signal) changeOptions.signal = runOptions.signal;
|
|
let changed = false;
|
|
let changeError = null;
|
|
let intentOutcome = null;
|
|
try {
|
|
if (typeof dataManager._setEnabledWithIntent === 'function') {
|
|
const intent = dataManager._setEnabledWithIntent(
|
|
layerId,
|
|
enabled,
|
|
changeOptions,
|
|
);
|
|
changed = await intent.promise;
|
|
if (Number.isInteger(intent.intentEpoch)) {
|
|
intentOutcome = await dataManager._waitForVisibilityIntent?.(
|
|
layerId,
|
|
intent.intentEpoch,
|
|
);
|
|
}
|
|
} else {
|
|
changed = await dataManager.setEnabled(
|
|
layerId,
|
|
enabled,
|
|
changeOptions,
|
|
);
|
|
}
|
|
if (layerId === 'rocket-launches' || layerId === 'satellites') {
|
|
await styleManager?._waitForContextLayerSettlement?.();
|
|
}
|
|
} catch (error) {
|
|
changeError = error;
|
|
}
|
|
const lifecycleSummary = readLayerLifecycleSummary(dataManager, layerId);
|
|
if (
|
|
intentOutcome?.succeeded === false &&
|
|
intentOutcome.cancellationReason
|
|
) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
cancelled: true,
|
|
phase: intentOutcome.phase,
|
|
cancellationReason: intentOutcome.cancellationReason,
|
|
successorIntentEpoch: intentOutcome.successorIntentEpoch,
|
|
successorEnabled: intentOutcome.successorEnabled,
|
|
successorOrigin: intentOutcome.successorOrigin,
|
|
...lifecycleSummary,
|
|
};
|
|
}
|
|
const intentCommitted = intentOutcome?.succeeded === true;
|
|
const current =
|
|
!runOptions.signal?.aborted &&
|
|
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
|
if (!current && !intentCommitted) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
cancelled: true,
|
|
error: 'Layer request was superseded by a newer voice turn',
|
|
...lifecycleSummary,
|
|
};
|
|
}
|
|
const settledEnabled = lifecycleSummary.enabled;
|
|
const lifecycleSettled =
|
|
lifecycleSummary.lifecycleState ===
|
|
(enabled ? 'enabled' : 'disabled') &&
|
|
!lifecycleSummary.lifecycleUncertain;
|
|
if (changeError) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
error:
|
|
changeError?.message ||
|
|
`Could not ${enabled ? 'enable' : 'disable'} the requested layer`,
|
|
...lifecycleSummary,
|
|
};
|
|
}
|
|
if (
|
|
changed === false ||
|
|
settledEnabled !== enabled ||
|
|
!lifecycleSettled
|
|
) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
error: `Could not ${enabled ? 'enable' : 'disable'} the requested layer`,
|
|
...lifecycleSummary,
|
|
};
|
|
}
|
|
if (enabled) _layerEnabledAt.set(layerId, Date.now());
|
|
const layer = dataManager.getAll().find((item) => item.id === layerId);
|
|
const snapshot = layer && enabled ? layerSnapshot(layer) : null;
|
|
return {
|
|
ok: true,
|
|
action: 'set_layer_visibility',
|
|
layerId,
|
|
label: layer?.name || layerId,
|
|
...lifecycleSummary,
|
|
...(snapshot?.feedState ? { feedState: snapshot.feedState } : {}),
|
|
...(snapshot?.source ? { source: snapshot.source } : {}),
|
|
};
|
|
}
|
|
|
|
if (name === 'select_nearest_aircraft') {
|
|
const layerId = normalizeLayerId(args.layerId || 'flights');
|
|
if (!['flights', 'military'].includes(layerId)) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
error:
|
|
'Nearest-aircraft selection supports Flights or Military Flights only',
|
|
};
|
|
}
|
|
const hasLocationId = Boolean(String(args.locationId || '').trim());
|
|
const hasLocationQuery = Boolean(String(args.locationQuery || '').trim());
|
|
const hasCoordinates =
|
|
args.latitude != null &&
|
|
args.longitude != null &&
|
|
Number.isFinite(Number(args.latitude)) &&
|
|
Number.isFinite(Number(args.longitude));
|
|
if (!hasLocationId && !hasLocationQuery && !hasCoordinates) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'location',
|
|
error:
|
|
'Nearest-aircraft selection needs a preset, place name, or latitude and longitude',
|
|
};
|
|
}
|
|
const locationArgs = {
|
|
waitForArrival: true,
|
|
...(args.locationId ? { locationId: args.locationId } : {}),
|
|
...(args.locationQuery ? { query: args.locationQuery } : {}),
|
|
...(hasCoordinates
|
|
? {
|
|
latitude: Number(args.latitude),
|
|
longitude: Number(args.longitude),
|
|
}
|
|
: {}),
|
|
};
|
|
const layer = await runGevAction(
|
|
'set_layer_visibility',
|
|
{
|
|
layerId,
|
|
enabled: true,
|
|
},
|
|
runOptions,
|
|
);
|
|
if (layer?.ok !== true || !current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'layer',
|
|
cancelled: !current() || Boolean(layer?.cancelled),
|
|
error: layer?.error || `${layerId} could not be enabled`,
|
|
layer,
|
|
};
|
|
}
|
|
|
|
const location = await runGevAction(
|
|
'fly_to_location',
|
|
locationArgs,
|
|
runOptions,
|
|
);
|
|
if (location?.ok !== true || !current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'location',
|
|
cancelled: !current() || Boolean(location?.cancelled),
|
|
error:
|
|
location?.error ||
|
|
`Could not arrive at ${location?.label || args.locationQuery || args.locationId || 'the requested place'}`,
|
|
location,
|
|
layer,
|
|
};
|
|
}
|
|
|
|
const layerModule = dataManager.layers.get(layerId)?.module || null;
|
|
let refreshed = false;
|
|
reportProgress('refresh', location.label);
|
|
try {
|
|
if (typeof dataManager.refreshLayer === 'function') {
|
|
refreshed = await dataManager.refreshLayer(layerId, {
|
|
signal: runOptions.signal,
|
|
});
|
|
} else if (typeof layerModule?.update === 'function') {
|
|
refreshed =
|
|
(await layerModule.update(viewer, {
|
|
signal: runOptions.signal,
|
|
})) !== false;
|
|
}
|
|
} catch {
|
|
refreshed = false;
|
|
}
|
|
if (!refreshed || !current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'refresh',
|
|
cancelled: !current(),
|
|
error: !current()
|
|
? 'Nearest-aircraft refresh was cancelled'
|
|
: `${layer.label || layerId} is enabled, but its destination refresh did not complete`,
|
|
location,
|
|
layer,
|
|
};
|
|
}
|
|
const stats = layerModule?.getStats?.() || {};
|
|
const source =
|
|
String(stats.source || layerModule?.source || '').trim() || null;
|
|
const feed = {
|
|
state: layerFeedState({ ...stats, source }),
|
|
source,
|
|
count: Number.isFinite(Number(stats.count))
|
|
? Number(stats.count)
|
|
: null,
|
|
};
|
|
|
|
reportProgress('nearest', location.label);
|
|
const nearest = await createAnalystEngine(
|
|
analystProviders(viewer, dataManager, {
|
|
recordLimitByLayer: { [layerId]: Number.MAX_SAFE_INTEGER },
|
|
placeSearch,
|
|
resolveRegionRing,
|
|
}),
|
|
).query({
|
|
layers: [layerId],
|
|
scope: { kind: 'view' },
|
|
filters: [{ field: 'onGround', op: 'eq', value: false }],
|
|
sortBy: 'distance',
|
|
sortDir: 'asc',
|
|
limit: 1,
|
|
});
|
|
const aircraft = nearest?.items?.[0] || null;
|
|
if (!aircraft || !current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'nearest',
|
|
cancelled: !current(),
|
|
error: !current()
|
|
? 'Nearest-aircraft selection was cancelled'
|
|
: feed.state === 'unavailable'
|
|
? `${layer.label || layerId} is enabled, but ${feed.source || 'its aircraft feed'} is unavailable`
|
|
: `${layer.label || layerId} is enabled${feed.state === 'fallback' ? ` on the ${feed.source || 'fallback'} feed` : ''}, but no airborne aircraft is loaded in the ${location.label || 'destination'} view yet`,
|
|
location,
|
|
layer,
|
|
feed,
|
|
count: nearest?.count || 0,
|
|
};
|
|
}
|
|
|
|
const stableAircraftId = aircraft.icao24 || aircraft.id;
|
|
const selection = await trackEntity(viewer, dataManager, styleManager, {
|
|
query: stableAircraftId,
|
|
layerId,
|
|
});
|
|
if (selection?.ok !== true) {
|
|
return {
|
|
ok: false,
|
|
action: 'select_nearest_aircraft',
|
|
stage: 'selection',
|
|
error:
|
|
selection?.error ||
|
|
'The nearest airborne aircraft could not be selected',
|
|
location,
|
|
layer,
|
|
feed,
|
|
selection,
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
action: 'select_nearest_aircraft',
|
|
location: location.label,
|
|
layerId,
|
|
label: selection.label,
|
|
feed,
|
|
aircraft: {
|
|
id: stableAircraftId,
|
|
callsign: aircraft.callsign || null,
|
|
altitudeM: aircraft.altitudeM ?? null,
|
|
distanceKm: aircraft.distanceKm ?? null,
|
|
onGround: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (name === 'set_visual_style') {
|
|
const style = normalizeStyle(args.style);
|
|
if (!style)
|
|
throw new Error(`Unknown visual style: ${args.style || 'missing'}`);
|
|
styleManager.setStyle(style, { userInitiated: true });
|
|
return { ok: true, action: 'set_visual_style', style };
|
|
}
|
|
|
|
if (name === 'set_voice_inactivity_timeout') {
|
|
const operation = readVoicePanelOperation?.();
|
|
if (!operation) return voiceUnavailable(name);
|
|
if (!current() || !operation.isCurrent())
|
|
return { ok: false, action: name, applied: false, cancelled: true };
|
|
const result = operation.setMinutes(args.minutes);
|
|
const duration =
|
|
result.minutes === 0 ? 'disabled' : `${result.minutes} minutes`;
|
|
return {
|
|
...result,
|
|
action: name,
|
|
say: !result.applied
|
|
? 'Voice inactivity was not changed.'
|
|
: result.persisted
|
|
? `Voice inactivity is ${duration}, saved in this browser.`
|
|
: `Voice inactivity is ${duration} for now, but could not be saved in this browser.`,
|
|
};
|
|
}
|
|
|
|
if (name === 'set_panel_open') {
|
|
const panelId = normalizePanelId(args.panelId || args.panel);
|
|
if (!panelId)
|
|
throw new Error(
|
|
`Unknown panel: ${args.panelId || args.panel || 'missing'}`,
|
|
);
|
|
const open = args.open !== false;
|
|
if (panelId === 'voice-settings' || panelId === 'voice-preview')
|
|
return showVoicePanel(
|
|
panelId === 'voice-settings' ? 'settings' : 'preview',
|
|
open,
|
|
runOptions,
|
|
);
|
|
if (panelId === 'provider-settings') {
|
|
if (!current())
|
|
return {
|
|
ok: false,
|
|
action: name,
|
|
panelId,
|
|
applied: false,
|
|
cancelled: true,
|
|
};
|
|
const doc = globalThis.document;
|
|
const panel = doc?.getElementById?.('key-setup');
|
|
const button = open
|
|
? doc?.getElementById?.('key-setup-chip')
|
|
: panel?.querySelector?.('[data-key-setup-close]');
|
|
const concealed =
|
|
open &&
|
|
[
|
|
'ui-clean-view',
|
|
'recording-mode',
|
|
'cockpit-mode',
|
|
'scene-playback-mode',
|
|
].some((mode) => doc?.body?.classList?.contains(mode));
|
|
if (
|
|
concealed ||
|
|
panel?.dataset?.ready !== 'true' ||
|
|
typeof button?.click !== 'function'
|
|
) {
|
|
const error = concealed
|
|
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
|
|
: 'Provider Settings is unavailable in this runtime.';
|
|
return {
|
|
ok: false,
|
|
action: name,
|
|
panelId,
|
|
applied: false,
|
|
unavailable: true,
|
|
error,
|
|
say: error,
|
|
};
|
|
}
|
|
button.click();
|
|
// Opening unhides immediately; closing removes visible before its fade ends.
|
|
const applied = open
|
|
? !panel.hidden
|
|
: !panel.classList.contains('visible');
|
|
const say = applied
|
|
? `Provider Settings is ${open ? 'open' : 'closed'}.`
|
|
: 'Provider Settings did not change.';
|
|
return {
|
|
ok: applied,
|
|
action: name,
|
|
panelId,
|
|
applied,
|
|
open: applied ? open : !panel.hidden,
|
|
say,
|
|
...(!applied ? { error: say } : {}),
|
|
};
|
|
}
|
|
setPanelOpen(styleManager, panelId, open);
|
|
return { ok: true, action: 'set_panel_open', panelId, open };
|
|
}
|
|
|
|
if (name === 'set_context_mode') {
|
|
if (!styleManager?.setContextMode) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_context_mode',
|
|
error: 'Context mode control unavailable',
|
|
};
|
|
}
|
|
const mode = normalizeContextMode(args.mode || args.contextMode);
|
|
if (
|
|
mode === null &&
|
|
args.mode != null &&
|
|
String(args.mode || '').trim() !== 'off'
|
|
) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_context_mode',
|
|
error: `Unknown context mode: ${args.mode || 'missing'}`,
|
|
};
|
|
}
|
|
const cancellationState = () =>
|
|
withContextModeVocabulary(
|
|
typeof styleManager.getContextModeState === 'function'
|
|
? styleManager.getContextModeState()
|
|
: {},
|
|
);
|
|
if (!current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'set_context_mode',
|
|
cancelled: true,
|
|
error: 'Context request was cancelled before it could run',
|
|
...cancellationState(),
|
|
};
|
|
}
|
|
if (mode && mode !== 'off') {
|
|
setPanelOpen(styleManager, 'global-context-panel', true);
|
|
}
|
|
const result = await styleManager.setContextMode(
|
|
mode === 'off' ? null : mode,
|
|
{
|
|
signal: runOptions.signal,
|
|
isCurrent: runOptions.isCurrent,
|
|
},
|
|
);
|
|
if (!current() && result?.ok !== true) {
|
|
return {
|
|
...withContextModeVocabulary(result),
|
|
ok: false,
|
|
action: 'set_context_mode',
|
|
cancelled: true,
|
|
error:
|
|
result?.error ||
|
|
'Context request was cancelled before it completed',
|
|
...cancellationState(),
|
|
};
|
|
}
|
|
const contactsWindow = ['contacts', 'flights'].includes(mode)
|
|
? activeContactsWindow(dataManager)
|
|
: null;
|
|
return {
|
|
...withContextModeVocabulary(result),
|
|
...(contactsWindow ? { contactsWindow } : {}),
|
|
};
|
|
}
|
|
|
|
if (name === 'control_cockpit') {
|
|
if (!styleManager?.controlCockpit) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
error: 'Cockpit control unavailable',
|
|
};
|
|
}
|
|
const rawAction = args.action || args.command;
|
|
const action = normalizeCockpitAction(rawAction);
|
|
const notificationToken = args.notificationToken || null;
|
|
if (!action) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
error: `Unknown cockpit action: ${args.action || args.command || 'missing'}`,
|
|
};
|
|
}
|
|
if (args.voicePanel !== undefined) {
|
|
if (
|
|
action !== 'exit' ||
|
|
!['preview', 'settings'].includes(args.voicePanel)
|
|
)
|
|
return {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
applied: false,
|
|
error: 'Voice panel reveal requires exit and a valid view.',
|
|
};
|
|
const result = await showVoicePanel(args.voicePanel, true, runOptions);
|
|
return {
|
|
...result,
|
|
action: 'control_cockpit',
|
|
cockpitAction: 'exit',
|
|
voicePanel: args.voicePanel,
|
|
};
|
|
}
|
|
const inferred = normalizeCockpitNavigationHints(rawAction);
|
|
const targetLayer = normalizeCockpitTargetLayer(
|
|
args.targetLayer || inferred.targetLayer || args.layer || args.layerId,
|
|
);
|
|
const aircraftClass = normalizeAircraftClassFilter(
|
|
args.aircraftClass ||
|
|
inferred.aircraftClass ||
|
|
args.type ||
|
|
args.filterType,
|
|
);
|
|
let contextChangedForEntry = false;
|
|
let priorContextMode = null;
|
|
let rollbackTarget = null;
|
|
if (
|
|
action === 'enter' &&
|
|
typeof styleManager.setContextMode === 'function'
|
|
) {
|
|
if (!current()) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
cancelled: true,
|
|
error: 'Cockpit entry was cancelled before it could run',
|
|
state: styleManager.getCockpitState?.() || null,
|
|
};
|
|
}
|
|
rollbackTarget = styleManager.getAircraftTrackingTarget?.() || null;
|
|
const contextState =
|
|
typeof styleManager.getContextModeState === 'function'
|
|
? styleManager.getContextModeState()
|
|
: {};
|
|
priorContextMode = contextState?.mode || null;
|
|
const contactsReady =
|
|
contextState?.mode === 'flights' &&
|
|
contextState?.active !== false &&
|
|
contextState?.changing !== true;
|
|
if (!contactsReady) {
|
|
const contextResult = await styleManager.setContextMode('flights', {
|
|
signal: runOptions.signal,
|
|
isCurrent: runOptions.isCurrent,
|
|
// Cockpit entry establishes Contacts as its own precondition. That
|
|
// is internal choreography, not an operator Context request, so it
|
|
// must stay inert: claiming here would cancel a pending shared
|
|
// style/detection restore the operator never overrode.
|
|
claimVisualAuthority: false,
|
|
});
|
|
contextChangedForEntry = contextResult?.ok === true;
|
|
if (contextResult?.ok !== true || !current()) {
|
|
const contextRollback = contextChangedForEntry
|
|
? await styleManager.setContextMode(priorContextMode, {
|
|
claimVisualAuthority: false,
|
|
})
|
|
: null;
|
|
return {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
cancelled: !current() || Boolean(contextResult?.cancelled),
|
|
error:
|
|
contextResult?.error ||
|
|
'Contacts context could not be established for Cockpit entry',
|
|
context: contextResult
|
|
? withContextModeVocabulary(contextResult)
|
|
: null,
|
|
contextRollback: withContextModeVocabulary(contextRollback),
|
|
state: styleManager.getCockpitState?.() || null,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
// Contacts activation can adopt a newer explicit aircraft selection.
|
|
// Sample only after that transaction settles so an older voice snapshot
|
|
// cannot overwrite the operator's newer choice.
|
|
const selectedTarget =
|
|
action === 'enter' ? selectedCockpitTarget(dataManager) : null;
|
|
let cockpitResult;
|
|
try {
|
|
cockpitResult = await styleManager.controlCockpit(action, {
|
|
notificationToken,
|
|
targetLayer,
|
|
aircraftClass,
|
|
selectedTarget,
|
|
rollbackTarget,
|
|
});
|
|
} catch (error) {
|
|
cockpitResult = {
|
|
ok: false,
|
|
action: 'control_cockpit',
|
|
error: error instanceof Error ? error.message : String(error),
|
|
state: styleManager.getCockpitState?.() || null,
|
|
};
|
|
}
|
|
if (
|
|
action === 'enter' &&
|
|
cockpitResult?.ok !== true &&
|
|
contextChangedForEntry
|
|
) {
|
|
const contextRollback = await styleManager.setContextMode(
|
|
priorContextMode,
|
|
{
|
|
// Undoing this action's own precondition — still choreography.
|
|
claimVisualAuthority: false,
|
|
...(current()
|
|
? {
|
|
signal: runOptions.signal,
|
|
isCurrent: runOptions.isCurrent,
|
|
}
|
|
: {}),
|
|
},
|
|
);
|
|
return {
|
|
...cockpitResult,
|
|
contextRollback: withContextModeVocabulary(contextRollback),
|
|
};
|
|
}
|
|
return cockpitResult;
|
|
}
|
|
|
|
if (name === 'show_data_layers_menu') {
|
|
const layerId = normalizeLayerId(args.layerId || args.layer);
|
|
setPanelOpen(styleManager, 'data-panel', true);
|
|
const focusedLayer =
|
|
layerId && dataManager.layers.has(layerId)
|
|
? focusDataLayerRow(layerId)
|
|
: null;
|
|
return {
|
|
ok: true,
|
|
action: 'show_data_layers_menu',
|
|
panelId: 'data-panel',
|
|
focusedLayer,
|
|
layers: dataManager
|
|
.getAll()
|
|
.filter((layer) => layer.showInTogglePanel !== false)
|
|
.map((layer) => ({
|
|
id: layer.id,
|
|
name: layer.name,
|
|
enabled: layer.enabled,
|
|
count: layer.stats?.count || 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
if (name === 'fly_to_location') {
|
|
// A referenced contact moves: locate it now, not from the old list.
|
|
if (args.entity?.layerId && args.entity?.id) {
|
|
const located = locateLayerEntity(dataManager, args.entity);
|
|
if (!located) {
|
|
return {
|
|
ok: false,
|
|
action: 'fly_to_location',
|
|
error: `${args.entity.label || args.entity.id} is no longer loaded`,
|
|
};
|
|
}
|
|
const { entity: _entity, ...rest } = args;
|
|
return runGevAction(
|
|
name,
|
|
{ ...rest, latitude: located.lat, longitude: located.lon },
|
|
runOptions,
|
|
);
|
|
}
|
|
return flyToRequestedLocation(viewer, args, {
|
|
onProgress: reportProgress,
|
|
placeSearch,
|
|
searchNavigation,
|
|
signal: runOptions.signal,
|
|
runImmediate:
|
|
typeof styleManager?.runImmediateLocationNavigation === 'function'
|
|
? (navigate) =>
|
|
styleManager.runImmediateLocationNavigation(navigate)
|
|
: null,
|
|
beginDeferred:
|
|
typeof styleManager?.beginDeferredLocationNavigation === 'function'
|
|
? () => styleManager.beginDeferredLocationNavigation()
|
|
: null,
|
|
reassertDeferred:
|
|
typeof styleManager?.reassertDeferredLocationNavigation === 'function'
|
|
? (generation) =>
|
|
styleManager.reassertDeferredLocationNavigation(generation)
|
|
: null,
|
|
onStart: () => {
|
|
if (typeof styleManager?.beginLocationNavigation === 'function') {
|
|
styleManager.beginLocationNavigation();
|
|
return;
|
|
}
|
|
interruptCameraMotion('nav:fly_to_location');
|
|
if (viewer.trackedEntity) stopAllTracking(viewer, dataManager);
|
|
},
|
|
});
|
|
}
|
|
|
|
if (name === 'adjust_camera_zoom') {
|
|
return adjustCameraZoom(viewer, args);
|
|
}
|
|
|
|
if (name === 'zoom_to_globe') {
|
|
if (typeof styleManager?.resetToGlobeView === 'function') {
|
|
return styleManager.resetToGlobeView();
|
|
}
|
|
const result = flyToGlobeView(viewer);
|
|
return {
|
|
ok: true,
|
|
action: 'zoom_to_globe',
|
|
heightKm: Math.round(GLOBE_VIEW.heightM / 1000),
|
|
centeredOn: {
|
|
latitude: Number(result.latitude.toFixed(2)),
|
|
longitude: Number(result.longitude.toFixed(2)),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (name === 'next_satellite_pass') {
|
|
return nextSatellitePass(viewer, dataManager, args);
|
|
}
|
|
|
|
if (name === 'next_iss_pass') {
|
|
return nextIssPass(viewer, dataManager, args);
|
|
}
|
|
|
|
if (name === 'analyst_query') {
|
|
return runAnalystQuery(
|
|
ensureAnalystEngine(),
|
|
dataManager,
|
|
args,
|
|
_layerEnabledAt,
|
|
current,
|
|
runOptions.signal,
|
|
);
|
|
}
|
|
|
|
if (name === 'move_camera') {
|
|
return moveCamera(args, (navigate, releaseOptions) =>
|
|
runManagedVoiceNavigation(
|
|
styleManager,
|
|
'camera',
|
|
'move_camera',
|
|
navigate,
|
|
releaseOptions,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (name === 'fly_route') {
|
|
return flyRoute(
|
|
annotations?.list?.() || [],
|
|
args,
|
|
(lat, lon) => floorServices.cachedGroundFloor(lat, lon),
|
|
(navigate) =>
|
|
runManagedVoiceNavigation(
|
|
styleManager,
|
|
'route',
|
|
'fly_route',
|
|
navigate,
|
|
),
|
|
(cells) => floorServices.warmGroundFloor(cells),
|
|
);
|
|
}
|
|
|
|
if (name === 'get_entity_context') {
|
|
return getEntityContext(
|
|
viewer,
|
|
dataManager,
|
|
styleManager,
|
|
args,
|
|
placeSearch,
|
|
);
|
|
}
|
|
|
|
if (name === 'get_current_view_state') {
|
|
return getCurrentViewState(
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
sceneDirector,
|
|
);
|
|
}
|
|
|
|
if (name === 'set_hud') {
|
|
const out = { ok: true, action: 'set_hud' };
|
|
if (args.layout != null) {
|
|
const result = styleManager.setHudLayout(args.layout);
|
|
if (!result.ok) return { ...result, action: 'set_hud' };
|
|
Object.assign(out, result);
|
|
}
|
|
if (args.visible != null) {
|
|
const result = styleManager.setHudVisible(args.visible);
|
|
if (!result.ok) return { ...result, action: 'set_hud' };
|
|
Object.assign(out, result);
|
|
}
|
|
return { ...out, hud: styleManager.getControlState().hud };
|
|
}
|
|
|
|
if (name === 'set_cyber_sonar') {
|
|
if (typeof styleManager?.setCyberSonar !== 'function') {
|
|
return {
|
|
ok: false,
|
|
action: 'set_cyber_sonar',
|
|
error: 'Cyber sonar controls are unavailable.',
|
|
};
|
|
}
|
|
return { action: 'set_cyber_sonar', ...styleManager.setCyberSonar(args) };
|
|
}
|
|
|
|
if (name === 'set_detection') {
|
|
const result = styleManager.setDetection({
|
|
enabled: typeof args.enabled === 'boolean' ? args.enabled : undefined,
|
|
mode: typeof args.mode === 'string' ? args.mode : undefined,
|
|
densityPct: Number.isFinite(Number(args.densityPct))
|
|
? Number(args.densityPct)
|
|
: undefined,
|
|
allocationStrategy:
|
|
typeof args.allocationStrategy === 'string'
|
|
? args.allocationStrategy
|
|
: undefined,
|
|
});
|
|
return { action: 'set_detection', ...result };
|
|
}
|
|
|
|
if (name === 'set_map_stack') {
|
|
const stackId = normalizeStackId(args.stack);
|
|
if (!stackId)
|
|
throw new Error(`Unknown map stack: ${args.stack || 'missing'}`);
|
|
const result = await styleManager.setMapStack(stackId);
|
|
return { action: 'set_map_stack', requested: stackId, ...result };
|
|
}
|
|
|
|
if (name === 'set_post_processing') {
|
|
const out = { ok: true, action: 'set_post_processing' };
|
|
if (args.bloom && typeof args.bloom === 'object') {
|
|
Object.assign(
|
|
out,
|
|
styleManager.setBloom({
|
|
enabled:
|
|
typeof args.bloom.enabled === 'boolean'
|
|
? args.bloom.enabled
|
|
: undefined,
|
|
intensityPct: Number.isFinite(Number(args.bloom.intensityPct))
|
|
? Number(args.bloom.intensityPct)
|
|
: undefined,
|
|
}),
|
|
);
|
|
}
|
|
if (args.sharpen && typeof args.sharpen === 'object') {
|
|
Object.assign(
|
|
out,
|
|
styleManager.setSharpen({
|
|
enabled:
|
|
typeof args.sharpen.enabled === 'boolean'
|
|
? args.sharpen.enabled
|
|
: undefined,
|
|
intensityPct: Number.isFinite(Number(args.sharpen.intensityPct))
|
|
? Number(args.sharpen.intensityPct)
|
|
: undefined,
|
|
}),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
if (name === 'control_scene') {
|
|
return controlScene(sceneDirector, args);
|
|
}
|
|
|
|
if (name === 'control_cctv') {
|
|
const action = String(args.action || '').toLowerCase();
|
|
if (
|
|
(action === 'enable' || action === 'disable') &&
|
|
dataManager.layers.get('cctv')?.module
|
|
) {
|
|
const result = await runGevAction(
|
|
'set_layer_visibility',
|
|
{ layerId: 'cctv', enabled: action === 'enable' },
|
|
runOptions,
|
|
);
|
|
return { ...result, action: 'control_cctv' };
|
|
}
|
|
return controlCctv(dataManager, args, styleManager);
|
|
}
|
|
|
|
if (name === 'control_radio') {
|
|
return controlRadio(viewer, dataManager, args, {
|
|
...runOptions,
|
|
placeSearch,
|
|
});
|
|
}
|
|
|
|
if (name === 'track_entity') {
|
|
return trackEntity(viewer, dataManager, styleManager, args, {
|
|
isCurrent: current,
|
|
});
|
|
}
|
|
|
|
if (name === 'stop_tracking') {
|
|
return stopAllTracking(viewer, dataManager);
|
|
}
|
|
|
|
if (name === 'frame_overhead') {
|
|
return frameOverhead(viewer, dataManager, styleManager, args);
|
|
}
|
|
|
|
if (name === 'annotate_map') {
|
|
return annotateMap(annotations, args, reportProgress);
|
|
}
|
|
|
|
if (name === 'clear_annotations') {
|
|
return clearAnnotations(annotations);
|
|
}
|
|
|
|
throw new Error(`Unknown GEV tool: ${name}`);
|
|
};
|
|
// Lifetimes: the runner lives for the page; conversation state — the
|
|
// analyst's follow-up memory and any write still in flight — lives for one
|
|
// voice session and is dropped by resetConversation().
|
|
let conversation = 0;
|
|
let disposed = false;
|
|
async function runVoiceAction(name, rawArgs = {}, callerOptions = {}) {
|
|
if (disposed)
|
|
return {
|
|
ok: false,
|
|
action: name,
|
|
cancelled: true,
|
|
error: 'Voice ended.',
|
|
};
|
|
const epoch = conversation;
|
|
const runOptions = {
|
|
...callerOptions,
|
|
isCurrent: () =>
|
|
epoch === conversation &&
|
|
!disposed &&
|
|
(typeof callerOptions.isCurrent !== 'function' ||
|
|
callerOptions.isCurrent()),
|
|
};
|
|
const input = rawArgs && typeof rawArgs === 'object' ? rawArgs : {};
|
|
// Point-and-ask: 'pointer' and referent:n become real targets here, from
|
|
// the turn's pointer snapshot and the latest numbered result.
|
|
const deictic = resolveDeicticArgs(name, input, {
|
|
pointer: deixis?.pointer?.activeSnapshot?.() || null,
|
|
referents: deixis?.referents || null,
|
|
imageFrame: deixis?.imageFrame?.() || null,
|
|
cameraKey: deixis?.cameraKey?.() || null,
|
|
});
|
|
if (deictic.error) {
|
|
return attachVoiceResult(
|
|
name,
|
|
{ ok: false, action: name, error: deictic.error },
|
|
input,
|
|
builders,
|
|
);
|
|
}
|
|
const referentGeneration = deixis?.referents?.generation?.();
|
|
let result = await runGevAction(name, deictic.args, runOptions);
|
|
if (deictic.used && result && typeof result === 'object')
|
|
result = { ...result, resolvedFrom: deictic.used };
|
|
const out = attachVoiceResult(name, result, deictic.args, builders);
|
|
// Only an accepted, still-current result may become "the last list": a
|
|
// cancelled turn or one finishing after the session ended must not
|
|
// repopulate a cleared registry.
|
|
const stillCurrent =
|
|
!runOptions.signal?.aborted &&
|
|
(typeof runOptions.isCurrent !== 'function' || runOptions.isCurrent());
|
|
if (stillCurrent)
|
|
deixis?.referents?.recordResult?.(name, out, {
|
|
since: referentGeneration,
|
|
});
|
|
if (stillCurrent && deictic.used?.source === 'pointer')
|
|
deixis?.onPointerUsed?.();
|
|
return out;
|
|
}
|
|
/**
|
|
* A voice session ended: invalidate every write still in flight, then
|
|
* forget the analyst's "last answer".
|
|
*/
|
|
runVoiceAction.resetConversation = () => {
|
|
conversation++;
|
|
analystEngine?.reset();
|
|
};
|
|
/** Voice was removed: nothing may run or write again. */
|
|
runVoiceAction.dispose = () => {
|
|
if (disposed) return;
|
|
runVoiceAction.resetConversation();
|
|
disposed = true;
|
|
};
|
|
return runVoiceAction;
|
|
}
|
|
|
|
function selectedCockpitTarget(dataManager) {
|
|
const selected = getSelectedEntityContext({ dataManager });
|
|
if (!selected || !['flights', 'military'].includes(selected.layerId))
|
|
return null;
|
|
const module = dataManager?.layers?.get(selected.layerId)?.module;
|
|
if (!module?.trackById || typeof module.trackById !== 'function') return null;
|
|
const id = String(selected.id || '').trim();
|
|
return id ? { layerId: selected.layerId, id } : null;
|
|
}
|
|
|
|
// Abuse guards: a single tool call may not request more than this many marks,
|
|
// each route no more waypoints than this, and free-text fields are clamped so a
|
|
// runaway model call can't drive unbounded geocode/Overpass/OSRM/DOM work. These
|
|
// mirror the tool-schema caps (defense-in-depth: a direct or schema-ignoring call
|
|
// is still bounded here).
|
|
const MAX_ANNOTATIONS_PER_CALL = 24;
|
|
const MAX_ROUTE_POINTS = 12;
|
|
const MAX_TARGET_LEN = 200;
|
|
const MAX_LABEL_LEN = 120;
|
|
|
|
function clampStr(value, max) {
|
|
if (typeof value !== 'string') return value;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
|
|
}
|
|
|
|
/** Bound the free-text + array sizes inside one annotation spec before it reaches the engine. */
|
|
function sanitizeAnnotationSpec(spec) {
|
|
if (!spec || typeof spec !== 'object') return spec;
|
|
const out = { ...spec };
|
|
if (typeof out.target === 'string')
|
|
out.target = clampStr(out.target, MAX_TARGET_LEN);
|
|
if (typeof out.toTarget === 'string')
|
|
out.toTarget = clampStr(out.toTarget, MAX_TARGET_LEN);
|
|
if (typeof out.label === 'string')
|
|
out.label = clampStr(out.label, MAX_LABEL_LEN);
|
|
if (Array.isArray(out.points)) {
|
|
out.points = out.points
|
|
.slice(0, MAX_ROUTE_POINTS)
|
|
.map((p) =>
|
|
p && typeof p === 'object' && typeof p.target === 'string'
|
|
? { ...p, target: clampStr(p.target, MAX_TARGET_LEN) }
|
|
: p,
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Draw "whiteboard" annotations on the 3D world to point out what the agent is
|
|
* talking about. Place names are resolved to real-world coordinates (and OSM
|
|
* footprints) by the annotation engine, so the agent never has to guess pixels.
|
|
*/
|
|
async function annotateMap(annotations, args = {}, reportProgress = () => {}) {
|
|
if (!annotations || typeof annotations.annotate !== 'function') {
|
|
return {
|
|
ok: false,
|
|
action: 'annotate_map',
|
|
error: 'Annotation engine unavailable',
|
|
};
|
|
}
|
|
const raw = Array.isArray(args.annotations) ? args.annotations : [];
|
|
if (!raw.length) {
|
|
return {
|
|
ok: false,
|
|
action: 'annotate_map',
|
|
error: 'No annotations supplied',
|
|
};
|
|
}
|
|
if (raw.length > MAX_ANNOTATIONS_PER_CALL) {
|
|
return {
|
|
ok: false,
|
|
action: 'annotate_map',
|
|
error: `Too many annotations in one call (${raw.length}); max ${MAX_ANNOTATIONS_PER_CALL}. Mark fewer places, or split across calls.`,
|
|
};
|
|
}
|
|
const requests = raw.map(sanitizeAnnotationSpec);
|
|
const firstTarget = requests.find((spec) => spec?.target || spec?.label);
|
|
reportProgress('resolve', firstTarget?.target || firstTarget?.label || '');
|
|
const result = await annotations.annotate(requests, {
|
|
// C1 invariant enforced in CODE (not just the prompt): the VOICE path NEVER clears as
|
|
// a side effect of drawing — annotations accumulate/persist, and only an explicit
|
|
// clear_annotations tool call wipes the board. (clearPrevious is intentionally ignored
|
|
// here and removed from the annotate_map schema; the console/demo API still has it.)
|
|
clearPrevious: false,
|
|
persist: args.persist !== false,
|
|
flyTo: Boolean(args.flyTo),
|
|
});
|
|
// Honesty: surface partial failure explicitly so the agent can tell the user
|
|
// which place(s) it couldn't mark instead of implying everything appeared.
|
|
const drewSome = result.drawn > 0;
|
|
const someFailed = result.failed > 0;
|
|
const failedLabels = [];
|
|
const pendingOutline = (result.results || []).find(
|
|
(r) => r.ok && r.outlinePending,
|
|
);
|
|
if (pendingOutline)
|
|
reportProgress('outline', pendingOutline.label || pendingOutline.target);
|
|
for (const r of result.results || []) {
|
|
if (r.ok) continue;
|
|
// Route failures carry the specific missing waypoint name(s) in failedTargets;
|
|
// everything else names its own label/target.
|
|
if (Array.isArray(r.failedTargets) && r.failedTargets.length)
|
|
failedLabels.push(...r.failedTargets);
|
|
else failedLabels.push(r.target || r.label || 'an unnamed place'); // target (the place) before caption
|
|
}
|
|
return {
|
|
ok: drewSome,
|
|
action: 'annotate_map',
|
|
drawn: result.drawn,
|
|
failed: result.failed,
|
|
partial: drewSome && someFailed,
|
|
failedLabels: someFailed ? failedLabels : undefined,
|
|
// A drawn route whose street routing was unavailable is a straight direct line,
|
|
// not a real walking/driving route — flag it so the voice layer stays honest.
|
|
routeFallback: (result.results || []).some((r) => r.ok && r.fallback),
|
|
capped: Boolean(result.capped),
|
|
// Progressive outlines: anchors are placed and returned immediately; footprints for
|
|
// these items are still being traced and will appear on their own (or the mark
|
|
// honestly stays a point). NOT a failure — the voice layer must not report it as one.
|
|
outlinePending:
|
|
(result.results || []).some((r) => r.ok && r.outlinePending) || undefined,
|
|
items: result.results,
|
|
// Keep `error` populated whenever ANYTHING failed (partial or total) so the
|
|
// result never reads as a clean success — but keep it STATIC (no raw place text);
|
|
// the actual names live only in the structured failedLabels DATA field, so the
|
|
// model-facing prose can't carry injected instructions from a place name.
|
|
error: someFailed ? 'Could not place one or more annotations' : null,
|
|
};
|
|
}
|
|
|
|
function clearAnnotations(annotations) {
|
|
if (!annotations || typeof annotations.clear !== 'function') {
|
|
return {
|
|
ok: false,
|
|
action: 'clear_annotations',
|
|
error: 'Annotation engine unavailable',
|
|
};
|
|
}
|
|
annotations.clear();
|
|
return { ok: true, action: 'clear_annotations' };
|
|
}
|
|
|
|
export function normalizeStackId(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return null;
|
|
return STACK_ALIASES.get(raw) || null;
|
|
}
|
|
|
|
/**
|
|
* Voice scene playback control. Playback is fire-and-forget: startScene
|
|
* sequences shots for minutes and must not block the realtime tool loop.
|
|
*/
|
|
function controlScene(sceneDirector, args = {}) {
|
|
if (!sceneDirector) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_scene',
|
|
error: 'Scene director unavailable',
|
|
};
|
|
}
|
|
const sceneAction = String(args.action || '').toLowerCase();
|
|
|
|
if (sceneAction === 'list') {
|
|
return {
|
|
ok: true,
|
|
action: 'control_scene',
|
|
scenes: sceneDirector.listScenes(),
|
|
...sceneDirector.getPlaybackStatus(),
|
|
};
|
|
}
|
|
if (sceneAction === 'status') {
|
|
return {
|
|
ok: true,
|
|
action: 'control_scene',
|
|
...sceneDirector.getPlaybackStatus(),
|
|
};
|
|
}
|
|
if (sceneAction === 'stop') {
|
|
sceneDirector.stopScene(msg('Stopped by voice'));
|
|
return { ok: true, action: 'control_scene', running: false };
|
|
}
|
|
if (sceneAction === 'next') {
|
|
sceneDirector.runNextScene();
|
|
return { ok: true, action: 'control_scene', advanced: true };
|
|
}
|
|
if (sceneAction === 'play') {
|
|
if (sceneDirector.running) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_scene',
|
|
error: 'A scene is already running — stop it first',
|
|
};
|
|
}
|
|
const scene = args.sceneId
|
|
? sceneDirector.findSceneByQuery(args.sceneId)
|
|
: sceneDirector.listScenes()[0] || null;
|
|
if (!scene) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_scene',
|
|
error: `No scene matched "${args.sceneId || ''}"`,
|
|
scenes: sceneDirector.listScenes(),
|
|
};
|
|
}
|
|
void sceneDirector.startScene(scene.id, { single: true });
|
|
return {
|
|
ok: true,
|
|
action: 'control_scene',
|
|
playing: scene.title,
|
|
shots: scene.shots,
|
|
};
|
|
}
|
|
throw new Error(`Unknown scene action: ${args.action || 'missing'}`);
|
|
}
|
|
|
|
/** Voice CCTV control over the cctv layer module's public surface. */
|
|
export async function controlCctv(dataManager, args = {}, styleManager = null) {
|
|
const action = String(args.action || '').toLowerCase();
|
|
const cctv = dataManager.layers.get('cctv')?.module;
|
|
if (!cctv) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cctv',
|
|
error: 'CCTV layer unavailable',
|
|
};
|
|
}
|
|
|
|
if (action === 'enable' || action === 'disable') {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cctv',
|
|
error: 'Enable or disable CCTV through set_layer_visibility',
|
|
};
|
|
}
|
|
if (!dataManager.isEnabled('cctv')) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cctv',
|
|
error: 'CCTV layer is off — enable it first',
|
|
};
|
|
}
|
|
|
|
const summarize = () => {
|
|
const ui = cctv.getUIState?.() || {};
|
|
return {
|
|
activeCameraId: ui.activeCameraId || null,
|
|
activeCamera: ui.activeCamera?.name || ui.activeCamera?.id || null,
|
|
cameraCount: Array.isArray(ui.cameras)
|
|
? ui.cameras.length
|
|
: ui.count || 0,
|
|
showCoverage: !!ui.showCoverage,
|
|
coverageMode: ui.coverageMode || (ui.showCoverage ? 'on' : 'off'),
|
|
showProjection: !!ui.showProjection,
|
|
calibrationMode: !!ui.calibrationMode,
|
|
autoHop: !!ui.autoHop,
|
|
};
|
|
};
|
|
|
|
if (action === 'select') {
|
|
const query = String(args.cameraQuery || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!query) throw new Error('control_cctv select needs cameraQuery');
|
|
const cams = cctv.getUIState?.()?.cameras || [];
|
|
const match =
|
|
cams.find((cam) => String(cam.id || '').toLowerCase() === query) ||
|
|
cams.find((cam) => String(cam.name || '').toLowerCase() === query) ||
|
|
cams.find((cam) =>
|
|
String(cam.name || '')
|
|
.toLowerCase()
|
|
.includes(query),
|
|
);
|
|
if (!match) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_cctv',
|
|
error: `No camera matched "${args.cameraQuery}"`,
|
|
...summarize(),
|
|
};
|
|
}
|
|
styleManager?.supersedeDeferredNavigation?.();
|
|
const selected = cctv.selectCamera(match.id);
|
|
const focusResult = selected
|
|
? cctv.focusCamera(match.id, 1.8)
|
|
: CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
|
return {
|
|
action: 'control_cctv',
|
|
selected: match.name || match.id,
|
|
...summarize(),
|
|
...cctvVoiceFocusOutcome(focusResult, { cameraSelected: !!selected }),
|
|
};
|
|
}
|
|
if (action === 'next' || action === 'prev') {
|
|
styleManager?.supersedeDeferredNavigation?.();
|
|
const nextId = cctv.cycleCamera(action === 'next' ? 1 : -1);
|
|
const focusResult = nextId
|
|
? cctv.focusCamera(nextId, 1.8)
|
|
: CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
|
return {
|
|
action: 'control_cctv',
|
|
...summarize(),
|
|
...cctvVoiceFocusOutcome(focusResult, { cameraSelected: !!nextId }),
|
|
};
|
|
}
|
|
if (action === 'nearest') {
|
|
styleManager?.supersedeDeferredNavigation?.();
|
|
const nearestId = cctv.focusNearest({ focus: false });
|
|
const focusResult = nearestId
|
|
? cctv.focusCamera(nearestId, 1.8)
|
|
: CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
|
return {
|
|
action: 'control_cctv',
|
|
...summarize(),
|
|
...cctvVoiceFocusOutcome(focusResult, { cameraSelected: !!nearestId }),
|
|
};
|
|
}
|
|
if (action === 'focus') {
|
|
const activeId = cctv.getUIState?.()?.activeCameraId;
|
|
if (activeId) styleManager?.supersedeDeferredNavigation?.();
|
|
const focusResult = activeId
|
|
? cctv.focusCamera(activeId)
|
|
: CCTV_FOCUS_RESULT.NO_ACTIVE_CAMERA;
|
|
return {
|
|
action: 'control_cctv',
|
|
...summarize(),
|
|
...cctvVoiceFocusOutcome(focusResult),
|
|
};
|
|
}
|
|
if (action === 'viewshed') {
|
|
// Color-coded coverage volumes; enabled:false drops back to plain
|
|
// coverage wireframes (not off — "hide coverage" is the coverage action).
|
|
const next =
|
|
typeof args.enabled === 'boolean' && !args.enabled ? 'on' : 'viewshed';
|
|
dataManager.setLayerParams(
|
|
'cctv',
|
|
{ coverageMode: next },
|
|
{ origin: 'voice' },
|
|
);
|
|
return { ok: true, action: 'control_cctv', ...summarize() };
|
|
}
|
|
if (action === 'adjust') {
|
|
const current = summarize();
|
|
const next =
|
|
typeof args.enabled === 'boolean'
|
|
? args.enabled
|
|
: !current.calibrationMode;
|
|
dataManager.setLayerParams(
|
|
'cctv',
|
|
{ calibrationMode: next },
|
|
{ origin: 'voice' },
|
|
);
|
|
return { ok: true, action: 'control_cctv', ...summarize() };
|
|
}
|
|
if (action === 'coverage') {
|
|
const current = summarize();
|
|
const next =
|
|
typeof args.enabled === 'boolean' ? args.enabled : !current.showCoverage;
|
|
dataManager.setLayerParams(
|
|
'cctv',
|
|
{ coverageMode: next ? 'on' : 'off' },
|
|
{ origin: 'voice' },
|
|
);
|
|
return { ok: true, action: 'control_cctv', ...summarize() };
|
|
}
|
|
if (action === 'projection' || action === 'autohop') {
|
|
const key = action === 'projection' ? 'showProjection' : 'autoHop';
|
|
const current = summarize();
|
|
const next =
|
|
typeof args.enabled === 'boolean' ? args.enabled : !current[key];
|
|
dataManager.setLayerParams('cctv', { [key]: next }, { origin: 'voice' });
|
|
return { ok: true, action: 'control_cctv', ...summarize() };
|
|
}
|
|
throw new Error(`Unknown CCTV action: ${args.action || 'missing'}`);
|
|
}
|
|
|
|
const RADIO_COUNTRY_CENTERS = new Map([
|
|
// i18n-ignore: place name in tool data
|
|
['us', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
|
|
// i18n-ignore: place name in tool data
|
|
['usa', { lat: 39.8, lon: -98.6, country: 'US', label: 'United States' }],
|
|
[
|
|
'united states',
|
|
// i18n-ignore: place name in tool data
|
|
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
|
|
],
|
|
[
|
|
'united states of america',
|
|
// i18n-ignore: place name in tool data
|
|
{ lat: 39.8, lon: -98.6, country: 'US', label: 'United States' },
|
|
],
|
|
]);
|
|
|
|
/** Resolve curated cities and common country requests without moving the camera. */
|
|
export function knownRadioLocation(query, locationId = '') {
|
|
const requestedId =
|
|
normalizeLocationId(locationId) || normalizeLocationId(query);
|
|
const city = requestedId ? CITY_POIS[requestedId] : null;
|
|
if (city) {
|
|
const bounds = city.viewBounds;
|
|
return {
|
|
lat: bounds
|
|
? (bounds.southwest.lat + bounds.northeast.lat) / 2
|
|
: city.pois[0]?.lat,
|
|
lon: bounds
|
|
? (bounds.southwest.lng + bounds.northeast.lng) / 2
|
|
: city.pois[0]?.lon,
|
|
label: city.name,
|
|
country: '',
|
|
};
|
|
}
|
|
return (
|
|
RADIO_COUNTRY_CENTERS.get(
|
|
String(query || '')
|
|
.trim()
|
|
.toLowerCase(),
|
|
) || null
|
|
);
|
|
}
|
|
|
|
function radioCoordinatePair(args = {}) {
|
|
const latitudeProvided = Object.hasOwn(args, 'latitude');
|
|
const longitudeProvided = Object.hasOwn(args, 'longitude');
|
|
const provided = latitudeProvided || longitudeProvided;
|
|
const latitude = args.latitude;
|
|
const longitude = args.longitude;
|
|
const valid =
|
|
latitudeProvided &&
|
|
longitudeProvided &&
|
|
typeof latitude === 'number' &&
|
|
typeof longitude === 'number' &&
|
|
Number.isFinite(latitude) &&
|
|
Number.isFinite(longitude) &&
|
|
latitude >= -90 &&
|
|
latitude <= 90 &&
|
|
longitude >= -180 &&
|
|
longitude <= 180;
|
|
return { provided, valid, latitude, longitude };
|
|
}
|
|
|
|
function radioActionIsCurrent(options = {}) {
|
|
return (
|
|
!options.signal?.aborted &&
|
|
(typeof options.isCurrent !== 'function' || options.isCurrent())
|
|
);
|
|
}
|
|
|
|
function radioAbortError() {
|
|
const error = new Error('Radio request was superseded by a newer voice turn');
|
|
error.name = 'AbortError';
|
|
return error;
|
|
}
|
|
|
|
async function resolveRadioLocation(
|
|
args = {},
|
|
coordinates = radioCoordinatePair(args),
|
|
options = {},
|
|
) {
|
|
if (!radioActionIsCurrent(options)) throw radioAbortError();
|
|
if (coordinates.valid) {
|
|
const { latitude, longitude } = coordinates;
|
|
return {
|
|
lat: latitude,
|
|
lon: longitude,
|
|
label: `${latitude.toFixed(3)}, ${longitude.toFixed(3)}`,
|
|
country: '',
|
|
};
|
|
}
|
|
const query = String(args.locationQuery || '').trim();
|
|
const known = knownRadioLocation(query, args.locationId);
|
|
if (known) return known;
|
|
if (!query) return null;
|
|
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. */
|
|
export async function controlRadio(
|
|
viewer,
|
|
dataManager,
|
|
args = {},
|
|
options = {},
|
|
) {
|
|
const requestedAction = String(args.action || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
const coordinates = radioCoordinatePair(args);
|
|
const hasSelectionCriteria = Boolean(
|
|
args.category ||
|
|
args.locationId ||
|
|
args.locationQuery ||
|
|
coordinates.provided ||
|
|
args.country ||
|
|
args.stationQuery,
|
|
);
|
|
// Realtime models can reasonably interpret "play news near Austin" as Play
|
|
// plus qualifiers. Play cannot honor those qualifiers, so normalize that
|
|
// equivalent tool shape to Select instead of silently choosing the current
|
|
// viewport's nearest station.
|
|
const action =
|
|
requestedAction === 'play' && hasSelectionCriteria
|
|
? 'select'
|
|
: requestedAction;
|
|
|
|
const readRadioLifecycle = () =>
|
|
readLayerLifecycleSummary(dataManager, 'radio');
|
|
|
|
const radio = dataManager.layers.get('radio')?.module;
|
|
if (!radio) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'Radio layer unavailable',
|
|
...readRadioLifecycle(),
|
|
};
|
|
}
|
|
const normalizedCountry = normalizeRadioCountryInput(args.country);
|
|
let lastIntentOutcome = null;
|
|
let lastIntentError = null;
|
|
|
|
const intentSummary = () =>
|
|
lastIntentOutcome
|
|
? {
|
|
phase: lastIntentOutcome.phase,
|
|
cancellationReason: lastIntentOutcome.cancellationReason || null,
|
|
successorIntentEpoch: lastIntentOutcome.successorIntentEpoch ?? null,
|
|
successorEnabled: lastIntentOutcome.successorEnabled ?? null,
|
|
successorOrigin: lastIntentOutcome.successorOrigin ?? null,
|
|
}
|
|
: {};
|
|
|
|
const cancelled = (summarize) => ({
|
|
ok: false,
|
|
action: 'control_radio',
|
|
cancelled: true,
|
|
error: 'Radio request was superseded by a newer voice turn',
|
|
...intentSummary(),
|
|
...summarize(),
|
|
});
|
|
|
|
const radioLifecycleIsSettled = (shouldEnable) => {
|
|
const lifecycle = readRadioLifecycle();
|
|
return (
|
|
lifecycle.enabled === shouldEnable &&
|
|
lifecycle.lifecycleState === (shouldEnable ? 'enabled' : 'disabled') &&
|
|
!lifecycle.lifecycleUncertain
|
|
);
|
|
};
|
|
|
|
const setRadioEnabled = async (shouldEnable) => {
|
|
lastIntentOutcome = null;
|
|
lastIntentError = null;
|
|
if (!radioActionIsCurrent(options)) return false;
|
|
const enableOptions = { origin: 'voice' };
|
|
// Both lifecycle directions can await module work. Let the manager own
|
|
// the complete transaction so barge-in cancels it before a settled
|
|
// visibility event can record explicit Context intent.
|
|
if (options.signal) enableOptions.signal = options.signal;
|
|
let result = false;
|
|
try {
|
|
if (typeof dataManager._setEnabledWithIntent === 'function') {
|
|
const intent = dataManager._setEnabledWithIntent(
|
|
'radio',
|
|
shouldEnable,
|
|
enableOptions,
|
|
);
|
|
result = await intent.promise;
|
|
if (Number.isInteger(intent.intentEpoch)) {
|
|
lastIntentOutcome = await dataManager._waitForVisibilityIntent?.(
|
|
'radio',
|
|
intent.intentEpoch,
|
|
);
|
|
}
|
|
} else {
|
|
result = await dataManager.setEnabled(
|
|
'radio',
|
|
shouldEnable,
|
|
enableOptions,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
lastIntentError = error;
|
|
return false;
|
|
}
|
|
if (lastIntentOutcome?.succeeded === false) return false;
|
|
if (!radioActionIsCurrent(options) && lastIntentOutcome?.succeeded !== true)
|
|
return false;
|
|
return result !== false && radioLifecycleIsSettled(shouldEnable);
|
|
};
|
|
|
|
const lifecycleFailure = (message, summarize) => ({
|
|
ok: false,
|
|
action: 'control_radio',
|
|
cancelled: Boolean(lastIntentOutcome?.cancellationReason),
|
|
error: lastIntentError?.message || message,
|
|
...intentSummary(),
|
|
...summarize(),
|
|
});
|
|
|
|
const authorizeRadioPlayerMutation = async ({ enableIfOff = false } = {}) => {
|
|
const lifecycle = readRadioLifecycle();
|
|
if (radioLifecycleIsSettled(true)) return true;
|
|
if (
|
|
!enableIfOff &&
|
|
!lifecycle.enabled &&
|
|
lifecycle.lifecycleState === 'disabled' &&
|
|
!lifecycle.lifecycleUncertain
|
|
)
|
|
return false;
|
|
const reconciled = await setRadioEnabled(true);
|
|
return reconciled && radioLifecycleIsSettled(true);
|
|
};
|
|
|
|
const summarize = () => {
|
|
const state = radio.getUIState?.() || {};
|
|
return {
|
|
radioAction: action,
|
|
...readRadioLifecycle(),
|
|
stationId: state.selected?.id || null,
|
|
category: state.filter || 'all',
|
|
audioState: state.audioState || 'stopped',
|
|
volumePct: Math.round((state.volume ?? 0.8) * 100),
|
|
mutedForVoice: Boolean(state.voiceDucked),
|
|
};
|
|
};
|
|
|
|
if (!normalizedCountry.valid) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error:
|
|
'Radio country must be a recognized code or country name (80 characters maximum)',
|
|
...summarize(),
|
|
};
|
|
}
|
|
|
|
if (coordinates.provided && !coordinates.valid) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error:
|
|
'Radio coordinates require a complete numeric latitude/longitude pair in range',
|
|
...summarize(),
|
|
};
|
|
}
|
|
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
|
|
if (action === 'enable' || action === 'disable') {
|
|
const shouldEnable = action === 'enable';
|
|
const changed = await setRadioEnabled(shouldEnable);
|
|
if (!radioActionIsCurrent(options) && lastIntentOutcome?.succeeded !== true)
|
|
return cancelled(summarize);
|
|
if (!changed) {
|
|
return lifecycleFailure(
|
|
`Radio could not be ${shouldEnable ? 'enabled' : 'disabled'}`,
|
|
summarize,
|
|
);
|
|
}
|
|
return { ok: true, action: 'control_radio', ...summarize() };
|
|
}
|
|
if (action === 'status')
|
|
return { ok: true, action: 'control_radio', ...summarize() };
|
|
if (action === 'volume') {
|
|
const volumePct = Number(args.volumePct);
|
|
if (!Number.isFinite(volumePct) || volumePct < 0 || volumePct > 100) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'Radio volume must be from 0 to 100',
|
|
...summarize(),
|
|
};
|
|
}
|
|
const authorized = await authorizeRadioPlayerMutation();
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if (!authorized) {
|
|
return lifecycleFailure(
|
|
'Radio must be fully enabled before changing volume',
|
|
summarize,
|
|
);
|
|
}
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
const volumeApplied =
|
|
typeof dataManager.setLayerParams === 'function'
|
|
? dataManager.setLayerParams(
|
|
'radio',
|
|
{ volume: volumePct / 100 },
|
|
{ origin: 'voice' },
|
|
)
|
|
: radio.setVolume(volumePct / 100);
|
|
if (volumeApplied === false) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'Radio must be fully enabled before changing volume',
|
|
...summarize(),
|
|
};
|
|
}
|
|
return { ok: true, action: 'control_radio', ...summarize() };
|
|
}
|
|
if (action === 'stop') {
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
let stopped = false;
|
|
try {
|
|
stopped = await radio.stopPlayback({ origin: 'voice' });
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: error?.message || 'Radio could not be stopped',
|
|
...summarize(),
|
|
};
|
|
}
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if (stopped === false) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'Radio could not be stopped',
|
|
...summarize(),
|
|
};
|
|
}
|
|
return { ok: true, action: 'control_radio', ...summarize() };
|
|
}
|
|
if (action === 'pause') {
|
|
// Pause is a playback-only control. In particular, a Pause sibling must
|
|
// never resurrect a layer that an explicit Disable just turned off. Keep
|
|
// its established cancellation authority while an enable is in flight:
|
|
// the controller commits a successful Pause by aborting that older work.
|
|
const lifecycle = readRadioLifecycle();
|
|
if (!lifecycle.enabled && lifecycle.lifecycleState !== 'enabling') {
|
|
return {
|
|
ok: true,
|
|
action: 'control_radio',
|
|
changed: false,
|
|
...summarize(),
|
|
};
|
|
}
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
const paused = radio.pause?.({ origin: 'voice' }) || false;
|
|
if (!paused) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'Radio could not be paused',
|
|
...summarize(),
|
|
};
|
|
}
|
|
return { ok: true, action: 'control_radio', changed: true, ...summarize() };
|
|
}
|
|
let resolvedLocation = null;
|
|
if (action === 'select') {
|
|
try {
|
|
// Resolve asynchronous user input before enabling Radio. That keeps an
|
|
// interrupted lookup from mutating layer or station state after barge-in.
|
|
resolvedLocation = await resolveRadioLocation(args, coordinates, options);
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError' || !radioActionIsCurrent(options))
|
|
return cancelled(summarize);
|
|
throw error;
|
|
}
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if ((args.locationQuery || args.locationId) && !resolvedLocation) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: `Could not resolve Radio location "${args.locationQuery || args.locationId}"`,
|
|
...summarize(),
|
|
};
|
|
}
|
|
}
|
|
const authorized = await authorizeRadioPlayerMutation({ enableIfOff: true });
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if (!authorized) {
|
|
return lifecycleFailure('Radio could not be enabled', summarize);
|
|
}
|
|
const state = radio.getUIState?.() || {};
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if (!state.stationCount) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: state.error || 'No healthy Radio stations are available',
|
|
...summarize(),
|
|
};
|
|
}
|
|
if (action === 'play' || action === 'resume') {
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
const prepared = state.selected
|
|
? true
|
|
: radio.cycleStation?.(1, {
|
|
focus: false,
|
|
autoplay: false,
|
|
fallback: true,
|
|
});
|
|
return {
|
|
ok: Boolean(prepared),
|
|
action: 'control_radio',
|
|
radioPlaybackRequested: Boolean(prepared),
|
|
...summarize(),
|
|
};
|
|
}
|
|
if (action === 'next' || action === 'previous') {
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
if (args.category) {
|
|
if (typeof dataManager.setLayerParams === 'function') {
|
|
dataManager.setLayerParams(
|
|
'radio',
|
|
{ filter: String(args.category) },
|
|
{ origin: 'voice' },
|
|
);
|
|
} else {
|
|
radio.setFilter(String(args.category));
|
|
}
|
|
}
|
|
const selected = radio.cycleStation?.(action === 'next' ? 1 : -1, {
|
|
focus: false,
|
|
autoplay: false,
|
|
fallback: true,
|
|
});
|
|
return {
|
|
ok: Boolean(selected),
|
|
action: 'control_radio',
|
|
radioPlaybackRequested: Boolean(selected),
|
|
...summarize(),
|
|
};
|
|
}
|
|
if (action === 'select') {
|
|
const location = resolvedLocation;
|
|
if (!radioActionIsCurrent(options)) return cancelled(summarize);
|
|
const station = radio.selectRequestedStation?.(
|
|
{
|
|
categoryId: String(args.category || 'all'),
|
|
anchor: location ? { lat: location.lat, lon: location.lon } : null,
|
|
country: normalizedCountry.empty
|
|
? String(location?.country || '')
|
|
: normalizedCountry.code,
|
|
stationQuery: String(args.stationQuery || ''),
|
|
},
|
|
{ autoplay: false },
|
|
);
|
|
if (!station) {
|
|
return {
|
|
ok: false,
|
|
action: 'control_radio',
|
|
error: 'No Radio station matched that location and category',
|
|
...summarize(),
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
action: 'control_radio',
|
|
radioPlaybackRequested: true,
|
|
requestedLocation: location?.label || null,
|
|
...summarize(),
|
|
};
|
|
}
|
|
throw new Error(`Unknown Radio action: ${args.action || 'missing'}`);
|
|
}
|
|
|
|
/**
|
|
* Maps a CCTV focus code to an honest voice-tool result.
|
|
* @param {string|boolean} focusResult CCTV focus result code.
|
|
* @param {Object} [options]
|
|
* @param {boolean} [options.cameraSelected=false] Whether this action first selected a camera.
|
|
* @returns {{ok: boolean, error: string|null}} Voice-facing result fields.
|
|
*/
|
|
export function cctvVoiceFocusOutcome(
|
|
focusResult,
|
|
{ cameraSelected = false } = {},
|
|
) {
|
|
if (focusResult === CCTV_FOCUS_RESULT.FOCUSED || focusResult === true) {
|
|
return { ok: true, error: null };
|
|
}
|
|
if (focusResult === CCTV_FOCUS_RESULT.TRACKING_HOLDS_VIEW) {
|
|
return {
|
|
ok: false,
|
|
error: cameraSelected
|
|
? 'Camera selected; tracking holds the view — say untrack to fly'
|
|
: 'Camera active; tracking holds the view — say untrack first',
|
|
};
|
|
}
|
|
if (focusResult === CCTV_FOCUS_RESULT.COCKPIT_ACTIVE) {
|
|
return {
|
|
ok: false,
|
|
error: cameraSelected
|
|
? 'Camera selected; in cockpit — exit cockpit to fly to it'
|
|
: 'In cockpit — exit cockpit to fly to a camera',
|
|
};
|
|
}
|
|
return { ok: false, error: 'No active camera to focus' };
|
|
}
|
|
|
|
/**
|
|
* Spoken name for a tracked entity descriptor.
|
|
*
|
|
* Aircraft follow the flight layers' label convention — callsign →
|
|
* registration → icao24 — so the narrated name matches the readout and the
|
|
* detection card instead of speaking a raw hex at a contact the UI is calling
|
|
* `N123AB`. Vessels and satellites carry no `registration`, so that link
|
|
* simply falls through to their own name/mmsi/noradId links.
|
|
* @param {object} found - Layer descriptor from `findByQuery`.
|
|
* @param {string} query - The spoken query, used as the last resort.
|
|
* @returns {string} A non-empty display name.
|
|
*/
|
|
export function formatTrackedEntityLabel(found, query = '') {
|
|
const text = (v) => String(v ?? '').trim();
|
|
return (
|
|
text(found?.callsign) ||
|
|
text(found?.registration) ||
|
|
text(found?.name) ||
|
|
text(found?.icao24) ||
|
|
text(found?.mmsi) ||
|
|
text(found?.noradId) ||
|
|
String(query)
|
|
);
|
|
}
|
|
|
|
/** Finds and tracks/selects an entity by spoken query across layer families. */
|
|
async function trackEntity(
|
|
viewer,
|
|
dataManager,
|
|
styleManager,
|
|
args = {},
|
|
{ isCurrent = () => true } = {},
|
|
) {
|
|
const cancelled = () => ({
|
|
ok: false,
|
|
cancelled: true,
|
|
action: 'track_entity',
|
|
error: 'Tracking request was superseded by a newer voice turn',
|
|
});
|
|
if (!isCurrent()) return cancelled();
|
|
const query = String(args.query || '').trim();
|
|
if (!query) throw new Error('track_entity needs a query');
|
|
|
|
// Fire queries route to the FIRMS layer's strongest detection
|
|
if (/\bfires?\b/i.test(query)) {
|
|
if (!dataManager.isEnabled('local-firms')) {
|
|
return {
|
|
ok: false,
|
|
action: 'track_entity',
|
|
query,
|
|
error: 'The FIRMS fires layer is not enabled',
|
|
};
|
|
}
|
|
const firms = dataManager.layers.get('local-firms')?.module;
|
|
const strongest = firms?.getStrongestFire?.();
|
|
if (!strongest) {
|
|
return {
|
|
ok: false,
|
|
action: 'track_entity',
|
|
query,
|
|
error: 'No fire detections loaded yet',
|
|
};
|
|
}
|
|
if (
|
|
!Number.isFinite(strongest.latitude) ||
|
|
!Number.isFinite(strongest.longitude)
|
|
) {
|
|
return {
|
|
ok: false,
|
|
action: 'track_entity',
|
|
query,
|
|
error: 'The strongest fire has no usable position',
|
|
};
|
|
}
|
|
if (!isCurrent()) return cancelled();
|
|
return runManagedVoiceNavigation(
|
|
styleManager,
|
|
'fire',
|
|
'track_entity',
|
|
() => {
|
|
flyToLandmark(viewer, strongest.latitude, strongest.longitude, {
|
|
range: 14000,
|
|
pitch: -50,
|
|
heading: 0,
|
|
buildingHeight: 0,
|
|
duration: 2.2,
|
|
});
|
|
return {
|
|
ok: true,
|
|
action: 'track_entity',
|
|
kind: 'fire',
|
|
layerId: 'local-firms',
|
|
// i18n-ignore: entity name in a tool result for the voice model
|
|
label: strongest.label || 'Strongest fire',
|
|
latitude: strongest.latitude,
|
|
longitude: strongest.longitude,
|
|
frp: strongest.frp ?? null,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
const requested = args.layerId ? normalizeLayerId(args.layerId) : null;
|
|
const families = TRACKABLE_FAMILIES.filter(
|
|
(family) => !requested || family.layerId === requested,
|
|
);
|
|
const skippedDisabled = [];
|
|
|
|
for (const family of families) {
|
|
if (!dataManager.isEnabled(family.layerId)) {
|
|
skippedDisabled.push(family.layerId);
|
|
continue;
|
|
}
|
|
const module = dataManager.layers.get(family.layerId)?.module;
|
|
if (!module || typeof module.findByQuery !== 'function') continue;
|
|
const found = module.findByQuery(query);
|
|
if (!found) continue;
|
|
|
|
if (!isCurrent()) return cancelled();
|
|
|
|
if (
|
|
family.kind === 'vessel' &&
|
|
(!Number.isFinite(found.latitude) || !Number.isFinite(found.longitude))
|
|
) {
|
|
return {
|
|
ok: false,
|
|
action: 'track_entity',
|
|
layerId: family.layerId,
|
|
kind: family.kind,
|
|
error: 'The matched vessel has no usable position',
|
|
};
|
|
}
|
|
|
|
return runManagedVoiceNavigation(
|
|
styleManager,
|
|
family.kind,
|
|
'track_entity',
|
|
() => {
|
|
let trackedOk = false;
|
|
if (family.kind === 'vessel') {
|
|
trackedOk = !!module.selectById?.(found.mmsi);
|
|
flyToLandmark(viewer, found.latitude, found.longitude, {
|
|
range: 6000,
|
|
pitch: -45,
|
|
heading: 0,
|
|
buildingHeight: 0,
|
|
duration: 2.0,
|
|
});
|
|
} else if (family.kind === 'satellite') {
|
|
trackedOk = !!module.trackById?.(found.noradId, { origin: 'voice' });
|
|
} else {
|
|
trackedOk = !!module.trackById?.(found.icao24, { origin: 'voice' });
|
|
}
|
|
|
|
return {
|
|
ok: trackedOk,
|
|
action: 'track_entity',
|
|
layerId: family.layerId,
|
|
kind: family.kind,
|
|
// Aircraft follow the flight layers' label convention (callsign →
|
|
// registration → icao24) so the spoken name matches what the UI shows;
|
|
// `registration` is absent on vessels/satellites and simply falls
|
|
// through to their own name/id links.
|
|
label: formatTrackedEntityLabel(found, query),
|
|
latitude: found.latitude ?? null,
|
|
longitude: found.longitude ?? null,
|
|
altitudeM: Number.isFinite(found.altitudeM)
|
|
? Math.round(found.altitudeM)
|
|
: null,
|
|
error: trackedOk ? null : 'Match found but tracking failed',
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
const disabledNote = skippedDisabled.length
|
|
? ` (disabled layers skipped: ${skippedDisabled.join(', ')})`
|
|
: '';
|
|
return {
|
|
ok: false,
|
|
action: 'track_entity',
|
|
query,
|
|
error: `Nothing matched "${query}"${disabledNote}`,
|
|
};
|
|
}
|
|
|
|
/** Releases tracking/selection on every entity layer family. */
|
|
function stopAllTracking(viewer, dataManager) {
|
|
const released = [];
|
|
const failed = new Set();
|
|
for (const family of TRACKABLE_FAMILIES) {
|
|
const module = dataManager.layers.get(family.layerId)?.module;
|
|
if (!module) continue;
|
|
try {
|
|
if (family.kind === 'vessel') {
|
|
if (module.getSelectedInfo?.()) {
|
|
if (
|
|
typeof module.clearSelection !== 'function' ||
|
|
module.clearSelection() === false
|
|
) {
|
|
failed.add(family.layerId);
|
|
} else {
|
|
released.push(family.layerId);
|
|
}
|
|
}
|
|
} else if (module.getTrackedInfo?.()) {
|
|
if (
|
|
typeof module.stopTracking !== 'function' ||
|
|
module.stopTracking({ origin: 'voice' }) === false
|
|
) {
|
|
failed.add(family.layerId);
|
|
} else {
|
|
released.push(family.layerId);
|
|
}
|
|
}
|
|
} catch {
|
|
failed.add(family.layerId);
|
|
}
|
|
}
|
|
for (const [layerId, key] of [
|
|
['flights', 'selectedFlightsTrackingId'],
|
|
['military', 'selectedMilitaryTrackingId'],
|
|
['satellites', 'selectedSatTrackingId'],
|
|
]) {
|
|
try {
|
|
if (
|
|
dataManager.setLayerParams(
|
|
layerId,
|
|
{ [key]: null },
|
|
{ origin: 'voice' },
|
|
) === false
|
|
) {
|
|
failed.add(layerId);
|
|
}
|
|
} catch {
|
|
failed.add(layerId);
|
|
}
|
|
}
|
|
if (viewer) viewer.trackedEntity = undefined;
|
|
if (failed.size) {
|
|
const failedLayerIds = [...failed];
|
|
return {
|
|
ok: false,
|
|
action: 'stop_tracking',
|
|
released,
|
|
failedLayerIds,
|
|
error: `Tracking could not be cleared for: ${failedLayerIds.join(', ')}`,
|
|
};
|
|
}
|
|
return { ok: true, action: 'stop_tracking', released };
|
|
}
|
|
|
|
/**
|
|
* Frames entities near the current view target with a cinematic pull-back:
|
|
* oblique high pitch for aircraft/ships, shallow wide pitch for satellites.
|
|
* When entries are found and detection is OFF, auto-enables panoptic
|
|
* detection so the framed entities are labeled, and reports
|
|
* detectionEnabled so the voice agent can mention labels are on.
|
|
*/
|
|
async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
|
|
const targetRaw = String(args.target || 'flights').toLowerCase();
|
|
const layerId =
|
|
FRAME_TARGETS.get(targetRaw) || normalizeLayerId(targetRaw) || 'flights';
|
|
if (!dataManager.layers.has(layerId)) {
|
|
return {
|
|
ok: false,
|
|
action: 'frame_overhead',
|
|
error: `Unknown target layer: ${args.target}`,
|
|
};
|
|
}
|
|
if (!dataManager.isEnabled(layerId)) {
|
|
return {
|
|
ok: false,
|
|
action: 'frame_overhead',
|
|
layerId,
|
|
error: `The ${layerId} layer is not enabled`,
|
|
};
|
|
}
|
|
const module = dataManager.layers.get(layerId)?.module;
|
|
const isSatellites = layerId === 'satellites';
|
|
const defaultRadiusKm = isSatellites
|
|
? 3000
|
|
: layerId === 'ais-live-vessels'
|
|
? 120
|
|
: 150;
|
|
const radiusKm = clampNumber(args.radiusKm, 10, 20000, defaultRadiusKm);
|
|
const center = getViewTargetCartesian(viewer) || viewer.camera.positionWC;
|
|
|
|
let entries = [];
|
|
if (typeof module.getNearby === 'function') {
|
|
entries = module.getNearby(center, radiusKm * 1000, 80) || [];
|
|
} else if (typeof module.getAllPositions === 'function') {
|
|
entries = (module.getAllPositions(800) || [])
|
|
.filter((entry) => entry.position)
|
|
.map((entry) => ({
|
|
...entry,
|
|
distance: Cesium.Cartesian3.distance(center, entry.position),
|
|
}))
|
|
.filter((entry) => entry.distance <= radiusKm * 1000)
|
|
.sort((a, b) => a.distance - b.distance)
|
|
.slice(0, 80);
|
|
}
|
|
if (!entries.length) {
|
|
return {
|
|
ok: false,
|
|
action: 'frame_overhead',
|
|
layerId,
|
|
radiusKm: Math.round(radiusKm),
|
|
count: 0,
|
|
error: `No ${targetRaw} within ${Math.round(radiusKm)} km of the current view`,
|
|
};
|
|
}
|
|
|
|
const sphere = Cesium.BoundingSphere.fromPoints(
|
|
entries.map((entry) => entry.position),
|
|
);
|
|
sphere.radius = Math.max(sphere.radius * 1.25, 8000);
|
|
const pitch = Cesium.Math.toRadians(isSatellites ? -35 : -62);
|
|
return runManagedVoiceNavigation(
|
|
styleManager,
|
|
'frame',
|
|
'frame_overhead',
|
|
() => {
|
|
viewer.camera.flyToBoundingSphere(sphere, {
|
|
duration: 2.0,
|
|
offset: new Cesium.HeadingPitchRange(
|
|
viewer.camera.heading,
|
|
pitch,
|
|
sphere.radius * 2.4,
|
|
),
|
|
});
|
|
|
|
let detectionEnabled = false;
|
|
try {
|
|
const detectionState = styleManager?.getDetectionState?.();
|
|
if (detectionState?.detectionMode === 'OFF') {
|
|
const detectionResult = styleManager.setDetection({ mode: 'dense' });
|
|
detectionEnabled = detectionResult?.ok === true;
|
|
} else if (detectionState) {
|
|
detectionEnabled = true;
|
|
}
|
|
} catch {
|
|
// detection facade unavailable; framing still succeeded
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
action: 'frame_overhead',
|
|
layerId,
|
|
radiusKm: Math.round(radiusKm),
|
|
count: entries.length,
|
|
detectionEnabled,
|
|
nearest: entries.slice(0, 5).map((entry) => ({
|
|
id: entry.id || entry.icao24 || entry.mmsi || null,
|
|
// Canonical identity: `id` is a display label for aircraft.
|
|
...(entry.icao24 ? { icao24: String(entry.icao24) } : {}),
|
|
...(entry.mmsi ? { mmsi: String(entry.mmsi) } : {}),
|
|
label: entry.label || entry.callsign || entry.name || null,
|
|
})),
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Run one validated voice camera mutation through the UI-owned authority seam. */
|
|
function runManagedVoiceNavigation(
|
|
styleManager,
|
|
noun,
|
|
action,
|
|
navigate,
|
|
releaseOptions = undefined,
|
|
) {
|
|
if (typeof styleManager?.runImmediateNavigation !== 'function') {
|
|
return { ok: false, action, error: 'Camera navigation policy unavailable' };
|
|
}
|
|
const result = styleManager.runImmediateNavigation(
|
|
noun,
|
|
navigate,
|
|
releaseOptions,
|
|
);
|
|
if (result !== false) return result;
|
|
return {
|
|
ok: false,
|
|
action,
|
|
error: 'Camera navigation is unavailable in the current view',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Place labels for the HUD's passive summary. It geocodes the view center
|
|
* and one more point. While the view still shows about the same area (see
|
|
* hudPlaceLookup.js) it answers for the points the last lookup asked about
|
|
* instead, so a settled camera does not buy a new set of Google requests for
|
|
* every small pan; those points are cache hits, and only what the last
|
|
* lookup did not get (a timeout, a failure, an empty answer) is asked again.
|
|
* `cachedOnly` reads the shared caches and makes no request.
|
|
*/
|
|
export async function getBasemapLabelContext(
|
|
viewer,
|
|
service = defaultGeospatial,
|
|
{ cachedOnly = false, previous = null } = {},
|
|
) {
|
|
const cameraHeightM = viewer.camera.positionCartographic.height;
|
|
const target = getViewTargetCartographic(viewer);
|
|
if (!target) {
|
|
return {
|
|
placeLabels: [],
|
|
streetLabels: [],
|
|
nearbyPlaceLabels: [],
|
|
view: null,
|
|
lookup: null,
|
|
};
|
|
}
|
|
|
|
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
|
|
const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6));
|
|
const view = placeLookupView({
|
|
latitude,
|
|
longitude,
|
|
rangeM: viewTargetRangeM(viewer, target, cameraHeightM),
|
|
fovRad: viewer.camera.frustum?.fov,
|
|
aspectRatio: viewer.camera.frustum?.aspectRatio,
|
|
tier: basemapLookupTier(cameraHeightM),
|
|
});
|
|
if (
|
|
!cachedOnly &&
|
|
previous?.lookup &&
|
|
!placeLookupViewChanged(previous.view, view)
|
|
) {
|
|
// The same points again: what the last lookup got is a cache hit, and
|
|
// only what it did not (a timeout, a failure, an empty answer) is asked.
|
|
const labels = await basemapLabelsAt(previous.lookup, service, {
|
|
cachedOnly: false,
|
|
});
|
|
return { ...labels, view: previous.view, lookup: previous.lookup };
|
|
}
|
|
const samples = sampleViewportCartographics(viewer);
|
|
const lookup = {
|
|
latitude,
|
|
longitude,
|
|
cameraHeightM,
|
|
// Cache reads are free, so a cached-only host keeps every sample.
|
|
samples: cachedOnly
|
|
? samples
|
|
: hudViewportSamples(samples, shouldReverseGeocode(cameraHeightM)),
|
|
};
|
|
return {
|
|
...(await basemapLabelsAt(lookup, service, { cachedOnly })),
|
|
view,
|
|
lookup,
|
|
};
|
|
}
|
|
|
|
/** Which of the height-gated lookups a camera height calls for. */
|
|
function basemapLookupTier(cameraHeightM) {
|
|
return [
|
|
shouldReverseGeocode(cameraHeightM),
|
|
shouldReverseGeocodeViewport(cameraHeightM) && cameraHeightM > 10000,
|
|
shouldFetchNearbyPlaces(cameraHeightM) &&
|
|
nearbyPlacesRadiusM(cameraHeightM),
|
|
].join(':');
|
|
}
|
|
|
|
/** Camera distance to the view target, or the camera height without one. */
|
|
function viewTargetRangeM(viewer, target, cameraHeightM) {
|
|
const position = viewer.camera.positionWC;
|
|
if (!position) return cameraHeightM;
|
|
const range = Cesium.Cartesian3.distance(
|
|
position,
|
|
Cesium.Cartographic.toCartesian(target),
|
|
);
|
|
return Number.isFinite(range) ? range : cameraHeightM;
|
|
}
|
|
|
|
async function basemapLabelsAt(
|
|
{ latitude, longitude, cameraHeightM, samples },
|
|
service,
|
|
{ cachedOnly },
|
|
) {
|
|
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
|
|
const cachedViewportPlaces = viewportPlacesFromCache(
|
|
samples,
|
|
cameraHeightM,
|
|
service,
|
|
);
|
|
const viewportPromise = cachedViewportPlaces
|
|
? Promise.resolve(cachedViewportPlaces)
|
|
: cachedOnly
|
|
? Promise.resolve(null)
|
|
: reverseGeocodeViewportSamples(samples, cameraHeightM, service);
|
|
const placePromise = shouldReverseGeocode(cameraHeightM)
|
|
? cachedOnly
|
|
? Promise.resolve(
|
|
reverseGeocodeCache.get(
|
|
reverseGeocodeKey(latitude, longitude, service),
|
|
) || null,
|
|
)
|
|
: reverseGeocode(latitude, longitude, service)
|
|
: Promise.resolve(null);
|
|
const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM)
|
|
? cachedOnly
|
|
? Promise.resolve(
|
|
nearbyPlacesCache.get(
|
|
nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service),
|
|
) || [],
|
|
)
|
|
: fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
|
|
: Promise.resolve([]);
|
|
const [viewportPlaces, place, nearbyPlaces] = await Promise.all([
|
|
resolveWithin(
|
|
viewportPromise,
|
|
BASEMAP_CONTEXT_WAIT_MS,
|
|
cachedViewportPlaces,
|
|
),
|
|
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, null),
|
|
resolveWithin(nearbyPromise, BASEMAP_CONTEXT_WAIT_MS, []),
|
|
]);
|
|
return {
|
|
placeLabels: uniqueStrings([
|
|
place?.formattedAddress,
|
|
place?.locality,
|
|
place?.region,
|
|
place?.country,
|
|
...(place?.labels || []),
|
|
...(viewportPlaces?.visibleLabels || []),
|
|
]).slice(0, 24),
|
|
streetLabels: uniqueStrings([
|
|
...(place?.streetLabels || []),
|
|
...(viewportPlaces?.streetLabels || []),
|
|
]).slice(0, 16),
|
|
nearbyPlaceLabels: uniqueStrings(
|
|
(nearbyPlaces || []).flatMap((nearbyPlace) => [
|
|
nearbyPlace.name,
|
|
nearbyPlace.address,
|
|
]),
|
|
).slice(0, 24),
|
|
};
|
|
}
|
|
|
|
function installViewTargetPrewarm(viewer) {
|
|
if (viewer.__gevViewTargetPrewarmInstalled) return;
|
|
viewer.__gevViewTargetPrewarmInstalled = true;
|
|
let timer = null;
|
|
let reportedPrewarmFailure = false;
|
|
viewer.camera.moveEnd.addEventListener(() => {
|
|
if (timer) window.clearTimeout(timer);
|
|
timer = window.setTimeout(() => {
|
|
timer = null;
|
|
// Belt and braces. Validating the pick is the fix; this catch exists
|
|
// because an idle callback is an UNCAUGHT context — nothing above it can
|
|
// handle a surprise from the scene graph, and a red console error on a
|
|
// plain camera flight is worse than a cold cache. Reported once per
|
|
// viewer so a repeating cause cannot spam the console.
|
|
const warm = () => {
|
|
try {
|
|
getViewTargetCartographic(viewer);
|
|
} catch (error) {
|
|
if (reportedPrewarmFailure) return;
|
|
reportedPrewarmFailure = true;
|
|
console.debug(
|
|
'[Voice] view-target prewarm skipped:',
|
|
error?.message || error,
|
|
);
|
|
}
|
|
};
|
|
if (typeof window.requestIdleCallback === 'function') {
|
|
window.requestIdleCallback(warm, { timeout: 500 });
|
|
} else {
|
|
warm();
|
|
}
|
|
}, 120);
|
|
});
|
|
}
|
|
|
|
function adjustCameraZoom(viewer, args) {
|
|
const direction = String(args.direction || '').toLowerCase();
|
|
if (direction !== 'in' && direction !== 'out') {
|
|
throw new Error('adjust_camera_zoom direction must be "in" or "out"');
|
|
}
|
|
|
|
const amount = String(args.amount || 'little').toLowerCase();
|
|
const fraction = {
|
|
little: 0.25,
|
|
medium: 0.55,
|
|
lot: 1.0,
|
|
}[amount];
|
|
if (!fraction) throw new Error(`Unknown zoom amount: ${args.amount}`);
|
|
|
|
const camera = viewer.camera;
|
|
const beforePosition = Cesium.Cartesian3.clone(camera.positionWC);
|
|
const beforeHeightM = camera.positionCartographic.height;
|
|
const target = getViewTargetCartesian(viewer);
|
|
const targetDistanceM = target
|
|
? Cesium.Cartesian3.distance(beforePosition, target)
|
|
: Math.max(100, beforeHeightM);
|
|
const minimumDistanceM = direction === 'in' ? 20 : 50;
|
|
const movementM = Math.max(minimumDistanceM, targetDistanceM * fraction);
|
|
|
|
camera.cancelFlight();
|
|
if (direction === 'out') {
|
|
camera.zoomOut(movementM);
|
|
} else {
|
|
const safeMovementM = Math.min(
|
|
movementM,
|
|
Math.max(0, targetDistanceM - 25),
|
|
);
|
|
if (safeMovementM <= 0) {
|
|
return {
|
|
ok: false,
|
|
action: 'adjust_camera_zoom',
|
|
direction,
|
|
amount,
|
|
error: 'Camera is already at the minimum target distance',
|
|
};
|
|
}
|
|
camera.zoomIn(safeMovementM);
|
|
}
|
|
viewer.scene.requestRender();
|
|
|
|
const afterPosition = camera.positionWC;
|
|
const movedM = Cesium.Cartesian3.distance(beforePosition, afterPosition);
|
|
const afterHeightM = camera.positionCartographic.height;
|
|
const moved = movedM >= 0.5;
|
|
return {
|
|
ok: moved,
|
|
action: 'adjust_camera_zoom',
|
|
direction,
|
|
amount,
|
|
movementRequestedM: Math.round(movementM),
|
|
movementActualM: Math.round(movedM),
|
|
beforeHeightM: Math.round(beforeHeightM),
|
|
afterHeightM: Math.round(afterHeightM),
|
|
error: moved ? null : 'Cesium camera position did not change',
|
|
};
|
|
}
|
|
|
|
const COMPASS_16 = [
|
|
'N',
|
|
'NNE',
|
|
'NE',
|
|
'ENE',
|
|
'E',
|
|
'ESE',
|
|
'SE',
|
|
'SSE',
|
|
'S',
|
|
'SSW',
|
|
'SW',
|
|
'WSW',
|
|
'W',
|
|
'WNW',
|
|
'NW',
|
|
'NNW',
|
|
];
|
|
|
|
function compassDir(azDeg) {
|
|
return COMPASS_16[Math.round((((azDeg % 360) + 360) % 360) / 22.5) % 16];
|
|
}
|
|
|
|
function nextIssPass(viewer, dataManager, args) {
|
|
let latDeg = Number.isFinite(args.latitude) ? args.latitude : null;
|
|
let lonDeg = Number.isFinite(args.longitude) ? args.longitude : null;
|
|
if (latDeg == null || lonDeg == null) {
|
|
const carto = viewer?.camera?.positionCartographic;
|
|
if (!carto) throw new Error('Camera position unavailable');
|
|
latDeg = Cesium.Math.toDegrees(carto.latitude);
|
|
lonDeg = Cesium.Math.toDegrees(carto.longitude);
|
|
}
|
|
const minElevDeg = Number.isFinite(args.minElevationDeg)
|
|
? args.minElevationDeg
|
|
: 10;
|
|
const result = dataManager?.layers
|
|
?.get('satellites')
|
|
?.module?.getNextIssPass?.({ latDeg, lonDeg, minElevDeg }) ?? {
|
|
status: 'no-tle',
|
|
};
|
|
if (result.status === 'no-tle') {
|
|
return {
|
|
ok: false,
|
|
action: 'next_iss_pass',
|
|
error:
|
|
'ISS orbital elements not loaded yet — enable the satellites layer once, then ask again.',
|
|
};
|
|
}
|
|
if (result.status === 'none') {
|
|
return {
|
|
ok: false,
|
|
action: 'next_iss_pass',
|
|
error: `No ISS pass above ${minElevDeg}° in the next 24 hours for this location.`,
|
|
};
|
|
}
|
|
const { pass } = result;
|
|
return {
|
|
ok: true,
|
|
action: 'next_iss_pass',
|
|
observer: { latitude: latDeg, longitude: lonDeg },
|
|
riseIso: new Date(pass.riseMs).toISOString(),
|
|
minutesFromNow: Math.round((pass.riseMs - Date.now()) / 60000),
|
|
durationMin: Math.max(1, Math.round((pass.setMs - pass.riseMs) / 60000)),
|
|
peakElevationDeg: Math.round(pass.maxElevDeg),
|
|
riseDirection: compassDir(pass.riseAzDeg),
|
|
visible: typeof pass.visible === 'boolean' ? pass.visible : null,
|
|
visibilityNote:
|
|
'Geometric illumination estimate only; weather, brightness and orbital-element age affect actual visibility.',
|
|
setIso: new Date(pass.setMs).toISOString(),
|
|
peakIso: new Date(pass.maxElevMs).toISOString(),
|
|
};
|
|
}
|
|
|
|
function nextSatellitePass(viewer, dataManager, args) {
|
|
const layer = dataManager?.layers?.get('satellites')?.module;
|
|
const identity = layer?.resolveSatelliteForPass?.(args.target) || {
|
|
status: 'not-found',
|
|
};
|
|
if (identity.status !== 'ok')
|
|
return {
|
|
ok: false,
|
|
action: 'next_satellite_pass',
|
|
...identity,
|
|
error:
|
|
identity.status === 'ambiguous'
|
|
? 'Several loaded satellites match. Choose a NORAD ID from candidates.'
|
|
: 'No loaded satellite matches. Enable satellites and use an exact name or NORAD ID.',
|
|
};
|
|
// Reuse the legacy location fallback and result formatting, substituting only
|
|
// this explicitly resolved catalog identity and the optional visibility filter.
|
|
const adapter = {
|
|
layers: new Map([
|
|
[
|
|
'satellites',
|
|
{
|
|
module: {
|
|
getNextIssPass: (options) =>
|
|
layer.getNextSatellitePass(identity.noradId, {
|
|
...options,
|
|
requireVisible: args.visibleOnly === true,
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
};
|
|
const result = nextIssPass(viewer, adapter, args);
|
|
if (result.error) {
|
|
result.error = result.error.replace(
|
|
/ISS/g,
|
|
identity.name || String(identity.noradId),
|
|
);
|
|
if (args.visibleOnly === true)
|
|
result.error +=
|
|
' Search required estimated illumination under a dark sky.';
|
|
}
|
|
return {
|
|
...result,
|
|
action: 'next_satellite_pass',
|
|
noradId: identity.noradId,
|
|
name: identity.name,
|
|
visibleOnly: args.visibleOnly === true,
|
|
horizonHours: 24,
|
|
};
|
|
}
|
|
|
|
function normalizePanelId(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return null;
|
|
if (PANEL_IDS.has(raw)) return raw;
|
|
return PANEL_ALIASES.get(raw.toLowerCase()) || null;
|
|
}
|
|
|
|
function normalizeLayerId(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return null;
|
|
if (LAYER_ALIASES.has(raw.toLowerCase()))
|
|
return LAYER_ALIASES.get(raw.toLowerCase());
|
|
return raw;
|
|
}
|
|
|
|
function normalizeCockpitTargetLayer(value) {
|
|
const layerId = normalizeLayerId(value);
|
|
if (!layerId || !COCKPIT_TARGET_LAYERS.has(layerId)) return null;
|
|
return layerId;
|
|
}
|
|
|
|
function normalizeCockpitNavigationHints(rawAction) {
|
|
const raw = String(rawAction || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return {};
|
|
|
|
const targetLayer =
|
|
raw.includes('vessel') || raw.includes('ship') || raw.includes('ais')
|
|
? 'ais-live-vessels'
|
|
: raw.includes('installation') ||
|
|
raw.includes('facility') ||
|
|
raw.includes('base')
|
|
? 'military-installations'
|
|
: raw.includes('military')
|
|
? 'military'
|
|
: null;
|
|
|
|
const aircraftClass =
|
|
raw.includes('helicopter') ||
|
|
raw.includes('helo') ||
|
|
raw.includes('chopper')
|
|
? 'helicopter'
|
|
: null;
|
|
|
|
return {
|
|
targetLayer,
|
|
aircraftClass,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize a spoken/typed aircraft-class filter to the class id the analyst
|
|
* records carry.
|
|
*
|
|
* Every real `classifyAircraft()` id is a single unpunctuated word, so callers
|
|
* already say them exactly and a plain lower-case is enough. The one exception
|
|
* is the TR-3B Easter egg (`tr3b`): people write and say it hyphenated, so
|
|
* "TR-3B" / "tr 3b" / "tr 3 b" would otherwise reach the analyst as a value no
|
|
* record matches. Collapsing spaces and hyphens and comparing against THAT ONE
|
|
* id keeps this surgical — no general alias table, and no other class id
|
|
* collapses to `tr3b`, so nothing else can be caught by it.
|
|
*
|
|
* App-side only: the voice tool schema and the model instructions are
|
|
* untouched, so this costs no prompt-cache churn.
|
|
* @param {*} value Raw class filter from the tool call or an utterance hint.
|
|
* @returns {string|null} Class id, or null when nothing was supplied.
|
|
*/
|
|
function normalizeAircraftClassFilter(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return null;
|
|
return raw.replace(/[\s-]+/g, '') === TR3B_CLASS ? TR3B_CLASS : raw;
|
|
}
|
|
|
|
function setPanelOpen(styleManager, panelId, open) {
|
|
if (styleManager && typeof styleManager.setPanelCollapsed === 'function') {
|
|
styleManager.setPanelCollapsed(panelId, !open, { explicit: true });
|
|
} else {
|
|
const panel = document.getElementById(panelId);
|
|
if (panel) panel.classList.toggle('collapsed', !open);
|
|
}
|
|
}
|
|
|
|
function normalizeContextMode(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return null;
|
|
return CONTEXT_MODE_ALIASES.get(raw) || null;
|
|
}
|
|
|
|
function normalizeCockpitAction(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return null;
|
|
|
|
const direct = COCKPIT_ACTION_ALIASES.get(raw);
|
|
if (direct) return direct;
|
|
|
|
const normalized = raw
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
const directNormalized = COCKPIT_ACTION_ALIASES.get(normalized);
|
|
if (directNormalized) return directNormalized;
|
|
|
|
if (/\bprevious\b|\bprev\b/.test(normalized)) return 'previous';
|
|
if (/\bstatus\b|\bstate\b/.test(normalized)) return 'status';
|
|
if (/\bexit\b|\bleave\b|\bquit\b/.test(normalized)) return 'exit';
|
|
if (/\benter\b|\bopen\b|\bstart\b/.test(normalized)) return 'enter';
|
|
if (/\bnext\b|\bclosest\b|\bnearby\b|\bnearest\b/.test(normalized))
|
|
return 'next';
|
|
|
|
return null;
|
|
}
|
|
|
|
function focusDataLayerRow(layerId) {
|
|
const row = document.querySelector(
|
|
`#data-toggles [data-layer-id="${CSS.escape(layerId)}"]`,
|
|
);
|
|
if (!row) return null;
|
|
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
row.classList.remove('gev-voice-focus');
|
|
void row.offsetWidth;
|
|
row.classList.add('gev-voice-focus');
|
|
window.setTimeout(() => row.classList.remove('gev-voice-focus'), 3000);
|
|
// The English name: tool results stay English whatever the UI language.
|
|
const name =
|
|
row.dataset.layerName ||
|
|
row.querySelector('.data-name')?.textContent?.trim() ||
|
|
layerId;
|
|
return { id: layerId, name };
|
|
}
|
|
|
|
function normalizeStyle(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (raw === 'filter off' || raw === 'off' || raw === 'default')
|
|
return 'normal';
|
|
if (raw === 'night vision' || raw === 'nvg') return 'surveillance';
|
|
if (raw === 'flir') return 'thermal';
|
|
if (ALLOWED_STYLES.has(raw)) return raw;
|
|
return null;
|
|
}
|
|
|
|
async function flyToRequestedLocation(
|
|
viewer,
|
|
args,
|
|
{
|
|
placeSearch = unavailablePlaceSearch,
|
|
searchNavigation = searchAndFlyTo,
|
|
signal,
|
|
onProgress = () => {},
|
|
onStart = null,
|
|
runImmediate = null,
|
|
beginDeferred = null,
|
|
reassertDeferred = null,
|
|
} = {},
|
|
) {
|
|
const requestedRangeM = Number(args.rangeM);
|
|
const rangeM = Number.isFinite(requestedRangeM)
|
|
? clampNumber(requestedRangeM, 100, 20000000, 900)
|
|
: null;
|
|
const locationId = normalizeLocationId(args.locationId || args.query);
|
|
const immediate = (navigate) =>
|
|
typeof runImmediate === 'function' ? runImmediate(navigate) : navigate();
|
|
const immediateOnStart = typeof runImmediate === 'function' ? null : onStart;
|
|
const cancelled = (label) => ({
|
|
ok: false,
|
|
cancelled: true,
|
|
action: 'fly_to_location',
|
|
label,
|
|
});
|
|
let settleArrival = null;
|
|
const arrival =
|
|
args.waitForArrival === true
|
|
? new Promise((resolve) => {
|
|
settleArrival = resolve;
|
|
})
|
|
: null;
|
|
const arrivalHooks = arrival
|
|
? {
|
|
onComplete: () => settleArrival?.('arrived'),
|
|
onCancel: () => settleArrival?.('cancelled'),
|
|
}
|
|
: {};
|
|
const afterArrival = async (result, label) => {
|
|
if (!arrival || result?.ok !== true) return result;
|
|
onProgress('fly', label);
|
|
const status = await arrival;
|
|
settleArrival = null;
|
|
return status === 'arrived'
|
|
? { ...result, arrived: true }
|
|
: cancelled(label);
|
|
};
|
|
|
|
if (locationId) {
|
|
const result = immediate(() =>
|
|
flyToPresetLocation(viewer, locationId, {
|
|
...(rangeM || args.viewMode === 'close'
|
|
? { range: rangeM || 250 }
|
|
: { viewMode: 'overview' }),
|
|
duration: 2.2,
|
|
onStart: immediateOnStart,
|
|
...arrivalHooks,
|
|
}),
|
|
);
|
|
if (result === false)
|
|
return cancelled(CITY_POIS[locationId]?.name || locationId);
|
|
const response = {
|
|
ok: Boolean(result),
|
|
action: 'fly_to_location',
|
|
locationId,
|
|
label: CITY_POIS[locationId]?.name || locationId,
|
|
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
|
|
navigationMode: rangeM
|
|
? 'explicit-range'
|
|
: args.viewMode === 'close'
|
|
? 'city-close'
|
|
: result?.navigationMode || 'city-overview',
|
|
};
|
|
return afterArrival(response, response.label);
|
|
}
|
|
|
|
const latitude = Number(args.latitude);
|
|
const longitude = Number(args.longitude);
|
|
if (Number.isFinite(latitude) && Number.isFinite(longitude)) {
|
|
const result = immediate(() =>
|
|
flyToLandmark(viewer, latitude, longitude, {
|
|
range: rangeM || 250,
|
|
pitch: -35,
|
|
heading: 0,
|
|
buildingHeight: 0,
|
|
duration: 2.2,
|
|
onStart: immediateOnStart,
|
|
...arrivalHooks,
|
|
}),
|
|
);
|
|
if (result === false)
|
|
return cancelled(`${latitude.toFixed(4)}, ${longitude.toFixed(4)}`);
|
|
const response = {
|
|
ok: true,
|
|
action: 'fly_to_location',
|
|
latitude,
|
|
longitude,
|
|
label: `${latitude.toFixed(4)}, ${longitude.toFixed(4)}`,
|
|
rangeM: Math.round(rangeM || 250),
|
|
navigationMode: rangeM ? 'explicit-range' : 'close-coordinate',
|
|
};
|
|
return afterArrival(response, response.label);
|
|
}
|
|
|
|
const query = String(args.query || '').trim();
|
|
if (query) {
|
|
// A query that names a curated preset POI ("the Texas State Capitol", "Golden Gate Bridge")
|
|
// flies to its hand-tuned camera pose — the same beautiful framing as clicking the LOCATIONS
|
|
// panel button — instead of generic geocode framing. An explicit rangeM still overrides distance.
|
|
const poiMatch = findPoiByName(query);
|
|
if (poiMatch) {
|
|
const result = immediate(() =>
|
|
flyToPOI(viewer, poiMatch.cityId, poiMatch.index, {
|
|
duration: 2.2,
|
|
onStart: immediateOnStart,
|
|
...arrivalHooks,
|
|
...(rangeM ? { range: rangeM } : {}),
|
|
}),
|
|
);
|
|
const poi = CITY_POIS[poiMatch.cityId]?.pois?.[poiMatch.index];
|
|
if (result === false) return cancelled(poi?.name || query);
|
|
const response = {
|
|
ok: Boolean(result),
|
|
action: 'fly_to_location',
|
|
query,
|
|
label: poi?.name || query,
|
|
navigationMode: rangeM ? 'preset-poi-range' : 'preset-poi',
|
|
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
|
|
};
|
|
return afterArrival(response, response.label);
|
|
}
|
|
|
|
const generation =
|
|
typeof beginDeferred === 'function' ? beginDeferred() : null;
|
|
if (generation === false) return cancelled(query);
|
|
onProgress('search', query);
|
|
const managedDeferred = typeof reassertDeferred === 'function';
|
|
const destination = await searchNavigation(viewer, query, {
|
|
placeSearch,
|
|
signal,
|
|
...(rangeM ? { range: rangeM } : {}),
|
|
forceClose: args.viewMode === 'close',
|
|
// 'overview' frames the geocode viewport even for precise-place results —
|
|
// previously dropped here, so "overview of Zilker Park" flew to a rooftop.
|
|
viewMode: args.viewMode || null,
|
|
duration: 2.2,
|
|
...arrivalHooks,
|
|
beforeFly: managedDeferred ? () => reassertDeferred(generation) : null,
|
|
onStart: managedDeferred ? null : onStart,
|
|
});
|
|
if (destination?.cancelled) return cancelled(query);
|
|
const response = {
|
|
ok: Boolean(destination),
|
|
action: 'fly_to_location',
|
|
query,
|
|
label: destination?.label || query,
|
|
navigationMode: destination?.navigationMode || null,
|
|
rangeM: destination?.rangeM || rangeM || null,
|
|
};
|
|
return afterArrival(response, response.label);
|
|
}
|
|
|
|
throw new Error(
|
|
'fly_to_location needs a locationId, query, or latitude/longitude',
|
|
);
|
|
}
|
|
|
|
function normalizeLocationId(value) {
|
|
const raw = String(value || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (!raw) return null;
|
|
if (CITY_POIS[raw]) return raw;
|
|
if (CITY_ALIASES.has(raw)) return CITY_ALIASES.get(raw);
|
|
return null;
|
|
}
|
|
|
|
function getCurrentViewState(
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
sceneDirector = null,
|
|
) {
|
|
return getWorldPacket({
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
sceneDirector,
|
|
});
|
|
}
|
|
|
|
async function getEntityContext(
|
|
viewer,
|
|
dataManager,
|
|
styleManager,
|
|
args = {},
|
|
service = defaultGeospatial,
|
|
) {
|
|
const startedAt = performance.now();
|
|
const scope = String(args.scope || 'auto').toLowerCase();
|
|
if (scope === 'target' && args.target) {
|
|
return getTargetContext(viewer, dataManager, styleManager, args, service);
|
|
}
|
|
const layerId = normalizeLayerId(args.layerId || args.layer);
|
|
const limit = Math.round(clampNumber(args.limit, 1, 12, 5));
|
|
const selected = selectedEntityContext(dataManager);
|
|
const cameraHeightM = viewer.camera.positionCartographic.height;
|
|
const viewTarget = getViewTargetCartographic(viewer);
|
|
const scenePromise = getSceneContext(
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
viewTarget,
|
|
service,
|
|
);
|
|
const selectedWillBeReturned =
|
|
selected && (scope === 'selected' || scope === 'auto');
|
|
const visible =
|
|
!selectedWillBeReturned && shouldScanVisibleEntities(cameraHeightM)
|
|
? visibleEntityContexts(viewer, dataManager, {
|
|
layerId,
|
|
limit,
|
|
target: viewTarget,
|
|
})
|
|
: [];
|
|
const scene = await scenePromise;
|
|
|
|
if ((scope === 'selected' || scope === 'auto') && selected) {
|
|
logSlowContext(startedAt, 'selected');
|
|
return {
|
|
ok: true,
|
|
action: 'get_entity_context',
|
|
scope: 'selected',
|
|
scene,
|
|
selected,
|
|
};
|
|
}
|
|
|
|
logSlowContext(startedAt, 'in_view');
|
|
return {
|
|
ok: true,
|
|
action: 'get_entity_context',
|
|
scope: 'in_view',
|
|
scene,
|
|
selected: selected || null,
|
|
visible,
|
|
count: visible.length,
|
|
visibleScanSkipped: !shouldScanVisibleEntities(cameraHeightM),
|
|
};
|
|
}
|
|
|
|
const AIRCRAFT_LAYERS = new Set(['flights', 'military']);
|
|
|
|
/** Live position of a layer record by id, or null when it is gone. */
|
|
function locateLayerEntity(dataManager, { layerId, id, lat, lon }) {
|
|
if (!layerId || !id) return null;
|
|
if (dataManager?.isEnabled && !dataManager.isEnabled(layerId)) return null;
|
|
let found = null;
|
|
try {
|
|
found =
|
|
dataManager?.layers?.get(layerId)?.module?.findByQuery?.(String(id)) ||
|
|
null;
|
|
} catch {
|
|
found = null;
|
|
}
|
|
if (Number.isFinite(found?.latitude) && Number.isFinite(found?.longitude))
|
|
return { lat: found.latitude, lon: found.longitude, found };
|
|
const record = getContextStore().entities.get(String(id));
|
|
if (
|
|
record?.layerId === layerId &&
|
|
Number.isFinite(record.latitude) &&
|
|
Number.isFinite(record.longitude)
|
|
)
|
|
return { lat: record.latitude, lon: record.longitude, record };
|
|
// Analyst-only layers (e.g. bikeshare) have no pick/context lookup. Read
|
|
// their current snapshot, so a removed or moved record is not resurrected.
|
|
try {
|
|
const matches =
|
|
dataManager?.layers
|
|
?.get(layerId)
|
|
?.module?.getAnalystRecords?.()
|
|
.filter(
|
|
(item) =>
|
|
String(item.icao24 ?? item.mmsi ?? item.noradId ?? item.id) ===
|
|
String(id),
|
|
) || [];
|
|
// Bikeshare uses station names as IDs: retain the coordinate identity
|
|
// even if a namesake disappears. Moving records use their fresh fix.
|
|
const row =
|
|
matches.length === 1 && layerId !== 'bikeshare'
|
|
? matches[0]
|
|
: matches.find((item) => item.lat === lat && item.lon === lon);
|
|
if (Number.isFinite(row?.lat) && Number.isFinite(row?.lon))
|
|
return {
|
|
lat: row.lat,
|
|
lon: row.lon,
|
|
found: { ...row, latitude: row.lat, longitude: row.lon },
|
|
};
|
|
} catch {
|
|
// A missing or unavailable snapshot cannot establish a live position.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Describe one pointed or referenced thing without changing the selection:
|
|
* a live aircraft descriptor for contacts, the context-store record for
|
|
* everything else. Aircraft fields use the same names as a selected contact,
|
|
* so the spoken identity line is identical.
|
|
*/
|
|
function describeTargetEntity(dataManager, target) {
|
|
if (!target?.layerId) return null;
|
|
const located = locateLayerEntity(dataManager, target);
|
|
if (located?.record)
|
|
return summarizeContextRecord(located.record, { includeProperties: true });
|
|
const found = located?.found;
|
|
if (!found) {
|
|
const record = getContextStore().entities.get(String(target.id));
|
|
return record
|
|
? summarizeContextRecord(record, { includeProperties: true })
|
|
: null;
|
|
}
|
|
const name = formatTrackedEntityLabel(found, target.label || target.id);
|
|
const aircraft = AIRCRAFT_LAYERS.has(target.layerId);
|
|
const route =
|
|
found.origin && found.destination
|
|
? `${found.origin} → ${found.destination}`
|
|
: '';
|
|
return {
|
|
id: String(target.id),
|
|
name,
|
|
layerId: target.layerId,
|
|
layerName: layerTitle(target.layerId),
|
|
source: null,
|
|
latitude: found.latitude ?? null,
|
|
longitude: found.longitude ?? null,
|
|
properties: compactProperties(
|
|
aircraft
|
|
? {
|
|
name,
|
|
callsign: found.callsign || '',
|
|
registration: found.registration || '',
|
|
operator: found.airline || '',
|
|
type: found.typeName || found.typeCode || '',
|
|
route,
|
|
altitude: Number.isFinite(found.altitudeM)
|
|
? `${Math.round(found.altitudeM * 3.28084)} ft`
|
|
: '',
|
|
}
|
|
: { name, mmsi: found.mmsi || '', noradId: found.noradId || '' },
|
|
),
|
|
active: true,
|
|
};
|
|
}
|
|
|
|
/** get_entity_context for a pointed/referenced target (scope 'target'). */
|
|
async function getTargetContext(
|
|
viewer,
|
|
dataManager,
|
|
styleManager,
|
|
args,
|
|
service,
|
|
) {
|
|
const target = args.target;
|
|
// Number(null) is 0: a coordinate-less referent must not become (0, 0).
|
|
const coordinate = (value) =>
|
|
value === null || value === undefined || value === '' ? NaN : Number(value);
|
|
let lat = coordinate(target.lat);
|
|
let lon = coordinate(target.lon);
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
const located = locateLayerEntity(dataManager, target);
|
|
lat = located?.lat ?? NaN;
|
|
lon = located?.lon ?? NaN;
|
|
}
|
|
const hasPosition = Number.isFinite(lat) && Number.isFinite(lon);
|
|
const viewTarget = hasPosition
|
|
? Cesium.Cartographic.fromDegrees(lon, lat)
|
|
: getViewTargetCartographic(viewer);
|
|
const selected = describeTargetEntity(dataManager, target);
|
|
const scene = await getSceneContext(
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
viewTarget,
|
|
service,
|
|
);
|
|
if (selected) {
|
|
return {
|
|
ok: true,
|
|
action: 'get_entity_context',
|
|
scope: 'pointer',
|
|
scene,
|
|
selected,
|
|
};
|
|
}
|
|
const cameraHeightM = viewer.camera.positionCartographic.height;
|
|
const visible = shouldScanVisibleEntities(cameraHeightM)
|
|
? visibleEntityContexts(viewer, dataManager, {
|
|
layerId: normalizeLayerId(args.layerId || args.layer),
|
|
limit: Math.round(clampNumber(args.limit, 1, 12, 5)),
|
|
target: viewTarget,
|
|
})
|
|
: [];
|
|
return {
|
|
ok: true,
|
|
action: 'get_entity_context',
|
|
scope: 'pointer',
|
|
scene,
|
|
selected: null,
|
|
...(hasPosition
|
|
? {
|
|
pointer: {
|
|
latitude: Number(lat.toFixed(5)),
|
|
longitude: Number(lon.toFixed(5)),
|
|
},
|
|
}
|
|
: {}),
|
|
visible,
|
|
count: visible.length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Answer an entity-centred "how many aircraft nearby" from the Contacts
|
|
* engine, or null when the question is not that.
|
|
*
|
|
* Applies only when the requested radius centre IS the active Contacts
|
|
* subject: that is precisely the case where the operator can see a number on
|
|
* the panel, so the spoken answer must be that number. Everything else — an
|
|
* explicit region, an arbitrary point, a named place — keeps the general
|
|
* record engine, which is what those questions actually mean.
|
|
* @param {object} args Tool arguments.
|
|
* @param {object} result The general engine's result, reused for scope text.
|
|
* @returns {object|null} A unified-count payload, or null.
|
|
*/
|
|
async function aircraftProximityWindowForQuery(
|
|
dataManager,
|
|
args,
|
|
result,
|
|
isCurrent = () => true,
|
|
) {
|
|
// Follow-ups re-filter the engine's retained answer. Re-projecting a fresh
|
|
// Contacts snapshot here would restore rows a previous filter removed.
|
|
if (result?.coverage?.followUp) return null;
|
|
const spec = normalizeAnalystSpec(args, args?.layers || []);
|
|
if (!spec) return null;
|
|
const scope = args?.scope;
|
|
if (String(scope?.kind || '').toLowerCase() !== 'radius') return null;
|
|
const layers = Array.isArray(args.layers) ? args.layers : [];
|
|
if (!layers.some((layer) => layer === 'flights' || layer === 'military'))
|
|
return null;
|
|
const awareness = dataManager?.layers?.get('military-awareness')?.module;
|
|
const snapshot = awareness?.getAircraftQuerySnapshot?.();
|
|
const subject = snapshot?.subject;
|
|
if (!subject?.position) return null;
|
|
// An explicit centre only qualifies when it IS the subject; otherwise the
|
|
// operator asked about somewhere else and must get that answer.
|
|
if (scope.center && !centerMatchesSubject(scope.center, subject.position))
|
|
return null;
|
|
const radiusM = Number.isFinite(scope.km)
|
|
? scope.km * 1000
|
|
: snapshot.radiusM || 250_000;
|
|
// A different radius is a different question. Let the general analyst
|
|
// engine answer it instead of relabelling the panel's 250 km snapshot.
|
|
if (
|
|
Number.isFinite(snapshot.radiusM) &&
|
|
Math.abs(radiusM - snapshot.radiusM) > 1
|
|
)
|
|
return null;
|
|
const label = subject.label || subject.id || 'the selected contact';
|
|
const radiusKm = Math.round(radiusM / 1000);
|
|
const wanted = layers.filter(
|
|
(layer) => layer === 'flights' || layer === 'military',
|
|
);
|
|
// Provenance belongs to the Awareness evaluation, not the current layer
|
|
// registry. The panel deliberately retains a cohort between render-cadence
|
|
// refreshes, so a live registry read here could already describe a newer
|
|
// source state than the count and items the operator is looking at.
|
|
const retainedProvenance = wanted
|
|
.map((key) => snapshot.cohorts?.[key]?.provenance)
|
|
.filter(Boolean);
|
|
const feedProvenance = feedProvenanceEnvelope(retainedProvenance);
|
|
const sourceSnapshotsById = new Map(
|
|
feedProvenance.layers.map((layer) => [layer.id, layer]),
|
|
);
|
|
// The cohort is the panel's; each contact is joined to its loaded analyst
|
|
// record so the same filters, ranking and item shape apply as everywhere
|
|
// else. The cohort still owns membership and distance.
|
|
const distanceKm = new WeakMap();
|
|
const groups = wanted.map((key) => {
|
|
const records = new Map();
|
|
for (const record of dataManager.layers
|
|
.get(key)
|
|
?.module?.getAnalystRecords?.(ANALYST_RECORD_CAP) || [])
|
|
if (record?.icao24) records.set(record.icao24, record);
|
|
const rows = (snapshot.cohorts?.[key]?.items || []).map((item) => {
|
|
const record = records.get(item.icao24);
|
|
let lat = record?.lat;
|
|
let lon = record?.lon;
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
|
const carto = item.position
|
|
? Cesium.Cartographic.fromCartesian(item.position)
|
|
: null;
|
|
lat = carto ? Cesium.Math.toDegrees(carto.latitude) : null;
|
|
lon = carto ? Cesium.Math.toDegrees(carto.longitude) : null;
|
|
}
|
|
const row = {
|
|
...(record || {
|
|
id: item.id,
|
|
icao24: item.icao24,
|
|
callsign: item.callsign ?? null,
|
|
altitudeM: item.altitudeM ?? null,
|
|
speedMps: item.velocityMps ?? null,
|
|
aircraftClass: item.aircraftClass ?? null,
|
|
}),
|
|
lat,
|
|
lon,
|
|
};
|
|
// Rank the retained cohort at its original precision; display rounding
|
|
// must not turn nearby contacts into artificial ID-sorted ties.
|
|
if (Number.isFinite(item.distance))
|
|
distanceKm.set(row, item.distance / 1000);
|
|
return row;
|
|
});
|
|
return { key, rows };
|
|
});
|
|
const selection = await selectAnalystRows(groups, {
|
|
filters: spec.filters,
|
|
sortBy: spec.sortBy,
|
|
sortDir: spec.sortDir,
|
|
limit: Math.round(clampNumber(args.limit, 1, 50, 12)),
|
|
layerKeys: wanted,
|
|
distanceOf: (row) => distanceKm.get(row),
|
|
isCurrent,
|
|
});
|
|
if (!selection || !isCurrent()) return null;
|
|
const items = selection.items.map(compactAnalystItem);
|
|
const complete = wanted.every((key) => {
|
|
const cohort = snapshot.cohorts?.[key];
|
|
return Number.isFinite(cohort?.count) && cohort.complete !== false;
|
|
});
|
|
const genericLayers = new Map(
|
|
(result?.coverage?.layersQueried || []).map((layer) => [
|
|
layer.layerKey,
|
|
layer,
|
|
]),
|
|
);
|
|
const layersQueried = wanted.map((key) => {
|
|
const cohort = snapshot.cohorts?.[key] || {};
|
|
const generic = genericLayers.get(key) || {};
|
|
const sourceSnapshot = sourceSnapshotsById.get(key);
|
|
const returned = Number.isFinite(cohort.count)
|
|
? cohort.count
|
|
: cohort.items?.length || 0;
|
|
return {
|
|
layerKey: key,
|
|
...(generic.status ? { status: generic.status } : {}),
|
|
...(generic.note ? { note: generic.note } : {}),
|
|
...(generic.window ? { window: generic.window } : {}),
|
|
returned,
|
|
total: cohort.complete === false ? null : returned,
|
|
truncated: cohort.complete === false,
|
|
...(cohort.source ? { source: cohort.source } : {}),
|
|
...(sourceSnapshot
|
|
? {
|
|
feedState: sourceSnapshot.feedState,
|
|
source: sourceSnapshot.source,
|
|
lastUpdate: sourceSnapshot.lastUpdate,
|
|
enabled: sourceSnapshot.enabled,
|
|
error: sourceSnapshot.error,
|
|
}
|
|
: {}),
|
|
};
|
|
});
|
|
const coverage = {
|
|
layersQueried,
|
|
records: {
|
|
returned: selection.count,
|
|
total: complete ? selection.count : null,
|
|
truncated: !complete,
|
|
},
|
|
scope: `window:${radiusKm}km@${label}`,
|
|
followUp: false,
|
|
feedProvenance,
|
|
note: `Contacts panel snapshot${snapshot.evaluatedAt ? ` at ${new Date(snapshot.evaluatedAt).toISOString()}` : ''} — the same retained cohort supplies the card, speech, and follow-up memory${complete ? '' : '; the count is a lower bound because at least one layer reached its retained limit'}.`,
|
|
};
|
|
const answer = {
|
|
ok: true,
|
|
action: 'analyst_query',
|
|
count: selection.count,
|
|
complete,
|
|
scopeLabel: `within ${radiusKm} km of ${label}`,
|
|
listed: items.length,
|
|
items,
|
|
summary: selection.summary,
|
|
...(result?.partial
|
|
? { partial: true, unanswered: [...(result.unanswered || [])] }
|
|
: {}),
|
|
display: {
|
|
scope: `within ${radiusKm} km of ${label} (Contacts window)`,
|
|
},
|
|
coverage,
|
|
// (D) The answer always says whose window it is and which engine produced it.
|
|
window: {
|
|
engine: 'contacts-window',
|
|
centeredOn: label,
|
|
radiusKm,
|
|
flights: snapshot.cohorts?.flights?.count ?? 'unknown',
|
|
military: snapshot.cohorts?.military?.count ?? 'unknown',
|
|
aircraft:
|
|
Number.isFinite(snapshot.cohorts?.flights?.count) &&
|
|
Number.isFinite(snapshot.cohorts?.military?.count)
|
|
? snapshot.cohorts.flights.count + snapshot.cohorts.military.count
|
|
: 'unknown',
|
|
complete,
|
|
evaluatedAt: snapshot.evaluatedAt || null,
|
|
},
|
|
...(snapshot.contactsWindow
|
|
? { contactsWindow: { ...snapshot.contactsWindow } }
|
|
: {}),
|
|
feedProvenance,
|
|
feedState: feedProvenance.overall,
|
|
};
|
|
return {
|
|
answer,
|
|
memory: {
|
|
matched: selection.matched,
|
|
// Follow-up ranking belongs to this retained Contacts evaluation, not
|
|
// the camera's current position. Keep the accessor with its row owners.
|
|
distanceOf: (row) => distanceKm.get(row),
|
|
layerKeys: wanted,
|
|
coverage,
|
|
unanswered: [...(result?.unanswered || [])],
|
|
scopeLabel: answer.scopeLabel,
|
|
scopeDetail: answer.display.scope,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Radius within which a requested centre counts as the Contacts subject's own
|
|
* position. Generous enough to absorb the fix-vs-display offset between what
|
|
* the model read off a card and where the contact is now, tight enough that a
|
|
* neighbouring landmark is never mistaken for the subject.
|
|
*/
|
|
const SUBJECT_CENTER_TOLERANCE_KM = 1;
|
|
|
|
/**
|
|
* Is this requested centre the Contacts subject's own position?
|
|
*
|
|
* Measured as true ground distance. A lat/lon delta box was wrong in a way
|
|
* that hid at the equator and widened toward it: a degree of longitude is
|
|
* ~111 km at the equator and ~78 km at 45°, so a fixed 0.01° box spans a
|
|
* different real distance at every latitude, and its diagonal admitted centres
|
|
* ~1.5 km away — far enough to be a different place, close enough to slip
|
|
* through and get answered as the subject's window.
|
|
* @param {{lat: number, lon: number}} center Requested centre.
|
|
* @param {Cesium.Cartesian3} subjectPosition Subject's world position.
|
|
* @returns {boolean} True when the two are the same place.
|
|
*/
|
|
function centerMatchesSubject(center, subjectPosition) {
|
|
if (!Number.isFinite(center?.lat) || !Number.isFinite(center?.lon))
|
|
return false;
|
|
const carto = Cesium.Cartographic.fromCartesian(subjectPosition);
|
|
if (!carto) return false;
|
|
const subjectLat = Cesium.Math.toDegrees(carto.latitude);
|
|
const subjectLon = Cesium.Math.toDegrees(carto.longitude);
|
|
return (
|
|
greatCircleKm(
|
|
{ lat: subjectLat, lon: subjectLon },
|
|
{ lat: center.lat, lon: center.lon },
|
|
) <= SUBJECT_CENTER_TOLERANCE_KM
|
|
);
|
|
}
|
|
|
|
function shouldScanVisibleEntities(cameraHeightM) {
|
|
return cameraHeightM <= 100000;
|
|
}
|
|
|
|
function selectedEntityContext(dataManager) {
|
|
const record = getSelectedEntityContext({ dataManager });
|
|
if (!record) return null;
|
|
return summarizeContextRecord(record, { includeProperties: true });
|
|
}
|
|
|
|
function visibleEntityContexts(
|
|
viewer,
|
|
dataManager,
|
|
{ layerId = null, limit = 5, target = null } = {},
|
|
) {
|
|
const nearbyRecords = [];
|
|
const canvas = viewer.scene.canvas;
|
|
const width = canvas.clientWidth || canvas.width || 0;
|
|
const height = canvas.clientHeight || canvas.height || 0;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const targetLat = target ? Cesium.Math.toDegrees(target.latitude) : null;
|
|
const targetLon = target ? Cesium.Math.toDegrees(target.longitude) : null;
|
|
const store = getContextStore();
|
|
const enabledLayerIds = new Set(
|
|
dataManager
|
|
.getAll()
|
|
.filter((layer) => layer.enabled)
|
|
.map((layer) => layer.id),
|
|
);
|
|
|
|
for (const record of store.entities.values()) {
|
|
if (layerId && record.layerId !== layerId) continue;
|
|
if (record.layerId && !enabledLayerIds.has(record.layerId)) continue;
|
|
if (record.entity?.show === false || record.dataSource?.show === false)
|
|
continue;
|
|
if (!Number.isFinite(record.latitude) || !Number.isFinite(record.longitude))
|
|
continue;
|
|
insertNearestRecord(
|
|
nearbyRecords,
|
|
{
|
|
record,
|
|
distanceScore: target
|
|
? approximateCoordinateDistanceSq(
|
|
targetLat,
|
|
targetLon,
|
|
record.latitude,
|
|
record.longitude,
|
|
)
|
|
: 0,
|
|
},
|
|
VISIBLE_ENTITY_SHORTLIST,
|
|
);
|
|
}
|
|
|
|
const candidates = [];
|
|
for (const { record } of nearbyRecords) {
|
|
const position = record.entity?.__localBaseCartesian;
|
|
if (!position) continue;
|
|
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
|
viewer.scene,
|
|
position,
|
|
);
|
|
if (
|
|
!screen ||
|
|
screen.x < 0 ||
|
|
screen.y < 0 ||
|
|
screen.x > width ||
|
|
screen.y > height
|
|
)
|
|
continue;
|
|
const dx = screen.x - centerX;
|
|
const dy = screen.y - centerY;
|
|
candidates.push({
|
|
summary: summarizeContextRecord(record, { includeProperties: true }),
|
|
distancePx: Math.sqrt(dx * dx + dy * dy),
|
|
});
|
|
}
|
|
|
|
return candidates
|
|
.sort((a, b) => a.distancePx - b.distancePx)
|
|
.slice(0, limit)
|
|
.map((item) => item.summary);
|
|
}
|
|
|
|
function insertNearestRecord(records, candidate, limit) {
|
|
if (records.length < limit) {
|
|
records.push(candidate);
|
|
records.sort((a, b) => a.distanceScore - b.distanceScore);
|
|
return;
|
|
}
|
|
if (candidate.distanceScore >= records[records.length - 1].distanceScore)
|
|
return;
|
|
|
|
let low = 0;
|
|
let high = records.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >> 1;
|
|
if (records[middle].distanceScore <= candidate.distanceScore)
|
|
low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
records.splice(low, 0, candidate);
|
|
records.pop();
|
|
}
|
|
|
|
async function getSceneContext(
|
|
viewer,
|
|
styleManager,
|
|
dataManager,
|
|
viewTarget = null,
|
|
service = defaultGeospatial,
|
|
) {
|
|
const cartographic = Cesium.Cartographic.fromCartesian(
|
|
viewer.camera.positionWC,
|
|
);
|
|
const basemap = await getBasemapContext(viewer, viewTarget, service);
|
|
const enabledLayers = dataManager
|
|
.getAll()
|
|
.filter((layer) => layer.enabled)
|
|
.map((layer) => ({
|
|
id: layer.id,
|
|
name: layer.name,
|
|
count: layer.stats?.count || 0,
|
|
source: layer.source,
|
|
}));
|
|
return {
|
|
camera: {
|
|
latitude: Number(Cesium.Math.toDegrees(cartographic.latitude).toFixed(6)),
|
|
longitude: Number(
|
|
Cesium.Math.toDegrees(cartographic.longitude).toFixed(6),
|
|
),
|
|
heightM: Math.round(cartographic.height),
|
|
},
|
|
basemap,
|
|
style: styleManager.activeStyle || 'normal',
|
|
enabledLayers,
|
|
};
|
|
}
|
|
|
|
async function getBasemapContext(
|
|
viewer,
|
|
viewTarget = null,
|
|
service = defaultGeospatial,
|
|
) {
|
|
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
|
|
const target = viewTarget;
|
|
const samples = sampleViewportCartographics(viewer);
|
|
const cameraCartographic = Cesium.Cartographic.fromCartesian(
|
|
viewer.camera.positionWC,
|
|
);
|
|
const cameraHeightM = cameraCartographic.height;
|
|
const viewScale = classifyViewScale(cameraHeightM);
|
|
const cachedViewportPlaces = viewportPlacesFromCache(
|
|
samples,
|
|
cameraHeightM,
|
|
service,
|
|
);
|
|
const viewportPlacesPromise = cachedViewportPlaces
|
|
? Promise.resolve(cachedViewportPlaces)
|
|
: reverseGeocodeViewportSamples(samples, cameraHeightM, service);
|
|
if (!target) {
|
|
const viewportPlaces = await resolveWithin(
|
|
viewportPlacesPromise,
|
|
BASEMAP_CONTEXT_WAIT_MS,
|
|
cachedViewportPlaces,
|
|
);
|
|
return {
|
|
source: 'Google Photorealistic 3D Tiles / Cesium basemap',
|
|
hasGoogle3DTiles: Boolean(window.__godsEyeView?.tileset),
|
|
viewScale,
|
|
viewportSamples: samples,
|
|
viewportPlaces,
|
|
target: null,
|
|
place: null,
|
|
};
|
|
}
|
|
|
|
const latitude = Number(Cesium.Math.toDegrees(target.latitude).toFixed(6));
|
|
const longitude = Number(Cesium.Math.toDegrees(target.longitude).toFixed(6));
|
|
const inferredCountry = inferCountryFromSamples(samples);
|
|
const knownLandmarks = nearbyKnownLandmarks(
|
|
latitude,
|
|
longitude,
|
|
cameraHeightM,
|
|
);
|
|
const fallbackPlace = coarseBasemapPlace(
|
|
viewScale,
|
|
latitude,
|
|
longitude,
|
|
inferredCountry,
|
|
);
|
|
const cachedPlace = shouldReverseGeocode(cameraHeightM)
|
|
? reverseGeocodeCache.get(
|
|
reverseGeocodeKey(latitude, longitude, service),
|
|
) || null
|
|
: null;
|
|
const nearbyCacheKey = nearbyPlacesCacheKey(
|
|
latitude,
|
|
longitude,
|
|
cameraHeightM,
|
|
service,
|
|
);
|
|
const cachedNearbyPlaces =
|
|
shouldFetchNearbyPlaces(cameraHeightM) &&
|
|
nearbyPlacesCache.has(nearbyCacheKey)
|
|
? nearbyPlacesCache.get(nearbyCacheKey)
|
|
: null;
|
|
const placePromise =
|
|
shouldReverseGeocode(cameraHeightM) && !cachedPlace
|
|
? reverseGeocode(latitude, longitude, service)
|
|
: Promise.resolve(cachedPlace);
|
|
const nearbyPlacesPromise =
|
|
shouldFetchNearbyPlaces(cameraHeightM) && !cachedNearbyPlaces
|
|
? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
|
|
: Promise.resolve(cachedNearbyPlaces);
|
|
const [viewportPlaces, resolvedPlace, resolvedNearbyPlaces] =
|
|
await Promise.all([
|
|
resolveWithin(
|
|
viewportPlacesPromise,
|
|
BASEMAP_CONTEXT_WAIT_MS,
|
|
cachedViewportPlaces,
|
|
),
|
|
resolveWithin(placePromise, BASEMAP_CONTEXT_WAIT_MS, cachedPlace),
|
|
resolveWithin(
|
|
nearbyPlacesPromise,
|
|
BASEMAP_CONTEXT_WAIT_MS,
|
|
cachedNearbyPlaces,
|
|
),
|
|
]);
|
|
const place = resolvedPlace || fallbackPlace;
|
|
const nearbyPlaces = resolvedNearbyPlaces || [];
|
|
return {
|
|
source: 'Google Photorealistic 3D Tiles / Cesium basemap',
|
|
hasGoogle3DTiles: Boolean(window.__godsEyeView?.tileset),
|
|
viewScale,
|
|
viewportSamples: samples,
|
|
viewportPlaces,
|
|
target: {
|
|
latitude,
|
|
longitude,
|
|
heightM: Math.round(target.height || 0),
|
|
},
|
|
knownLandmarks,
|
|
nearbyPlaces,
|
|
place,
|
|
};
|
|
}
|
|
|
|
function classifyViewScale(cameraHeightM) {
|
|
if (cameraHeightM > 12000000) return 'global';
|
|
if (cameraHeightM > 3000000) return 'continental';
|
|
if (cameraHeightM > 750000) return 'regional';
|
|
if (cameraHeightM > 100000) return 'metro';
|
|
if (cameraHeightM > 10000) return 'city';
|
|
return 'local';
|
|
}
|
|
|
|
function shouldReverseGeocode(cameraHeightM) {
|
|
return cameraHeightM <= 750000;
|
|
}
|
|
|
|
function shouldReverseGeocodeViewport(cameraHeightM) {
|
|
return cameraHeightM <= 3000000;
|
|
}
|
|
|
|
function shouldFetchNearbyPlaces(cameraHeightM) {
|
|
return cameraHeightM <= 25000;
|
|
}
|
|
|
|
function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) {
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
|
const maxDistanceKm =
|
|
cameraHeightM <= 5000
|
|
? 2
|
|
: cameraHeightM <= 50000
|
|
? 10
|
|
: cameraHeightM <= 250000
|
|
? 35
|
|
: 0;
|
|
if (maxDistanceKm <= 0) return [];
|
|
|
|
const matches = [];
|
|
for (const [cityId, city] of Object.entries(CITY_POIS)) {
|
|
for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) {
|
|
const poi = city.pois[poiIndex];
|
|
const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi);
|
|
if (distanceKm > maxDistanceKm) continue;
|
|
matches.push({
|
|
name: poi.name,
|
|
cityId,
|
|
city: city.name,
|
|
latitude: poi.lat,
|
|
longitude: poi.lon,
|
|
distanceKm: Number(distanceKm.toFixed(3)),
|
|
});
|
|
}
|
|
}
|
|
return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5);
|
|
}
|
|
|
|
function coarseBasemapPlace(
|
|
viewScale,
|
|
latitude,
|
|
longitude,
|
|
inferredCountry = null,
|
|
) {
|
|
if (viewScale === 'global') {
|
|
return {
|
|
formattedAddress: 'Global Earth view',
|
|
locality: null,
|
|
region: null,
|
|
country: null,
|
|
precision: 'global',
|
|
note: 'Camera is too far out for a precise street or city label; do not infer a local place from the center point.',
|
|
};
|
|
}
|
|
return {
|
|
formattedAddress: inferredCountry?.country
|
|
? `${viewScale[0].toUpperCase()}${viewScale.slice(1)} basemap view over ${inferredCountry.country}`
|
|
: `${viewScale[0].toUpperCase()}${viewScale.slice(1)} basemap view centered near ${latitude.toFixed(2)}, ${longitude.toFixed(2)}`,
|
|
locality: null,
|
|
region: null,
|
|
country: inferredCountry?.country || null,
|
|
precision: viewScale,
|
|
confidence: inferredCountry?.confidence || null,
|
|
note: 'Camera altitude is high, so this is approximate basemap context rather than a precise address.',
|
|
};
|
|
}
|
|
|
|
function getViewTargetCartographic(viewer) {
|
|
const signature = cameraViewSignature(viewer);
|
|
const cached = viewTargetCache.get(viewer);
|
|
if (
|
|
cached?.signature === signature &&
|
|
performance.now() - cached.cachedAt < 2500
|
|
) {
|
|
return cached.target;
|
|
}
|
|
const position = getViewTargetCartesian(viewer);
|
|
// `fromCartesian` still returns undefined for a point too near the ellipsoid
|
|
// center to project; normalize that to the same "no target" null the callers
|
|
// already handle for a missed pick.
|
|
const target = position
|
|
? Cesium.Cartographic.fromCartesian(position) || null
|
|
: null;
|
|
viewTargetCache.set(viewer, {
|
|
signature,
|
|
target,
|
|
cachedAt: performance.now(),
|
|
});
|
|
return target;
|
|
}
|
|
|
|
function cameraViewSignature(viewer) {
|
|
const camera = viewer.camera;
|
|
const cartographic = camera.positionCartographic;
|
|
return [
|
|
Cesium.Math.toDegrees(cartographic.latitude).toFixed(5),
|
|
Cesium.Math.toDegrees(cartographic.longitude).toFixed(5),
|
|
Math.round(cartographic.height / 2),
|
|
camera.heading.toFixed(3),
|
|
camera.pitch.toFixed(3),
|
|
].join(':');
|
|
}
|
|
|
|
/**
|
|
* World position under the center of the viewport, or null when the view has no
|
|
* target. Each stage of the cascade is validated before it is accepted: a depth
|
|
* pick over empty sky can return a NaN or center-of-the-earth Cartesian, and
|
|
* converting one of those throws deep inside Cesium. A degenerate pick is a
|
|
* MISSED pick, so it falls through to the next stage rather than poisoning
|
|
* every caller downstream.
|
|
*/
|
|
function getViewTargetCartesian(viewer) {
|
|
const scene = viewer.scene;
|
|
const canvas = scene.canvas;
|
|
const width = canvas.clientWidth || canvas.width || 0;
|
|
const height = canvas.clientHeight || canvas.height || 0;
|
|
const center = new Cesium.Cartesian2(width / 2, height / 2);
|
|
let position = null;
|
|
|
|
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') {
|
|
try {
|
|
position = scene.pickPosition(center);
|
|
} catch {
|
|
position = null;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!isPickedWorldPosition(position) &&
|
|
viewer.camera &&
|
|
typeof viewer.camera.pickEllipsoid === 'function'
|
|
) {
|
|
try {
|
|
position = viewer.camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
|
|
} catch {
|
|
position = null;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!isPickedWorldPosition(position) &&
|
|
viewer.camera &&
|
|
typeof viewer.camera.getPickRay === 'function'
|
|
) {
|
|
try {
|
|
const ray = viewer.camera.getPickRay(center);
|
|
position = scene.globe?.pick(ray, scene) || null;
|
|
} catch {
|
|
position = null;
|
|
}
|
|
}
|
|
|
|
return isPickedWorldPosition(position) ? position : null;
|
|
}
|
|
|
|
function sampleViewportCartographics(viewer) {
|
|
const scene = viewer.scene;
|
|
const canvas = scene.canvas;
|
|
const width = canvas.clientWidth || canvas.width || 0;
|
|
const height = canvas.clientHeight || canvas.height || 0;
|
|
if (!width || !height) return [];
|
|
|
|
const points = [
|
|
[0.5, 0.5],
|
|
[0.25, 0.35],
|
|
[0.75, 0.35],
|
|
[0.25, 0.65],
|
|
[0.75, 0.65],
|
|
[0.5, 0.25],
|
|
[0.5, 0.75],
|
|
];
|
|
|
|
const samples = [];
|
|
for (const [x, y] of points) {
|
|
const cartesian = viewer.camera.pickEllipsoid(
|
|
new Cesium.Cartesian2(width * x, height * y),
|
|
Cesium.Ellipsoid.WGS84,
|
|
);
|
|
if (!isPickedWorldPosition(cartesian)) continue;
|
|
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
|
if (!carto) continue;
|
|
samples.push({
|
|
latitude: Number(Cesium.Math.toDegrees(carto.latitude).toFixed(4)),
|
|
longitude: Number(Cesium.Math.toDegrees(carto.longitude).toFixed(4)),
|
|
});
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
function inferCountryFromSamples(samples) {
|
|
if (!samples.length) return null;
|
|
const counts = new Map();
|
|
for (const sample of samples) {
|
|
const country = inferCountry(sample.latitude, sample.longitude);
|
|
if (!country) continue;
|
|
counts.set(country, (counts.get(country) || 0) + 1);
|
|
}
|
|
if (!counts.size) return null;
|
|
const [country, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
return {
|
|
country,
|
|
confidence: Number((count / samples.length).toFixed(2)),
|
|
sampleCount: count,
|
|
totalSamples: samples.length,
|
|
};
|
|
}
|
|
|
|
function inferCountry(latitude, longitude) {
|
|
const regions = [
|
|
{ name: 'Iran', south: 24.0, north: 40.2, west: 44.0, east: 63.5 },
|
|
{ name: 'Iraq', south: 29.0, north: 37.5, west: 38.5, east: 49.0 },
|
|
{ name: 'Turkey', south: 35.5, north: 42.5, west: 25.5, east: 45.2 },
|
|
{ name: 'Saudi Arabia', south: 16.0, north: 32.5, west: 34.0, east: 56.5 },
|
|
{ name: 'Afghanistan', south: 29.0, north: 38.8, west: 60.0, east: 75.5 },
|
|
{ name: 'Pakistan', south: 23.0, north: 37.2, west: 60.5, east: 77.5 },
|
|
{ name: 'Turkmenistan', south: 35.0, north: 42.9, west: 52.0, east: 66.8 },
|
|
{ name: 'Azerbaijan', south: 38.3, north: 41.9, west: 44.6, east: 50.8 },
|
|
{ name: 'Armenia', south: 38.7, north: 41.4, west: 43.4, east: 46.7 },
|
|
{ name: 'Japan', south: 24.0, north: 46.5, west: 122.0, east: 146.5 },
|
|
{ name: 'South Korea', south: 33.0, north: 38.8, west: 124.0, east: 132.0 },
|
|
{ name: 'North Korea', south: 37.5, north: 43.2, west: 124.0, east: 131.0 },
|
|
{ name: 'China', south: 18.0, north: 53.8, west: 73.0, east: 135.2 },
|
|
{ name: 'Russia', south: 41.0, north: 82.0, west: 19.0, east: 180.0 },
|
|
{
|
|
name: 'United States',
|
|
south: 24.0,
|
|
north: 49.8,
|
|
west: -125.0,
|
|
east: -66.0,
|
|
},
|
|
];
|
|
const region = regions.find(
|
|
(item) =>
|
|
latitude >= item.south &&
|
|
latitude <= item.north &&
|
|
longitude >= item.west &&
|
|
longitude <= item.east,
|
|
);
|
|
return region?.name || null;
|
|
}
|
|
|
|
async function reverseGeocode(latitude, longitude, service) {
|
|
const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service);
|
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
|
const key = reverseGeocodeKey(latitude, longitude, service);
|
|
if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key);
|
|
if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key);
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const place =
|
|
(await service.reverseGeocode?.(latitude, longitude)) || null;
|
|
if (place) {
|
|
reverseGeocodeCache.set(key, place);
|
|
if (reverseGeocodeCache.size > 256)
|
|
reverseGeocodeCache.delete(reverseGeocodeCache.keys().next().value);
|
|
}
|
|
return place;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
reverseGeocodeInFlight.delete(key);
|
|
}
|
|
})();
|
|
reverseGeocodeInFlight.set(key, request);
|
|
return request;
|
|
}
|
|
|
|
async function reverseGeocodeViewportSamples(samples, cameraHeightM, service) {
|
|
if (!shouldReverseGeocodeViewport(cameraHeightM) || !samples.length)
|
|
return null;
|
|
// At building scale, center geocoding plus Nearby Places is more precise and
|
|
// avoids three redundant Google requests.
|
|
if (cameraHeightM <= 10000) return null;
|
|
const prioritySamples = [samples[0], samples[1], samples[2]].filter(Boolean);
|
|
const places = (
|
|
await Promise.all(
|
|
prioritySamples.map(async (sample) => {
|
|
const place = await reverseGeocode(
|
|
sample.latitude,
|
|
sample.longitude,
|
|
service,
|
|
);
|
|
if (!place) return null;
|
|
return {
|
|
latitude: sample.latitude,
|
|
longitude: sample.longitude,
|
|
formattedAddress: place.formattedAddress,
|
|
locality: place.locality,
|
|
region: place.region,
|
|
country: place.country,
|
|
types: place.types,
|
|
labels: place.labels,
|
|
streetLabels: place.streetLabels,
|
|
};
|
|
}),
|
|
)
|
|
).filter(Boolean);
|
|
return summarizeViewportPlaces(places);
|
|
}
|
|
|
|
function viewportPlacesFromCache(samples, cameraHeightM, service) {
|
|
const { reverseGeocodeCache } = cachesFor(service);
|
|
if (
|
|
!shouldReverseGeocodeViewport(cameraHeightM) ||
|
|
cameraHeightM <= 10000 ||
|
|
!samples.length
|
|
)
|
|
return null;
|
|
const places = [samples[0], samples[1], samples[2]]
|
|
.filter(Boolean)
|
|
.flatMap((sample) => {
|
|
const place = reverseGeocodeCache.get(
|
|
reverseGeocodeKey(sample.latitude, sample.longitude, service),
|
|
);
|
|
if (!place) return [];
|
|
return [
|
|
{
|
|
latitude: sample.latitude,
|
|
longitude: sample.longitude,
|
|
formattedAddress: place.formattedAddress,
|
|
locality: place.locality,
|
|
region: place.region,
|
|
country: place.country,
|
|
types: place.types,
|
|
labels: place.labels,
|
|
streetLabels: place.streetLabels,
|
|
},
|
|
];
|
|
});
|
|
return summarizeViewportPlaces(places);
|
|
}
|
|
|
|
function summarizeViewportPlaces(places) {
|
|
if (!places.length) return null;
|
|
return {
|
|
samples: places,
|
|
dominantCountry: dominantValue(
|
|
places.map((place) => place.country).filter(Boolean),
|
|
),
|
|
dominantRegion: dominantValue(
|
|
places.map((place) => place.region).filter(Boolean),
|
|
),
|
|
dominantLocality: dominantValue(
|
|
places.map((place) => place.locality).filter(Boolean),
|
|
),
|
|
visibleLabels: uniqueStrings(
|
|
places.flatMap((place) => place.labels || []),
|
|
).slice(0, 24),
|
|
streetLabels: uniqueStrings(
|
|
places.flatMap((place) => place.streetLabels || []),
|
|
).slice(0, 20),
|
|
};
|
|
}
|
|
|
|
async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) {
|
|
const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service);
|
|
const radiusM = nearbyPlacesRadiusM(cameraHeightM);
|
|
const cacheKey = nearbyPlacesCacheKey(
|
|
latitude,
|
|
longitude,
|
|
cameraHeightM,
|
|
service,
|
|
);
|
|
if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey);
|
|
if (nearbyPlacesInFlight.has(cacheKey))
|
|
return nearbyPlacesInFlight.get(cacheKey);
|
|
|
|
const request = (async () => {
|
|
try {
|
|
const places = (
|
|
(await service.nearby?.({ latitude, longitude, radiusM })) || []
|
|
)
|
|
.filter((place) => place?.name)
|
|
.slice(0, 12);
|
|
nearbyPlacesCache.set(cacheKey, places);
|
|
if (nearbyPlacesCache.size > 256)
|
|
nearbyPlacesCache.delete(nearbyPlacesCache.keys().next().value);
|
|
return places;
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
nearbyPlacesInFlight.delete(cacheKey);
|
|
}
|
|
})();
|
|
nearbyPlacesInFlight.set(cacheKey, request);
|
|
return request;
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return [
|
|
...new Set(values.map((value) => sanitizeLabel(value)).filter(Boolean)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalize a feed-sourced label (place/street/POI name from OSM, geocoding,
|
|
* Google Places, etc.) before it enters the voice LLM's scene context.
|
|
* Collapses newlines/control chars to single spaces and hard-caps length, so
|
|
* crafted map data can't smuggle multi-line "instructions" into the prompt.
|
|
* Defense-in-depth — these are reference labels, not commands.
|
|
* @param {*} value - Raw label value.
|
|
* @returns {string} Sanitized single-line label (max 120 chars).
|
|
*/
|
|
function sanitizeLabel(value) {
|
|
const text = String(value || '');
|
|
let out = '';
|
|
for (const ch of text) {
|
|
const code = ch.codePointAt(0);
|
|
out += code < 0x20 || code === 0x7f ? ' ' : ch; // drop control chars incl. newlines
|
|
}
|
|
return out.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
}
|
|
|
|
function cachePrecision(service) {
|
|
return Number.isInteger(service?.cachePrecision)
|
|
? Math.max(3, Math.min(6, service.cachePrecision))
|
|
: 4;
|
|
}
|
|
|
|
function reverseGeocodeKey(latitude, longitude, service) {
|
|
return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))}`;
|
|
}
|
|
|
|
function nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service) {
|
|
const radiusM = nearbyPlacesRadiusM(cameraHeightM);
|
|
return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))},${radiusM}`;
|
|
}
|
|
|
|
function nearbyPlacesRadiusM(cameraHeightM) {
|
|
if (cameraHeightM <= 1000) return 500;
|
|
if (cameraHeightM <= 5000) return 2000;
|
|
return 5000;
|
|
}
|
|
|
|
async function resolveWithin(promise, timeoutMs, fallback) {
|
|
let timeout = null;
|
|
try {
|
|
return await Promise.race([
|
|
Promise.resolve(promise).catch(() => fallback),
|
|
new Promise((resolve) => {
|
|
timeout = window.setTimeout(() => resolve(fallback), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function approximateCoordinateDistanceSq(latA, lonA, latB, lonB) {
|
|
const latDelta = latB - latA;
|
|
const lonDelta =
|
|
(lonB - lonA) * Math.cos(Cesium.Math.toRadians((latA + latB) / 2));
|
|
return latDelta * latDelta + lonDelta * lonDelta;
|
|
}
|
|
|
|
function logSlowContext(startedAt, scope) {
|
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
if (durationMs >= 500) {
|
|
console.info(
|
|
`[GEV Voice] ${scope} scene context completed in ${durationMs}ms`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function dominantValue(values) {
|
|
if (!values.length) return null;
|
|
const counts = new Map();
|
|
for (const value of values) counts.set(value, (counts.get(value) || 0) + 1);
|
|
const [value, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
return {
|
|
value,
|
|
count,
|
|
confidence: Number((count / values.length).toFixed(2)),
|
|
};
|
|
}
|
|
|
|
function summarizeContextRecord(record, { includeProperties = false } = {}) {
|
|
return {
|
|
id: String(record.id || ''),
|
|
name:
|
|
cleanText(record.label || record.properties?.name) ||
|
|
layerTitle(record.layerId),
|
|
layerId: record.layerId || null,
|
|
layerName: record.layerName || layerTitle(record.layerId),
|
|
source: record.source || null,
|
|
latitude: record.latitude ?? null,
|
|
longitude: record.longitude ?? null,
|
|
properties: includeProperties
|
|
? compactProperties(record.properties || {})
|
|
: undefined,
|
|
active: isContextRecordActive(record),
|
|
};
|
|
}
|
|
|
|
function compactProperties(props) {
|
|
const preferredKeys = [
|
|
'name',
|
|
'operator',
|
|
'owner',
|
|
'brand',
|
|
'addr:city',
|
|
'addr:state',
|
|
'country',
|
|
'capacity',
|
|
'output',
|
|
'osm_id',
|
|
'source',
|
|
];
|
|
const flat = {
|
|
...props,
|
|
...(props.tags && typeof props.tags === 'object' ? props.tags : {}),
|
|
};
|
|
const result = {};
|
|
for (const key of preferredKeys) {
|
|
const value = cleanText(flat[key]);
|
|
if (value) result[key] = value;
|
|
}
|
|
for (const [key, value] of Object.entries(flat)) {
|
|
if (Object.keys(result).length >= 12) break;
|
|
if (key === 'tags' || result[key] !== undefined) continue;
|
|
const text = cleanText(value);
|
|
if (text) result[key] = text;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function cleanText(value) {
|
|
if (value == null || typeof value === 'object') return '';
|
|
const text = String(value).trim();
|
|
if (!text || text === 'undefined' || text === 'null') return '';
|
|
return text.length > 180 ? `${text.slice(0, 177)}...` : text;
|
|
}
|
|
|
|
function layerTitle(layerId) {
|
|
if (layerId === 'local-datacenters') return 'Datacenter';
|
|
if (layerId === 'local-dams') return 'Dam';
|
|
if (layerId === 'telegeography-submarine-cables') return 'Submarine Cable';
|
|
if (layerId === 'local-firms') return 'Active Fire';
|
|
return layerId || 'Entity';
|
|
}
|
|
|
|
function clampNumber(value, min, max, fallback) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) return fallback;
|
|
return Math.max(min, Math.min(max, numeric));
|
|
}
|
|
|
|
/**
|
|
* Analyst query — spoken questions over data already on the client
|
|
* ("how many flights over Texas?", "biggest fire near LA?", "which ships
|
|
* are headed to Oakland?"). The ENGINE (analystEngine.js) does the query
|
|
* logic; this wiring supplies live providers and compacts the result for
|
|
* the voice payload. One engine per runner keeps follow-up memory
|
|
* ("which of those is closest?") scoped to the session.
|
|
*/
|
|
|
|
/**
|
|
* Most records one layer hands a single query. Above every loaded feed seen
|
|
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
|
* answers count the whole loaded set; a feed past it reports
|
|
* returned/total/truncated rather than a silently short count.
|
|
*/
|
|
const ANALYST_RECORD_CAP = 250_000;
|
|
/** A voice-enabled layer is still filling its first poll for this long. */
|
|
const ANALYST_WARMUP_MS = 45_000;
|
|
|
|
function layerIsWarming(enabledAt, layerKey) {
|
|
const at = enabledAt.get(layerKey);
|
|
return Boolean(at && Date.now() - at < ANALYST_WARMUP_MS);
|
|
}
|
|
|
|
function analystProviders(
|
|
viewer,
|
|
dataManager,
|
|
{
|
|
recordLimitByLayer = null,
|
|
placeSearch = unavailablePlaceSearch,
|
|
resolveRegionRing = (name) =>
|
|
defaultAnnotationResolver.resolveRegionRingForQuery(
|
|
name,
|
|
undefined,
|
|
placeSearch,
|
|
),
|
|
isWarming = () => false,
|
|
} = {},
|
|
) {
|
|
// Per-layer truncation seen by the last getRecords call.
|
|
const truncatedBy = new Map();
|
|
return {
|
|
getRecords(layerKey) {
|
|
truncatedBy.delete(layerKey);
|
|
const layer = dataManager.layers.get(layerKey);
|
|
if (!layer || !dataManager.isEnabled(layerKey)) return [];
|
|
const mod = layer.module;
|
|
if (typeof mod?.getAnalystRecords !== 'function') return [];
|
|
const requestedLimit = recordLimitByLayer?.[layerKey];
|
|
const cap = Number.isFinite(requestedLimit)
|
|
? requestedLimit
|
|
: ANALYST_RECORD_CAP;
|
|
// One past the cap proves whether anything was left out.
|
|
const rows = mod.getAnalystRecords(Math.min(cap + 1, 2 ** 31)) || [];
|
|
if (rows.length > cap) {
|
|
truncatedBy.set(layerKey, true);
|
|
return rows.slice(0, cap);
|
|
}
|
|
return rows;
|
|
},
|
|
getLayerSnapshot(layerKey) {
|
|
const row = dataManager.getAll?.().find((layer) => layer.id === layerKey);
|
|
if (row) return layerSnapshot(row);
|
|
const module = dataManager.layers?.get(layerKey)?.module;
|
|
return layerSnapshot({
|
|
id: layerKey,
|
|
enabled: dataManager.isEnabled?.(layerKey),
|
|
stats: module?.getStats?.() || {},
|
|
});
|
|
},
|
|
getRecordCoverage(layerKey, rows) {
|
|
if (!truncatedBy.get(layerKey)) return { total: rows.length };
|
|
const loaded = dataManager.layers
|
|
.get(layerKey)
|
|
?.module?.getStats?.().count;
|
|
return {
|
|
total: Number.isFinite(loaded) && loaded > rows.length ? loaded : null,
|
|
truncated: true,
|
|
};
|
|
},
|
|
isWarming,
|
|
/** The layer's own status text for an empty result, e.g. "Fly below 400 km". */
|
|
getEmptyNote(layerKey) {
|
|
const stats =
|
|
dataManager.layers.get(layerKey)?.module?.getStats?.() || {};
|
|
const note = [stats.coverage, stats.statusMessage].find(
|
|
(value) => typeof value === 'string' && value.trim(),
|
|
);
|
|
return note ? note.trim() : null;
|
|
},
|
|
resolveRegionRing,
|
|
/**
|
|
* The active Contacts subject, when there is one — the centre the operator
|
|
* is reasoning about while Contacts is up. Null whenever Contacts is off,
|
|
* so view-centred behaviour is unchanged outside it.
|
|
* @returns {{lat: number, lon: number, label: string|null}|null} Subject centre.
|
|
*/
|
|
getContextSubject() {
|
|
const snapshot = dataManager?.layers
|
|
?.get('military-awareness')
|
|
?.module?.getContextSnapshot?.();
|
|
const subject = snapshot?.subject;
|
|
if (!subject?.position) return null;
|
|
const carto = Cesium.Cartographic.fromCartesian(subject.position);
|
|
if (!carto) return null;
|
|
return {
|
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
label: subject.label || subject.id || null,
|
|
};
|
|
},
|
|
getViewContext() {
|
|
const carto = viewer.camera.positionCartographic;
|
|
const altKm = carto.height / 1000;
|
|
// View radius scales with altitude: street-level asks stay local,
|
|
// country-level asks sweep wide. Clamped so "in view" is never absurd.
|
|
const viewRadiusKm = Math.max(25, Math.min(2500, altKm * 1.6));
|
|
return {
|
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
viewRadiusKm,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Identity fields every compact analyst item keeps when present. */
|
|
const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
|
'icao24',
|
|
'mmsi',
|
|
'registration',
|
|
'label',
|
|
'callsign',
|
|
'name',
|
|
'noradId',
|
|
]);
|
|
|
|
/**
|
|
* Compact one analyst record for the voice payload: identity, position (so a
|
|
* follow-up can fly to it), distance, and that layer's manifest fields with
|
|
* times as ISO text. The full record set stays engine-side for follow-ups.
|
|
*
|
|
* `icao24`/`mmsi` ride along because the tool instructions tell the model to
|
|
* hand this result straight to track_entity, and `id` is a DISPLAY label
|
|
* (callsign, else registration, else hex). A callsign-less contact therefore
|
|
* handed track_entity a tail number the lookup could not resolve, and the
|
|
* model burned the turn on retries (owner field session 2026-08-21, 23:48).
|
|
* @param {object} r Engine record.
|
|
* @returns {object} Compact item.
|
|
*/
|
|
function compactAnalystItem(r) {
|
|
const compact = { layerKey: r.layerKey, id: r.id };
|
|
const present = (value) => value !== null && value !== undefined;
|
|
for (const key of ANALYST_IDENTITY_FIELDS)
|
|
if (present(r[key])) compact[key] = r[key];
|
|
if (Number.isFinite(r.lat) && Number.isFinite(r.lon)) {
|
|
compact.lat = Math.round(r.lat * 1e4) / 1e4;
|
|
compact.lon = Math.round(r.lon * 1e4) / 1e4;
|
|
}
|
|
if (present(r.distanceKm)) compact.distanceKm = r.distanceKm;
|
|
const fields = voiceLayer(r.layerKey)?.query?.fields || {};
|
|
for (const [key, spec] of Object.entries(fields)) {
|
|
if (!present(r[key]) || key in compact) continue;
|
|
if (spec.type === 'time') {
|
|
const ms = Number(r[key]);
|
|
// A finite value can still be past Date's range, where toISOString throws.
|
|
const time = new Date(ms);
|
|
if (Number.isFinite(time.getTime())) {
|
|
if (key === 'lastContactAtMs') compact.lastContactAtMs = ms;
|
|
else compact.time = time.toISOString();
|
|
}
|
|
} else compact[key] = r[key];
|
|
}
|
|
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
|
return compact;
|
|
}
|
|
|
|
/** Refusal fields the analyst engine owns and the model must receive. */
|
|
const ANALYST_REFUSAL_FIELDS = Object.freeze([
|
|
'allowed',
|
|
'field',
|
|
'expected',
|
|
'layers',
|
|
'layerStatus',
|
|
'cancelled',
|
|
]);
|
|
|
|
const VIEWPORT_COVERAGE_NOTE = 'the flights layer loads by viewport';
|
|
|
|
/** Resolve omitted follow-up scope from the engine's retained coverage. */
|
|
function effectiveAnalystScopeKind(args, coverage) {
|
|
const explicit = String(args.scope?.kind || '').toLowerCase();
|
|
if (explicit) return explicit;
|
|
const retained = String(coverage?.scope || '')
|
|
.split(':', 1)[0]
|
|
.toLowerCase();
|
|
if (retained === 'window') return 'radius';
|
|
if (['anywhere', 'radius', 'region', 'view'].includes(retained))
|
|
return retained;
|
|
return args.followUp ? 'anywhere' : 'view';
|
|
}
|
|
|
|
/** Add the viewport qualification without replacing retained provenance. */
|
|
function withViewportCoverageNote(coverage) {
|
|
if (!coverage) return coverage;
|
|
const current = typeof coverage.note === 'string' ? coverage.note : '';
|
|
if (current.includes(VIEWPORT_COVERAGE_NOTE)) return coverage;
|
|
const separator = current ? '; ' : '';
|
|
return {
|
|
...coverage,
|
|
note: current
|
|
? `${current}${separator}${VIEWPORT_COVERAGE_NOTE}`
|
|
: VIEWPORT_COVERAGE_NOTE,
|
|
};
|
|
}
|
|
|
|
/** Whether this query counts records from a viewport-loaded layer. */
|
|
function isViewportScopedAnalystResult(args, coverage) {
|
|
const scopeKind = effectiveAnalystScopeKind(args, coverage);
|
|
return (
|
|
(scopeKind === 'radius' || scopeKind === 'view') &&
|
|
(coverage?.layersQueried || []).some(
|
|
(layer) => voiceLayer(layer.layerKey)?.query?.viewportLoaded,
|
|
)
|
|
);
|
|
}
|
|
|
|
async function runAnalystQuery(
|
|
analystEngine,
|
|
dataManager,
|
|
args = {},
|
|
_layerEnabledAt,
|
|
isCurrent = () => true,
|
|
signal = undefined,
|
|
) {
|
|
let entityWindow = null;
|
|
const result = await analystEngine.query(
|
|
{
|
|
layers: Array.isArray(args.layers) ? args.layers : undefined,
|
|
scope: args.scope,
|
|
filters: Array.isArray(args.filters) ? args.filters : [],
|
|
sortBy: args.sortBy || null,
|
|
sortDir: args.sortDir,
|
|
limit: args.limit,
|
|
followUp: Boolean(args.followUp),
|
|
},
|
|
{
|
|
isCurrent,
|
|
signal,
|
|
beforeCommit: async (candidate) => {
|
|
const projected = await aircraftProximityWindowForQuery(
|
|
dataManager,
|
|
args,
|
|
candidate,
|
|
isCurrent,
|
|
);
|
|
if (
|
|
projected?.answer &&
|
|
projected?.memory &&
|
|
isViewportScopedAnalystResult(args, projected.answer.coverage)
|
|
) {
|
|
const coverage = withViewportCoverageNote(projected.answer.coverage);
|
|
projected.answer.coverage = coverage;
|
|
projected.memory.coverage = coverage;
|
|
}
|
|
entityWindow = projected?.answer || null;
|
|
return projected?.memory || null;
|
|
},
|
|
},
|
|
);
|
|
if (!result.ok) {
|
|
const refusal = {
|
|
ok: false,
|
|
action: 'analyst_query',
|
|
code: result.code || null,
|
|
error: result.error,
|
|
};
|
|
// The engine's semantic fields pass through unchanged: a cancellation
|
|
// stays a cancellation.
|
|
for (const key of ANALYST_REFUSAL_FIELDS)
|
|
if (result[key] !== undefined) refusal[key] = result[key];
|
|
if (result.coverage) refusal.coverage = result.coverage;
|
|
return refusal;
|
|
}
|
|
const items = result.items.map(compactAnalystItem);
|
|
// Warm-up honesty: a layer enabled seconds ago hasn't finished its first
|
|
// poll (entity layers render one interval behind live BY DESIGN) — so a low
|
|
// count is "still loading", not fact.
|
|
const warming = (result.coverage?.layersQueried || [])
|
|
.filter((l) => layerIsWarming(_layerEnabledAt, l.layerKey))
|
|
.map((l) => l.layerKey);
|
|
if (warming.length) {
|
|
const note = `${warming.join(', ')} just turned on — still loading, counts will rise`;
|
|
result.display.caveat = result.display.caveat
|
|
? `${note}; ${result.display.caveat}`
|
|
: note;
|
|
}
|
|
// A radius/view count over a viewport-loaded layer counts what is LOADED, and
|
|
// the flights layer reloads as the camera moves — so this number can sit well
|
|
// under the Contacts cohort without either being wrong.
|
|
const scopeKind = effectiveAnalystScopeKind(args, result.coverage);
|
|
const viewportScoped = isViewportScopedAnalystResult(args, result.coverage);
|
|
if (viewportScoped && result.coverage) {
|
|
// The engine's follow-up memory retains this same object, so update its
|
|
// note in place after deriving the qualified copy.
|
|
result.coverage.note = withViewportCoverageNote(result.coverage).note;
|
|
}
|
|
// ENTITY-CENTRED NEARBY: answered by the SAME engine that fills the Contacts
|
|
// panel, so the spoken number and the panel readout for one centre cannot
|
|
// differ. The generic record/scope engine still owns explicit regions and
|
|
// arbitrary points — only "how many aircraft around <this contact>" is
|
|
// unified, because that is the question the panel is already answering.
|
|
if (entityWindow) {
|
|
return entityWindow;
|
|
}
|
|
|
|
const contactsWindow = activeContactsWindow(dataManager);
|
|
const aircraftQueried = (result.coverage?.layersQueried || []).some(
|
|
(l) => l.layerKey === 'flights' || l.layerKey === 'military',
|
|
);
|
|
// Both numbers, and which one answers the question. The window counts have
|
|
// ridden along in `contactsWindow` for a while, and the owner's trial showed
|
|
// that is not enough on its own: with Contacts active and a DATACENTER in
|
|
// the selection slot, the model centred a radius on the datacenter, answered
|
|
// 15, and then explained away the 111 sitting in the same payload ("that
|
|
// number is from the Contacts window, and I wasn't using Contacts as the
|
|
// source"). So the payload now states the relationship instead of leaving it
|
|
// to be inferred from two bare numbers.
|
|
const windowAircraft = Number.isFinite(contactsWindow?.aircraft)
|
|
? contactsWindow.aircraft
|
|
: null;
|
|
const proximityScoped = scopeKind === 'radius' || scopeKind === 'view';
|
|
const countsReconciliation =
|
|
contactsWindow &&
|
|
aircraftQueried &&
|
|
proximityScoped &&
|
|
windowAircraft !== null
|
|
? `Contacts is ACTIVE: its window holds ${windowAircraft} aircraft within ` +
|
|
`${contactsWindow.radiusKm} km of ${contactsWindow.centeredOn}, and that is the answer to a bare ` +
|
|
`"how many aircraft are nearby". This query measured something else — ${result.count} ${result.scopeLabel}. ` +
|
|
'Give this one only if the operator asked about that specific area, and name both scopes if you give both.'
|
|
: null;
|
|
return {
|
|
ok: true,
|
|
action: 'analyst_query',
|
|
count: result.count,
|
|
// False when a layer was capped: the count is a floor ("at least").
|
|
complete: result.complete !== false,
|
|
...(result.partial
|
|
? { partial: true, unanswered: result.unanswered || [] }
|
|
: {}),
|
|
...(result.centeredOn ? { centeredOn: result.centeredOn } : {}),
|
|
// Every count names its scope in words; a bare number is what made two
|
|
// honest answers look like a contradiction.
|
|
scopeLabel: result.scopeLabel,
|
|
// More matched than listed; the count above is still complete.
|
|
listed: items.length,
|
|
items,
|
|
summary: result.summary,
|
|
// On-screen detail (scope, feed window, caveats); not for recital.
|
|
display: result.display,
|
|
coverage: result.coverage,
|
|
feedProvenance: result.coverage?.feedProvenance || null,
|
|
feedState: result.coverage?.feedProvenance?.overall || null,
|
|
// The panel's own numbers, carried so the answer can match what the
|
|
// operator is looking at regardless of how the model reads the note.
|
|
// Flattened alongside the object so the count and its subject cannot be
|
|
// missed inside a nested shape.
|
|
...(contactsWindow && aircraftQueried
|
|
? {
|
|
contactsWindow,
|
|
contactsWindowCount: windowAircraft,
|
|
contactsWindowSubject: contactsWindow.centeredOn || null,
|
|
}
|
|
: {}),
|
|
...(countsReconciliation ? { countsReconciliation } : {}),
|
|
};
|
|
}
|