mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge upstream main into fix/location-resolution
This commit is contained in:
commit
1eded0468f
12 files changed
+784
-165
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
- Close the compact Radio disclosure when an adaptive tactical right-rail
|
||||||
|
restore directly expands Context, before Space Missions can be covered.
|
||||||
|
A repeated Context-open request also clears stale disclosure state.
|
||||||
|
Real PanelChrome constructor wiring and the rail reflow path are covered
|
||||||
|
by focused regressions. Credits: kvnloo (#816), raysung-2026's original
|
||||||
|
#804 report, asturksever's review, and quanticsoul4772's independent
|
||||||
|
Windows diagnosis.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Extract a reusable model-free World Packet from the existing voice
|
||||||
|
`get_current_view_state` owner without restoring disabled layers to the
|
||||||
|
model context. Keep the enabled-only payload, disabled-layer count, source
|
||||||
|
provenance, action result and view-state contract; read the layer roster once
|
||||||
|
per packet; voice retains the speech/card envelope at its own boundary.
|
||||||
|
Credits: original GEV voice/layer contributors and #880 implementation.
|
||||||
@@ -504,6 +504,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft,
|
|||||||
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
get_entity_context, annotate_map and analyst_query. Analyst speech and the card
|
||||||
share one deterministic count/scope headline; lower bounds use "At least" and
|
share one deterministic count/scope headline; lower bounds use "At least" and
|
||||||
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only.
|
||||||
|
The model-free `gods-eye-view/agent/world-packet` export exposes
|
||||||
|
`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to
|
||||||
|
non-voice consumers. It reads the same enabled-only state and feed
|
||||||
|
provenance before voice adds speech/card fields, with no new network requests
|
||||||
|
or mutations.
|
||||||
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
The voice card nested in the mic control (`voiceCard.js`) shows captions, plan
|
||||||
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
steps and the result. `resultDisplay.js` is the one adapter from a result to
|
||||||
the card: builder results show as built, and analyst results show a title
|
the card: builder results show as built, and analyst results show a title
|
||||||
@@ -1211,6 +1216,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
|||||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||||
writes. This extraction does not change panel positions or layout defaults.
|
writes. This extraction does not change panel positions or layout defaults.
|
||||||
|
Context expansion through the panel owner or adaptive right-rail restoration
|
||||||
|
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||||
|
visible. The mini remains available while Context is collapsed. This fixes the
|
||||||
|
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||||
collapse can still be undone.
|
collapse can still be undone.
|
||||||
|
|||||||
+2
-1
@@ -277,6 +277,7 @@
|
|||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
"./tools/services": "./src/tools/services.js",
|
"./tools/services": "./src/tools/services.js",
|
||||||
"./view": "./src/view/index.js"
|
"./view": "./src/view/index.js",
|
||||||
|
"./agent/world-packet": "./src/agent/worldPacket.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,6 +637,7 @@
|
|||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/realtimeBackend.js",
|
"src/voice/realtimeBackend.js",
|
||||||
"src/voice/realtimeBackend.test.mjs",
|
"src/voice/realtimeBackend.test.mjs",
|
||||||
|
"src/agent/worldPacket.test.mjs",
|
||||||
"src/voice/layerSummary.js",
|
"src/voice/layerSummary.js",
|
||||||
"build/application-html.js",
|
"build/application-html.js",
|
||||||
"src/app/sourceSlot.js",
|
"src/app/sourceSlot.js",
|
||||||
@@ -692,6 +693,7 @@
|
|||||||
"src/ui/shareRestoration.js",
|
"src/ui/shareRestoration.js",
|
||||||
"src/ui/visualSettings.js",
|
"src/ui/visualSettings.js",
|
||||||
"src/ui/panelChrome.js",
|
"src/ui/panelChrome.js",
|
||||||
|
"src/ui/panelChrome.radioOwnership.test.mjs",
|
||||||
"src/testSupport/readShellSource.mjs",
|
"src/testSupport/readShellSource.mjs",
|
||||||
"src/ui/stateOwners.test.mjs",
|
"src/ui/stateOwners.test.mjs",
|
||||||
"src/layers/flights/records.js",
|
"src/layers/flights/records.js",
|
||||||
|
|||||||
@@ -2221,6 +2221,7 @@
|
|||||||
"src/voice/commands.js",
|
"src/voice/commands.js",
|
||||||
"src/voice/control.js",
|
"src/voice/control.js",
|
||||||
"src/voice/gevActions.js",
|
"src/voice/gevActions.js",
|
||||||
|
"src/agent/worldPacket.js",
|
||||||
"src/voice/speech.js",
|
"src/voice/speech.js",
|
||||||
"src/ui/layerNames.js",
|
"src/ui/layerNames.js",
|
||||||
"src/voice/earcon.js",
|
"src/voice/earcon.js",
|
||||||
@@ -3951,5 +3952,18 @@
|
|||||||
"src/i18n/marker.js"
|
"src/i18n/marker.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
|
},
|
||||||
|
"agent-world-packet": {
|
||||||
|
"exports": ["./agent/world-packet"],
|
||||||
|
"modules": [
|
||||||
|
"src/agent/worldPacket.js",
|
||||||
|
"src/data/layerSnapshot.js",
|
||||||
|
"src/data/feedState.js",
|
||||||
|
"src/contextModePolicy.js",
|
||||||
|
"src/loadingFeedback.js",
|
||||||
|
"src/data/installationFeedback.js",
|
||||||
|
"src/i18n/marker.js"
|
||||||
|
],
|
||||||
|
"external": ["cesium"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
feedProvenanceEnvelope,
|
||||||
|
layerSnapshot,
|
||||||
|
layerSnapshots,
|
||||||
|
} from '../data/layerSnapshot.js';
|
||||||
|
import { contextModeWord } from '../contextModePolicy.js';
|
||||||
|
|
||||||
|
export const TRACKABLE_FAMILIES = Object.freeze([
|
||||||
|
{ layerId: 'flights', kind: 'aircraft' },
|
||||||
|
{ layerId: 'military', kind: 'aircraft' },
|
||||||
|
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
||||||
|
{ layerId: 'satellites', kind: 'satellite' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
||||||
|
{ field: 'mode', emptyAs: 'off' },
|
||||||
|
{ field: 'entering', emptyAs: null },
|
||||||
|
{ field: 'priorMode', emptyAs: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
||||||
|
'context',
|
||||||
|
'contextRollback',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate context-mode payloads into the public tool vocabulary while
|
||||||
|
* retaining the internal id beside each translated field.
|
||||||
|
*/
|
||||||
|
export function withContextModeVocabulary(state) {
|
||||||
|
if (!state || typeof state !== 'object') return state;
|
||||||
|
let out = state;
|
||||||
|
const mutable = () => {
|
||||||
|
if (out === state) out = { ...state };
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
||||||
|
if (!(field in state)) continue;
|
||||||
|
const internal = state[field] ?? null;
|
||||||
|
const target = mutable();
|
||||||
|
target[field] = contextModeWord(internal, { emptyAs });
|
||||||
|
target[`${field}Internal`] = internal;
|
||||||
|
}
|
||||||
|
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
||||||
|
const nested = state[field];
|
||||||
|
if (!nested || typeof nested !== 'object') continue;
|
||||||
|
const translated = withContextModeVocabulary(nested);
|
||||||
|
if (translated !== nested) mutable()[field] = translated;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read tracked entities from their canonical layer owners. */
|
||||||
|
export function collectTrackedEntities(dataManager) {
|
||||||
|
const tracked = [];
|
||||||
|
for (const family of TRACKABLE_FAMILIES) {
|
||||||
|
const module = dataManager?.layers?.get(family.layerId)?.module;
|
||||||
|
if (!module) continue;
|
||||||
|
try {
|
||||||
|
const info =
|
||||||
|
family.kind === 'vessel'
|
||||||
|
? module.getSelectedInfo?.()
|
||||||
|
: module.getTrackedInfo?.();
|
||||||
|
if (info)
|
||||||
|
tracked.push({
|
||||||
|
kind: family.kind,
|
||||||
|
layerId: family.layerId,
|
||||||
|
...info,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A not-yet-ready layer contributes no tracked observation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the active Contacts window from its owning awareness layer. */
|
||||||
|
export function activeContactsWindow(dataManager) {
|
||||||
|
try {
|
||||||
|
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
||||||
|
return (
|
||||||
|
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the model-free fast World Packet from existing application owners.
|
||||||
|
*
|
||||||
|
* This performs no network requests and intentionally mirrors the historical
|
||||||
|
* get_current_view_state payload so voice can delegate without behavior drift.
|
||||||
|
*/
|
||||||
|
export function getWorldPacket(options = {}) {
|
||||||
|
const {
|
||||||
|
viewer,
|
||||||
|
styleManager,
|
||||||
|
dataManager,
|
||||||
|
sceneDirector = null,
|
||||||
|
now: requestedNow,
|
||||||
|
} = options ?? {};
|
||||||
|
// A default parameter only covers undefined; null or NaN would age every
|
||||||
|
// feed against the epoch and report stale data as "just now".
|
||||||
|
const now = Number.isFinite(requestedNow) ? requestedNow : Date.now();
|
||||||
|
if (!viewer?.camera?.positionWC)
|
||||||
|
throw new TypeError('World Packet requires a viewer camera');
|
||||||
|
if (!dataManager || typeof dataManager.getAll !== 'function')
|
||||||
|
throw new TypeError('World Packet requires a data manager');
|
||||||
|
|
||||||
|
const cartographic = Cesium.Cartographic.fromCartesian(
|
||||||
|
viewer.camera.positionWC,
|
||||||
|
);
|
||||||
|
// Read the roster once. Voice must not rewalk disabled layers: preserving the
|
||||||
|
// current enabled-only view keeps the model's context budget bounded.
|
||||||
|
const rows = dataManager.getAll();
|
||||||
|
const enabledRows = rows.filter((layer) => layer.enabled);
|
||||||
|
const contactsWindow = activeContactsWindow(dataManager);
|
||||||
|
const contextState =
|
||||||
|
typeof styleManager?.getContextModeState === 'function'
|
||||||
|
? withContextModeVocabulary(styleManager.getContextModeState())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action: 'get_current_view_state',
|
||||||
|
camera: {
|
||||||
|
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
||||||
|
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
||||||
|
heightM: cartographic.height,
|
||||||
|
},
|
||||||
|
style: styleManager?.activeStyle || 'normal',
|
||||||
|
context: contextState
|
||||||
|
? {
|
||||||
|
...contextState,
|
||||||
|
...(contactsWindow ? { contactsWindow } : {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
cockpit:
|
||||||
|
typeof styleManager?.getCockpitState === 'function'
|
||||||
|
? styleManager.getCockpitState()
|
||||||
|
: null,
|
||||||
|
controls:
|
||||||
|
typeof styleManager?.getControlState === 'function'
|
||||||
|
? styleManager.getControlState()
|
||||||
|
: null,
|
||||||
|
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
||||||
|
tracked: collectTrackedEntities(dataManager),
|
||||||
|
disabledLayerCount: rows.length - enabledRows.length,
|
||||||
|
// Preserve get_current_view_state's existing compact enabled-only shape.
|
||||||
|
layers: enabledRows.map((layer) => {
|
||||||
|
const snapshot = layerSnapshot(layer, { now });
|
||||||
|
return {
|
||||||
|
id: layer.id,
|
||||||
|
name: layer.name,
|
||||||
|
enabled: layer.enabled,
|
||||||
|
count: layer.stats?.count || 0,
|
||||||
|
error: layer.stats?.error || null,
|
||||||
|
feedState: snapshot.feedState,
|
||||||
|
source: snapshot.source,
|
||||||
|
lastUpdate: snapshot.lastUpdate,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
feedProvenance: feedProvenanceEnvelope(
|
||||||
|
layerSnapshots(rows, { now }).filter((layer) => layer.enabled),
|
||||||
|
{ now },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
import { getWorldPacket } from './worldPacket.js';
|
||||||
|
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||||
|
|
||||||
|
function withoutVoicePresentation(result) {
|
||||||
|
const packet = { ...result };
|
||||||
|
delete packet.say;
|
||||||
|
delete packet.display;
|
||||||
|
delete packet.schedule;
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
|
||||||
|
const viewer = {
|
||||||
|
trackedEntity: undefined,
|
||||||
|
clock: { onTick: { addEventListener: () => () => {} } },
|
||||||
|
scene: {
|
||||||
|
canvas: {
|
||||||
|
clientWidth: 1200,
|
||||||
|
clientHeight: 800,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
},
|
||||||
|
globe: { getHeight: () => 0 },
|
||||||
|
tweens: [],
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
positionWC: position,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||||
|
heading: 0,
|
||||||
|
pitch: Cesium.Math.toRadians(-45),
|
||||||
|
moveEnd: { addEventListener: () => () => {} },
|
||||||
|
cancelFlight() {},
|
||||||
|
lookAtTransform() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 3,
|
||||||
|
source: 'fixture',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([['traffic', { module: null }]]),
|
||||||
|
getAll: () => rows,
|
||||||
|
};
|
||||||
|
const styleManager = {
|
||||||
|
activeStyle: 'normal',
|
||||||
|
getContextModeState: () => ({ mode: 'flights' }),
|
||||||
|
getCockpitState: () => null,
|
||||||
|
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||||
|
};
|
||||||
|
return { viewer, dataManager, styleManager };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('World Packet preserves get_current_view_state parity', async () => {
|
||||||
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
|
||||||
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
|
assert.equal(packet.context.mode, 'contacts');
|
||||||
|
assert.equal(packet.context.modeInternal, 'flights');
|
||||||
|
assert.equal(packet.layers[0].feedState, 'nominal');
|
||||||
|
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => {
|
||||||
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
|
dataManager.getAll = () => [
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
source: 'fixture',
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
assert.equal(packet.layers[0].count, 0);
|
||||||
|
assert.notEqual(packet.layers[0].feedState, 'nominal');
|
||||||
|
assert.notEqual(packet.feedProvenance.overall, 'nominal');
|
||||||
|
assert.match(packet.feedProvenance.note, /fixture unavailable/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 2026-10 main voice contract omits disabled layers from the model payload.
|
||||||
|
test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => {
|
||||||
|
const { viewer, dataManager, styleManager } = harness();
|
||||||
|
const rows = dataManager.getAll();
|
||||||
|
rows.push({
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: false,
|
||||||
|
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||||
|
});
|
||||||
|
let reads = 0;
|
||||||
|
dataManager.getAll = () => {
|
||||||
|
reads += 1;
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
const packet = getWorldPacket({ viewer, dataManager, styleManager });
|
||||||
|
assert.equal(reads, 1, 'collect the roster once rather than repeatedly');
|
||||||
|
assert.equal(packet.disabledLayerCount, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
packet.layers.map((layer) => layer.id),
|
||||||
|
['traffic'],
|
||||||
|
);
|
||||||
|
assert.equal(packet.feedProvenance.overall, 'nominal');
|
||||||
|
|
||||||
|
const runner = createGevActionRunner({ viewer, dataManager, styleManager });
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
assert.deepEqual(withoutVoicePresentation(voice), packet);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0);
|
||||||
|
|
||||||
|
// Exercises every packet field: context vocabulary and Contacts window,
|
||||||
|
// cockpit, controls, scene playback, tracked families (one not ready),
|
||||||
|
// enabled and disabled layers, and an aged and an unavailable feed.
|
||||||
|
function pinnedHarness() {
|
||||||
|
const { viewer } = harness();
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 12,
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
lastUpdate: PINNED_NOW - 90_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
stats: {
|
||||||
|
count: 0,
|
||||||
|
source: 'fixture',
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
lastUpdate: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'earthquakes',
|
||||||
|
name: 'Earthquakes',
|
||||||
|
enabled: false,
|
||||||
|
stats: { count: 99, source: 'fixture', error: 'stale' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const module = (methods) => ({ module: methods });
|
||||||
|
const dataManager = {
|
||||||
|
layers: new Map([
|
||||||
|
[
|
||||||
|
'flights',
|
||||||
|
module({
|
||||||
|
getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ais-live-vessels',
|
||||||
|
module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'satellites',
|
||||||
|
module({
|
||||||
|
getTrackedInfo: () => {
|
||||||
|
throw new Error('not ready');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'military-awareness',
|
||||||
|
module({
|
||||||
|
getContextSnapshot: () => ({ snapshot: true }),
|
||||||
|
contactsWindowFromSnapshot: (snapshot) =>
|
||||||
|
snapshot ? { windowMin: 30, count: 4 } : null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
getAll: () => rows,
|
||||||
|
};
|
||||||
|
const styleManager = {
|
||||||
|
activeStyle: 'night-vision',
|
||||||
|
getContextModeState: () => ({
|
||||||
|
mode: 'flights',
|
||||||
|
entering: null,
|
||||||
|
priorMode: null,
|
||||||
|
}),
|
||||||
|
getCockpitState: () => ({ active: false }),
|
||||||
|
getControlState: () => ({ hud: { visible: 'auto' } }),
|
||||||
|
};
|
||||||
|
const sceneDirector = {
|
||||||
|
getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }),
|
||||||
|
};
|
||||||
|
return { viewer, dataManager, styleManager, sceneDirector };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captured from main's get_current_view_state before the extraction
|
||||||
|
// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW,
|
||||||
|
// minus the speech envelope. Key order is part of the contract.
|
||||||
|
const PRE_EXTRACTION_PAYLOAD = {
|
||||||
|
ok: true,
|
||||||
|
action: 'get_current_view_state',
|
||||||
|
camera: {
|
||||||
|
latitude: 30.259999999999994,
|
||||||
|
longitude: -97.74,
|
||||||
|
heightM: 499.99999999901877,
|
||||||
|
},
|
||||||
|
style: 'night-vision',
|
||||||
|
context: {
|
||||||
|
mode: 'contacts',
|
||||||
|
entering: null,
|
||||||
|
priorMode: null,
|
||||||
|
modeInternal: 'flights',
|
||||||
|
enteringInternal: null,
|
||||||
|
priorModeInternal: null,
|
||||||
|
contactsWindow: { windowMin: 30, count: 4 },
|
||||||
|
},
|
||||||
|
cockpit: { active: false },
|
||||||
|
controls: { hud: { visible: 'auto' } },
|
||||||
|
scenePlayback: { playing: false, sceneId: 'fixture' },
|
||||||
|
tracked: [
|
||||||
|
{
|
||||||
|
kind: 'aircraft',
|
||||||
|
layerId: 'flights',
|
||||||
|
icao24: 'abc123',
|
||||||
|
callsign: 'TEST1',
|
||||||
|
},
|
||||||
|
{ kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' },
|
||||||
|
],
|
||||||
|
disabledLayerCount: 1,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
count: 12,
|
||||||
|
error: null,
|
||||||
|
feedState: 'nominal',
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
lastUpdate: 1792238310000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
count: 0,
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
feedState: 'unavailable',
|
||||||
|
source: 'fixture',
|
||||||
|
lastUpdate: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
feedProvenance: {
|
||||||
|
overall: 'unavailable',
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'flights',
|
||||||
|
name: 'Flights',
|
||||||
|
enabled: true,
|
||||||
|
feedState: 'nominal',
|
||||||
|
source: 'fixture-adsb',
|
||||||
|
count: 12,
|
||||||
|
lastUpdate: 1792238310000,
|
||||||
|
ageSec: 90,
|
||||||
|
ageLabel: '1m ago',
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traffic',
|
||||||
|
name: 'Traffic',
|
||||||
|
enabled: true,
|
||||||
|
feedState: 'unavailable',
|
||||||
|
source: 'fixture',
|
||||||
|
count: 0,
|
||||||
|
lastUpdate: 0,
|
||||||
|
ageSec: null,
|
||||||
|
ageLabel: null,
|
||||||
|
error: 'fixture unavailable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function assertPinnedPayload(actual) {
|
||||||
|
assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD);
|
||||||
|
// deepEqual ignores key order; the serialized form does not.
|
||||||
|
assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('World Packet matches the pre-extraction get_current_view_state payload', () => {
|
||||||
|
const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW });
|
||||||
|
assertPinnedPayload(packet);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voice get_current_view_state still returns the pre-extraction payload', async (t) => {
|
||||||
|
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||||
|
const runner = createGevActionRunner(pinnedHarness());
|
||||||
|
const voice = await runner('get_current_view_state');
|
||||||
|
assertPinnedPayload(withoutVoicePresentation(voice));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet ages feeds against the clock when now is not a finite number', (t) => {
|
||||||
|
t.mock.method(Date, 'now', () => PINNED_NOW);
|
||||||
|
for (const now of [null, Number.NaN, '2026-10-17']) {
|
||||||
|
const packet = getWorldPacket({ ...pinnedHarness(), now });
|
||||||
|
const flights = packet.feedProvenance.layers.find(
|
||||||
|
(layer) => layer.id === 'flights',
|
||||||
|
);
|
||||||
|
assert.equal(flights.ageSec, 90, `now: ${String(now)}`);
|
||||||
|
assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('World Packet rejects null options with the camera requirement', () => {
|
||||||
|
assert.throws(() => getWorldPacket(null), /requires a viewer camera/);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|||||||
import { join, relative } from 'node:path';
|
import { join, relative } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { TRACKABLE_FAMILIES } from './agent/worldPacket.js';
|
||||||
import { setLocale } from './i18n/index.js';
|
import { setLocale } from './i18n/index.js';
|
||||||
import {
|
import {
|
||||||
COCKPIT_REFUSAL_NOUNS,
|
COCKPIT_REFUSAL_NOUNS,
|
||||||
@@ -68,10 +69,8 @@ const NOUN_LITERAL =
|
|||||||
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
/\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g;
|
||||||
|
|
||||||
function trackableKinds() {
|
function trackableKinds() {
|
||||||
const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8');
|
// This is shared with voice; test the exported owner, not its file position.
|
||||||
const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/);
|
return TRACKABLE_FAMILIES.map(({ kind }) => kind);
|
||||||
assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js');
|
|
||||||
return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
// Non-literal arguments: what each one can be. A wrapper's own `noun` (or all
|
||||||
|
|||||||
+24
-1
@@ -72,7 +72,8 @@ export class PanelChrome {
|
|||||||
variant: this.hud.getVariant(),
|
variant: this.hud.getVariant(),
|
||||||
}),
|
}),
|
||||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
syncPanelCollapseButton: (panel) =>
|
||||||
|
this._syncRailPanelCollapseButton(panel),
|
||||||
readDisplayScrollTop: () =>
|
readDisplayScrollTop: () =>
|
||||||
this._displayPortalScrollRestoreOwner === 'standard'
|
this._displayPortalScrollRestoreOwner === 'standard'
|
||||||
? this._standardDisplayScrollTop
|
? this._standardDisplayScrollTop
|
||||||
@@ -287,6 +288,19 @@ export class PanelChrome {
|
|||||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncRailPanelCollapseButton(panelEl) {
|
||||||
|
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||||
|
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||||
|
if (
|
||||||
|
panelEl?.id === 'global-context-panel' &&
|
||||||
|
!panelEl.classList.contains('collapsed') &&
|
||||||
|
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||||
|
) {
|
||||||
|
this._setRadioDisclosure?.(false);
|
||||||
|
}
|
||||||
|
this._syncPanelCollapseButton(panelEl);
|
||||||
|
}
|
||||||
|
|
||||||
_syncPanelCollapseButton(panelEl) {
|
_syncPanelCollapseButton(panelEl) {
|
||||||
const isRightRail = [
|
const isRightRail = [
|
||||||
'pp-toggles',
|
'pp-toggles',
|
||||||
@@ -501,6 +515,15 @@ export class PanelChrome {
|
|||||||
syncShare,
|
syncShare,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// A repeated Context-open request must clear a stale compact Radio mini
|
||||||
|
// even when no collapsed class changes.
|
||||||
|
if (
|
||||||
|
!nextCollapsed &&
|
||||||
|
panelId === 'global-context-panel' &&
|
||||||
|
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||||
|
) {
|
||||||
|
this._setRadioDisclosure?.(false);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||||
!wasAutoCollapsed
|
!wasAutoCollapsed
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { PanelChrome } from './panelChrome.js';
|
||||||
|
import { PanelLayoutController } from './panelLayoutController.js';
|
||||||
|
|
||||||
|
function classList(initial = []) {
|
||||||
|
const values = new Set(initial);
|
||||||
|
return {
|
||||||
|
contains: (value) => values.has(value),
|
||||||
|
add: (...items) => items.forEach((item) => values.add(item)),
|
||||||
|
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||||
|
toggle(value, force) {
|
||||||
|
if (force === true) values.add(value);
|
||||||
|
else if (force === false) values.delete(value);
|
||||||
|
else if (values.has(value)) values.delete(value);
|
||||||
|
else values.add(value);
|
||||||
|
return values.has(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||||
|
const priorDocument = globalThis.document;
|
||||||
|
const contextPanel = {
|
||||||
|
id: 'global-context-panel',
|
||||||
|
classList: classList(),
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
documentElement: { dataset: { uiTheme: 'tactical' } },
|
||||||
|
getElementById(id) {
|
||||||
|
return id === 'global-context-panel' ? contextPanel : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = priorDocument;
|
||||||
|
});
|
||||||
|
|
||||||
|
const radioDock = { classList: classList(['disclosure-open']) };
|
||||||
|
const calls = [];
|
||||||
|
const owner = {
|
||||||
|
_contextRadioDock: radioDock,
|
||||||
|
_setRadioDisclosure(open) {
|
||||||
|
calls.push(open);
|
||||||
|
radioDock.classList.toggle('disclosure-open', open);
|
||||||
|
},
|
||||||
|
_leftPanelStack: null,
|
||||||
|
_rightPanelStack: null,
|
||||||
|
_panelLayout: {
|
||||||
|
_leftStackPreferredPanelId: null,
|
||||||
|
_rightStackPreferredPanelId: null,
|
||||||
|
},
|
||||||
|
_syncPanelCollapseButton() {},
|
||||||
|
_scheduleLeftPanelLayout() {},
|
||||||
|
_scheduleRightPanelLayout() {},
|
||||||
|
shareLinkManager: null,
|
||||||
|
cockpitView: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
PanelChrome.prototype.setPanelCollapsed.call(
|
||||||
|
owner,
|
||||||
|
'global-context-panel',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [false]);
|
||||||
|
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||||
|
const priorRaf = globalThis.requestAnimationFrame;
|
||||||
|
let nextFrame;
|
||||||
|
globalThis.requestAnimationFrame = (callback) => {
|
||||||
|
nextFrame = callback;
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.requestAnimationFrame = priorRaf;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||||
|
// and the user opened the compact Radio controls from its header.
|
||||||
|
const contextPanel = {
|
||||||
|
id: 'global-context-panel',
|
||||||
|
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||||
|
};
|
||||||
|
const radioDock = { classList: classList(['disclosure-open']) };
|
||||||
|
const radioMini = { hidden: false };
|
||||||
|
const calls = [];
|
||||||
|
const chrome = {
|
||||||
|
_contextRadioDock: radioDock,
|
||||||
|
_setRadioDisclosure(open) {
|
||||||
|
calls.push(`disclosure:${open}`);
|
||||||
|
radioDock.classList.toggle('disclosure-open', open);
|
||||||
|
radioMini.hidden = !open;
|
||||||
|
},
|
||||||
|
_syncPanelCollapseButton(panel) {
|
||||||
|
calls.push(`button:${panel.id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const layout = {
|
||||||
|
destroyed: false,
|
||||||
|
_rightStackLayoutFrame: null,
|
||||||
|
_rightStackReconsiderAutoCollapse: false,
|
||||||
|
_rightPanelStack: {
|
||||||
|
querySelectorAll: (selector) =>
|
||||||
|
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||||
|
},
|
||||||
|
_syncPanelCollapseButton(panel) {
|
||||||
|
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||||
|
},
|
||||||
|
_syncRightPanelAdaptiveLayout() {
|
||||||
|
calls.push('layout');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||||
|
reconsiderAutoCollapse: true,
|
||||||
|
});
|
||||||
|
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||||
|
assert.equal(radioMini.hidden, false);
|
||||||
|
nextFrame();
|
||||||
|
|
||||||
|
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||||
|
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||||
|
assert.equal(radioMini.hidden, true);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'disclosure:false',
|
||||||
|
'button:global-context-panel',
|
||||||
|
'layout',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||||
|
const priorDocument = globalThis.document;
|
||||||
|
const priorRaf = globalThis.requestAnimationFrame;
|
||||||
|
let nextFrame;
|
||||||
|
const contextPanel = {
|
||||||
|
id: 'global-context-panel',
|
||||||
|
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||||
|
};
|
||||||
|
const rightRail = {
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById(id) {
|
||||||
|
return id === 'right-context-rail' ? rightRail : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.requestAnimationFrame = (callback) => {
|
||||||
|
nextFrame = callback;
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = priorDocument;
|
||||||
|
globalThis.requestAnimationFrame = priorRaf;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dock = { classList: classList(['disclosure-open']) };
|
||||||
|
const calls = [];
|
||||||
|
const chrome = new PanelChrome({
|
||||||
|
elements: { _contextRadioDock: dock },
|
||||||
|
operations: {
|
||||||
|
_setRadioDisclosure(open) {
|
||||||
|
calls.push(`disclosure:${open}`);
|
||||||
|
dock.classList.toggle('disclosure-open', open);
|
||||||
|
},
|
||||||
|
_syncPanelCollapseButton(panel) {
|
||||||
|
calls.push(`button:${panel.id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
|
||||||
|
readCockpit: () => null,
|
||||||
|
readShareLinks: () => null,
|
||||||
|
readInitialShare: () => null,
|
||||||
|
readScrollRestoreOwner: () => null,
|
||||||
|
readDisplayScrollTop: () => 0,
|
||||||
|
});
|
||||||
|
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
|
||||||
|
calls.push('layout');
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome._panelLayout._scheduleRightPanelLayout({
|
||||||
|
reconsiderAutoCollapse: true,
|
||||||
|
});
|
||||||
|
assert.equal(typeof nextFrame, 'function');
|
||||||
|
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||||
|
nextFrame();
|
||||||
|
|
||||||
|
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||||
|
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'disclosure:false',
|
||||||
|
'button:global-context-panel',
|
||||||
|
'layout',
|
||||||
|
]);
|
||||||
|
});
|
||||||
+12
-159
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
layerSnapshot,
|
layerSnapshot,
|
||||||
layerSnapshots,
|
|
||||||
feedProvenanceEnvelope,
|
feedProvenanceEnvelope,
|
||||||
} from '../data/layerSnapshot.js';
|
} from '../data/layerSnapshot.js';
|
||||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||||
@@ -27,7 +26,6 @@ import {
|
|||||||
isContextRecordActive,
|
isContextRecordActive,
|
||||||
} from '../data/contextStore.js';
|
} from '../data/contextStore.js';
|
||||||
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
|
||||||
import { contextModeWord } from '../contextModePolicy.js';
|
|
||||||
import {
|
import {
|
||||||
createAnalystEngine,
|
createAnalystEngine,
|
||||||
normalizeAnalystSpec,
|
normalizeAnalystSpec,
|
||||||
@@ -48,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
|||||||
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
|
||||||
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
|
||||||
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
|
||||||
|
import {
|
||||||
|
getWorldPacket,
|
||||||
|
withContextModeVocabulary,
|
||||||
|
activeContactsWindow,
|
||||||
|
TRACKABLE_FAMILIES,
|
||||||
|
} from '../agent/worldPacket.js';
|
||||||
|
|
||||||
const ALLOWED_STYLES = new Set([
|
const ALLOWED_STYLES = new Set([
|
||||||
'normal',
|
'normal',
|
||||||
@@ -119,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([
|
|||||||
['space-missions', 'space-missions'],
|
['space-missions', 'space-missions'],
|
||||||
['missions', 'space-missions'],
|
['missions', 'space-missions'],
|
||||||
]);
|
]);
|
||||||
/**
|
|
||||||
* Every model-readable field that carries a context-mode id, and what an
|
|
||||||
* absent value means for each.
|
|
||||||
*
|
|
||||||
* `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode`
|
|
||||||
* are absent when there is no such mode at all — calling those 'off' would
|
|
||||||
* assert a state that does not exist.
|
|
||||||
*/
|
|
||||||
const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([
|
|
||||||
{ field: 'mode', emptyAs: 'off' },
|
|
||||||
{ field: 'entering', emptyAs: null },
|
|
||||||
{ field: 'priorMode', emptyAs: null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Nested results that are themselves context-mode payloads the model reads. */
|
|
||||||
const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([
|
|
||||||
'context',
|
|
||||||
'contextRollback',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report a context-mode payload in the tools' own vocabulary.
|
|
||||||
*
|
|
||||||
* `set_context_mode` accepts 'contacts' while the mode's internal id is
|
|
||||||
* 'flights'. Reporting the internal id back made the model read
|
|
||||||
* `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts
|
|
||||||
* window counts sitting in the very same payload (owner field session
|
|
||||||
* 2026-08-21). Secondary fields and nested transition/rollback results are
|
|
||||||
* translated too — one leaked internal id is enough to recreate the confusion,
|
|
||||||
* and a rollback result is exactly what the model reads when something went
|
|
||||||
* wrong. Each internal id is kept alongside as `<field>Internal` for anything
|
|
||||||
* reasoning about layers.
|
|
||||||
* @param {object|null|undefined} state Any payload carrying context-mode fields.
|
|
||||||
* @returns {object|null|undefined} The same payload, modes translated.
|
|
||||||
*/
|
|
||||||
function withContextModeVocabulary(state) {
|
|
||||||
if (!state || typeof state !== 'object') return state;
|
|
||||||
let out = state;
|
|
||||||
const mutable = () => {
|
|
||||||
if (out === state) out = { ...state };
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) {
|
|
||||||
if (!(field in state)) continue;
|
|
||||||
const internal = state[field] ?? null;
|
|
||||||
const target = mutable();
|
|
||||||
target[field] = contextModeWord(internal, { emptyAs });
|
|
||||||
target[`${field}Internal`] = internal;
|
|
||||||
}
|
|
||||||
for (const field of NESTED_CONTEXT_RESULT_FIELDS) {
|
|
||||||
const nested = state[field];
|
|
||||||
if (!nested || typeof nested !== 'object') continue;
|
|
||||||
const translated = withContextModeVocabulary(nested);
|
|
||||||
if (translated !== nested) mutable()[field] = translated;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COCKPIT_ACTION_ALIASES = new Map([
|
const COCKPIT_ACTION_ALIASES = new Map([
|
||||||
['next', 'next'],
|
['next', 'next'],
|
||||||
['previous', 'previous'],
|
['previous', 'previous'],
|
||||||
@@ -246,14 +192,6 @@ const STACK_ALIASES = new Map([
|
|||||||
['road map', 'osm'],
|
['road map', 'osm'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Search order for track_entity across entity layer families. */
|
|
||||||
const TRACKABLE_FAMILIES = [
|
|
||||||
{ layerId: 'flights', kind: 'aircraft' },
|
|
||||||
{ layerId: 'military', kind: 'aircraft' },
|
|
||||||
{ layerId: 'ais-live-vessels', kind: 'vessel' },
|
|
||||||
{ layerId: 'satellites', kind: 'satellite' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FRAME_TARGETS = new Map([
|
const FRAME_TARGETS = new Map([
|
||||||
['flights', 'flights'],
|
['flights', 'flights'],
|
||||||
['planes', 'flights'],
|
['planes', 'flights'],
|
||||||
@@ -2553,26 +2491,6 @@ function runManagedVoiceNavigation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gathers tracked/selected entities across layer families for read-back. */
|
|
||||||
function collectTrackedEntities(dataManager) {
|
|
||||||
const tracked = [];
|
|
||||||
for (const family of TRACKABLE_FAMILIES) {
|
|
||||||
const module = dataManager.layers.get(family.layerId)?.module;
|
|
||||||
if (!module) continue;
|
|
||||||
try {
|
|
||||||
const info =
|
|
||||||
family.kind === 'vessel'
|
|
||||||
? module.getSelectedInfo?.()
|
|
||||||
: module.getTrackedInfo?.();
|
|
||||||
if (info)
|
|
||||||
tracked.push({ kind: family.kind, layerId: family.layerId, ...info });
|
|
||||||
} catch {
|
|
||||||
// layer not ready
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBasemapLabelContext(
|
export async function getBasemapLabelContext(
|
||||||
viewer,
|
viewer,
|
||||||
service = defaultGeospatial,
|
service = defaultGeospatial,
|
||||||
@@ -3210,60 +3128,12 @@ function getCurrentViewState(
|
|||||||
dataManager,
|
dataManager,
|
||||||
sceneDirector = null,
|
sceneDirector = null,
|
||||||
) {
|
) {
|
||||||
const cartographic = Cesium.Cartographic.fromCartesian(
|
return getWorldPacket({
|
||||||
viewer.camera.positionWC,
|
viewer,
|
||||||
);
|
styleManager,
|
||||||
return {
|
dataManager,
|
||||||
ok: true,
|
sceneDirector,
|
||||||
action: 'get_current_view_state',
|
});
|
||||||
camera: {
|
|
||||||
latitude: Cesium.Math.toDegrees(cartographic.latitude),
|
|
||||||
longitude: Cesium.Math.toDegrees(cartographic.longitude),
|
|
||||||
heightM: cartographic.height,
|
|
||||||
},
|
|
||||||
style: styleManager.activeStyle || 'normal',
|
|
||||||
context:
|
|
||||||
typeof styleManager.getContextModeState === 'function'
|
|
||||||
? {
|
|
||||||
...withContextModeVocabulary(styleManager.getContextModeState()),
|
|
||||||
// The numbers on the operator's Contacts panel, so a window/count
|
|
||||||
// question can be answered from what they are looking at.
|
|
||||||
...(activeContactsWindow(dataManager)
|
|
||||||
? { contactsWindow: activeContactsWindow(dataManager) }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
cockpit:
|
|
||||||
typeof styleManager.getCockpitState === 'function'
|
|
||||||
? styleManager.getCockpitState()
|
|
||||||
: null,
|
|
||||||
controls:
|
|
||||||
typeof styleManager.getControlState === 'function'
|
|
||||||
? styleManager.getControlState()
|
|
||||||
: null,
|
|
||||||
scenePlayback: sceneDirector?.getPlaybackStatus?.() || null,
|
|
||||||
tracked: collectTrackedEntities(dataManager),
|
|
||||||
// Enabled layers only: the full registry cost ~1k tokens per call and
|
|
||||||
// the model only reasons about what is on.
|
|
||||||
disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled)
|
|
||||||
.length,
|
|
||||||
layers: dataManager
|
|
||||||
.getAll()
|
|
||||||
.filter((layer) => layer.enabled)
|
|
||||||
.map((layer) => ({
|
|
||||||
id: layer.id,
|
|
||||||
name: layer.name,
|
|
||||||
enabled: layer.enabled,
|
|
||||||
count: layer.stats?.count || 0,
|
|
||||||
error: layer.stats?.error || null,
|
|
||||||
feedState: layerSnapshot(layer).feedState,
|
|
||||||
source: layerSnapshot(layer).source,
|
|
||||||
lastUpdate: layerSnapshot(layer).lastUpdate,
|
|
||||||
})),
|
|
||||||
feedProvenance: feedProvenanceEnvelope(
|
|
||||||
layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEntityContext(
|
async function getEntityContext(
|
||||||
@@ -4635,23 +4505,6 @@ function clampNumber(value, min, max, fallback) {
|
|||||||
* ("which of those is closest?") scoped to the session.
|
* ("which of those is closest?") scoped to the session.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* The Contacts panel's own counts, or null when Contacts has no subject.
|
|
||||||
* Read through the awareness snapshot the panel renders, so the two cannot
|
|
||||||
* diverge no matter which surface asks.
|
|
||||||
* @returns {object|null} Panel-equivalent window counts.
|
|
||||||
*/
|
|
||||||
function activeContactsWindow(dataManager) {
|
|
||||||
try {
|
|
||||||
const layer = dataManager?.layers?.get('military-awareness')?.module;
|
|
||||||
return (
|
|
||||||
layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Most records one layer hands a single query. Above every loaded feed seen
|
* 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
|
* so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so
|
||||||
|
|||||||
Reference in new issue
Block a user