feat(military): render on Navara

Task 11b. src/layers/military/render/navara.js: the shared Navara aircraft
renderer with the military's sprite slot (SPRITE_LAYER_ORDER 'military',
under flights) and jet.glb's nose offset; registered by
registerNavaraLayerRenderers (now a table of lazily imported factories,
flights and military), so the Navara catalog offers military and the main
chunk stays without three/Navara.

- The follow passes between aircraft layers on Navara as on Cesium: the
  follow-handle → tracked-handle map and the "follow being started" handle
  are shared by every aircraft renderer of the page, so a layer whose follow
  another layer took reads the new owner's tracked handle (and its
  gevSelectionOrigin) — before, it read a bare follow handle and reported
  'programmatic'.
- trackById on a selected-but-not-followed contact follows it again (as
  flights since 10b; on Navara any camera command ends a follow).
- qaDrawn (the probe's drawn()) for military, shared with flights
  (src/layers/aircraft/qaDrawn.js).
- ?qaFeed=military:synthetic[:count]: seeded readsb rows (1 000 by
  default, 150 near JFK, 12 on the ground, 8 types), the first 25 hexes
  shared with the synthetic civil flights.
- qa-w1-layers: sections 4–6 read the --layer's module (were 'flights');
  section 5 keeps the queries beside the voice answers (the voice block
  overwrote them, so --compare never compared queries); for military: the
  follow passes military → flights → military, and flights leaves the 25
  shared aircraft to military while it is on (9 975 / 10 000 / 9 975).

qa-w1-layers --layer=military: Cesium 25/25, Navara 26/26, --compare parity
(queries included; the known 2.3 px camera offset at the global pose only).
This commit is contained in:
German Khokhlov committed 2026-10-17 05:05:40 +04:00
1 parent 3a0a3b0551
commit 9dc446bd24
13 files changed
+423 -56

No files matched your search

+3
View File
@@ -352,6 +352,9 @@
"src/layers/military/queries.js", "src/layers/military/queries.js",
"src/layers/military/index.js", "src/layers/military/index.js",
"src/layers/military/render/cesium.js", "src/layers/military/render/cesium.js",
"src/layers/military/render/navara.js",
"src/layers/aircraft/qaDrawn.js",
"src/app/navaraLayers.test.mjs",
"src/layers/aircraft/index.js", "src/layers/aircraft/index.js",
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/military/ownership.test.mjs", "src/layers/military/ownership.test.mjs",
+9 -4
View File
@@ -897,7 +897,8 @@
"src/time/clock.js", "src/time/clock.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js", "src/globe/cesium/engineFromViewer.js",
"src/layers/shared/color.js" "src/layers/shared/color.js",
"src/layers/aircraft/qaDrawn.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -954,7 +955,8 @@
"src/geo/developerError.js", "src/geo/developerError.js",
"src/time/clock.js", "src/time/clock.js",
"src/globe/contract.js", "src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js" "src/globe/cesium/engineFromViewer.js",
"src/layers/aircraft/qaDrawn.js"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -2596,7 +2598,9 @@
"src/data/selectionDispatcher.js", "src/data/selectionDispatcher.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/aircraft/render/navara.js", "src/layers/aircraft/render/navara.js",
"src/layers/flights/render/navara.js" "src/layers/flights/render/navara.js",
"src/layers/military/render/navara.js",
"src/layers/aircraft/qaDrawn.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3172,7 +3176,8 @@
"src/data/selectionDispatcher.js", "src/data/selectionDispatcher.js",
"src/layers/shared/color.js", "src/layers/shared/color.js",
"src/layers/flights/render/cesium.js", "src/layers/flights/render/cesium.js",
"src/layers/military/render/cesium.js" "src/layers/military/render/cesium.js",
"src/layers/aircraft/qaDrawn.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+18 -2
View File
@@ -5,16 +5,24 @@
// shape. Times are relative to now, so the objects stay fresh. // shape. Times are relative to now, so the objects stay fresh.
// //
// flights:synthetic[:count] OpenSky states, `count` aircraft (8 000) // flights:synthetic[:count] OpenSky states, `count` aircraft (8 000)
// military:synthetic[:count] readsb rows, `count` aircraft (1 000); the
// first 25 are also synthetic civil flights
// vessels:fixture 12 000 AIS rows, or those within an area // vessels:fixture 12 000 AIS rows, or those within an area
import { import {
createFlightSource, createFlightSource,
createMilitarySource,
createVesselSource, createVesselSource,
} from '../../sources/live/standalone.js'; } from '../../sources/live/standalone.js';
import { syntheticFlights, syntheticVessels } from '../../bench/w1Feeds.js'; import {
syntheticFlights,
syntheticMilitary,
syntheticVessels,
} from '../../bench/w1Feeds.js';
const KINDS = Object.freeze({ const KINDS = Object.freeze({
flights: new Set(['synthetic']), flights: new Set(['synthetic']),
military: new Set(['synthetic']),
vessels: new Set(['fixture']), vessels: new Set(['fixture']),
}); });
@@ -30,7 +38,7 @@ export function parseQaFeed(search = '') {
(count !== null && !(Number.isInteger(count) && count > 0)) (count !== null && !(Number.isInteger(count) && count > 0))
) )
throw new TypeError( throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], vessels:fixture)`, `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture)`,
); );
return { layer, kind, count }; return { layer, kind, count };
}); });
@@ -52,6 +60,14 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
fetchImpl: async () => fetchImpl: async () =>
json(syntheticFlights({ nowMs: now(), count: count ?? 8000 })), json(syntheticFlights({ nowMs: now(), count: count ?? 8000 })),
}); });
if (layer === 'military')
sources.military = createMilitarySource({
now,
fetchImpl: async (url) =>
String(url).includes('/track')
? json({ timestamp: now() / 1000, trace: [] })
: json(syntheticMilitary({ nowMs: now(), count: count ?? 1000 })),
});
if (layer === 'vessels') if (layer === 'vessels')
sources.vessels = createVesselSource({ sources.vessels = createVesselSource({
fetchImpl: async (url) => { fetchImpl: async (url) => {
+21
View File
@@ -42,3 +42,24 @@ test('fixture sources answer with the live sources’ snapshot shape', async ()
assert.ok(near.records.length > 10 && near.records.length < 12000); assert.ok(near.records.length > 10 && near.records.length < 12000);
assert.deepEqual(createQaSources([]), {}); assert.deepEqual(createQaSources([]), {});
}); });
test('military:synthetic answers with the military source’s shape, some shared with flights', async () => {
const now = Date.UTC(2026, 9, 16, 12);
const sources = createQaSources(
parseQaFeed('?qaFeed=military:synthetic:300,flights:synthetic:500'),
{ now: () => now },
);
const military = await sources.military.getSnapshot({});
assert.equal(military.records.length, 300);
assert.ok(military.records.some((record) => record.onGround));
assert.ok(military.records.every((record) => record.typeCode));
const identities = await sources.military.getIdentities({});
assert.ok(identities.records?.length ?? identities.length);
const civil = new Set(
(await sources.flights.getSnapshot({})).records.map(({ id }) => id),
);
const shared = military.records.filter(({ id }) => civil.has(id));
assert.equal(shared.length, 25);
const track = await sources.military.getTrack('ae1100');
assert.deepEqual(track.records, []);
});
+29
View File
@@ -0,0 +1,29 @@
// The layers the Navara mode draws (registerNavaraLayerRenderers): each W1
// layer with a Navara renderer, registered once, through import().
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerNavaraLayerRenderers } from './navaraMode.js';
import {
createLayerAvailability,
layerAvailableOn,
rendererFor,
} from './layers/renderers.js';
test('the Navara mode registers the flights and military renderers, once', async () => {
assert.equal(layerAvailableOn('military', 'navara'), false);
await Promise.all([
registerNavaraLayerRenderers(),
registerNavaraLayerRenderers(),
]);
await registerNavaraLayerRenderers();
for (const id of ['flights', 'military'])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
createLayerAvailability('navara')
.available()
.map(({ id }) => id),
['flights', 'military'],
);
assert.equal(rendererFor('satellites', { kind: 'navara' }), null);
});
+20 -5
View File
@@ -54,11 +54,26 @@ const SHELL_CREDIT_KEYS = new Set([
* here, through import(), so Cesium mode never fetches them. * here, through import(), so Cesium mode never fetches them.
*/ */
export async function registerNavaraLayerRenderers() { export async function registerNavaraLayerRenderers() {
if (layerAvailableOn('flights', 'navara')) return; const loaders = {
const { createFlightsNavaraRenderer } = flights: async () =>
await import('../layers/flights/render/navara.js'); (await import('../layers/flights/render/navara.js'))
if (!layerAvailableOn('flights', 'navara')) .createFlightsNavaraRenderer,
registerLayerRenderers('navara', { flights: createFlightsNavaraRenderer }); military: async () =>
(await import('../layers/military/render/navara.js'))
.createMilitaryNavaraRenderer,
};
const missing = Object.keys(loaders).filter(
(id) => !layerAvailableOn(id, 'navara'),
);
if (!missing.length) return;
const factories = await Promise.all(missing.map((id) => loaders[id]()));
// A concurrent call may have registered some meanwhile.
const fresh = Object.fromEntries(
missing
.map((id, index) => [id, factories[index]])
.filter(([id]) => !layerAvailableOn(id, 'navara')),
);
if (Object.keys(fresh).length) registerLayerRenderers('navara', fresh);
} }
/** /**
+61 -1
View File
@@ -1,5 +1,5 @@
// Seeded synthetic feed answers: the global /api/flights snapshot (OpenSky // Seeded synthetic feed answers: the global /api/flights snapshot (OpenSky
// states), 12 000 AIS vessels on /api/vessels (with and without an area) and // states), the /api/military snapshot (readsb rows), 12 000 AIS vessels on /api/vessels (with and without an area) and
// the local receivers' 1090 MHz and 978 MHz UAT aircraft near JFK, timed // the local receivers' 1090 MHz and 978 MHz UAT aircraft near JFK, timed
// relative to one instant, so every run sees the same objects at the same // relative to one instant, so every run sees the same objects at the same
// places. Pure: Node and browser, no I/O. Used by the W1 baseline recordings // places. Pure: Node and browser, no I/O. Used by the W1 baseline recordings
@@ -123,6 +123,66 @@ export function syntheticVessels({
return { rows, newestPositionAt: nowMs }; return { rows, newestPositionAt: nowMs };
} }
// Military types across the weight classes (data/aircraftClass): heavies,
// fast jets, helicopters, tankers, patrol.
const MILITARY_TYPES = [
['C17', 'RCH'],
['F16', 'VIPER'],
['KC135', 'PACK'],
['H60', 'DUSTY'],
['C130', 'HERKY'],
['P8', 'PELCN'],
['E3TF', 'SENTRY'],
['MQ9', 'REAPER'],
];
/**
* The /api/military answer (readsb rows): `count` aircraft, 150 of them near
* JFK (12 on the ground there), the rest worldwide. The first `overlap`
* hexes are also civil synthetic flights (a00000…), which the flights layer
* leaves to this layer while it is on (duplicate suppression).
*/
export function syntheticMilitary({
nowMs = W1_FROZEN_MS,
count = 1_000,
seed = 41,
overlap = 25,
} = {}) {
const random = mulberry32(seed);
const near = Math.min(150, count);
const ac = [];
for (let index = 0; index < count; index++) {
const [type, call] = MILITARY_TYPES[index % MILITARY_TYPES.length];
const nearJfk = index < near;
const grounded = nearJfk && index % 12 === 5;
const lat = nearJfk
? JFK.lat + (random() - 0.5) * (grounded ? 0.01 : 1.0)
: random() * 140 - 70;
const lon = nearJfk
? JFK.lng + (random() - 0.5) * (grounded ? 0.01 : 1.3)
: random() * 360 - 180;
ac.push({
hex:
index < overlap ? hex(0xa00000 + index, 6) : hex(0xae1000 + index, 6),
type: 'adsb_icao',
flight: `${call}${String(index % 100).padStart(2, '0')}`.padEnd(8),
r: `${10_000 + index}`,
t: type,
alt_baro: grounded ? 'ground' : Math.round(2_000 + random() * 33_000),
gs: grounded
? Math.round(random() * 12)
: Math.round(160 + random() * 330),
track: Math.round(random() * 3600) / 10,
baro_rate: 0,
lat: Number(lat.toFixed(5)),
lon: Number(lon.toFixed(5)),
seen_pos: Number((random() * 2).toFixed(1)),
seen: 0,
});
}
return { ac, now: nowMs / 1000, total: ac.length, msg: 'No error' };
}
/** The /api/local-receivers/aircraft answer: 1090 MHz and 978 MHz UAT aircraft. */ /** The /api/local-receivers/aircraft answer: 1090 MHz and 978 MHz UAT aircraft. */
export function syntheticLocalReceivers({ export function syntheticLocalReceivers({
nowMs = W1_FROZEN_MS, nowMs = W1_FROZEN_MS,
+49
View File
@@ -0,0 +1,49 @@
// What an aircraft layer draws, as plain data (QA: the engine-neutral probe,
// window.__godsEyeView.layers.drawn): per contact its glyph (image, scale,
// shown, screen rotation, alpha, position) and its 3D model, read from the
// renderer's handles — the same on every engine. Shared by the aircraft
// layers (flights, military).
/**
* @param {object} state The layer's state: `_billboards` (id → sprite
* handle), `_models` (id → model handle), `_trackedIcao`, `_trackedModel`.
* @returns {Array<object>}
*/
export function aircraftQaDrawn(state) {
const out = [];
for (const [id, sprite] of state._billboards) {
const model = state._models.get(id);
out.push({
id,
tracked: id === state._trackedIcao,
image: sprite.image,
scale: sprite.scale,
width: sprite.width,
show: sprite.show,
rotation: sprite.rotation,
alpha: sprite.color?.alpha ?? null,
position: sprite.position
? {
x: sprite.position.x,
y: sprite.position.y,
z: sprite.position.z,
}
: null,
model: model
? { scale: model.scale, show: model.show, ready: model.ready }
: null,
});
}
const tracked = state._trackedModel;
if (state._trackedIcao && tracked)
out.push({
id: state._trackedIcao,
trackedModel: true,
model: {
scale: tracked.scale,
show: tracked.show,
ready: tracked.ready,
},
});
return out;
}
+15 -7
View File
@@ -189,6 +189,16 @@ const scalarKey = (scalar) =>
? `${scalar.near},${scalar.nearValue},${scalar.far},${scalar.farValue}` ? `${scalar.near},${scalar.nearValue},${scalar.far},${scalar.farValue}`
: 'none'; : 'none';
// Follow handle → the tracked handle it follows, for every aircraft layer on
// the engine: a layer whose follow another aircraft layer took reads the
// new owner's tracked handle (its gevSelectionOrigin), as Cesium's
// viewer.trackedEntity gives it the other layer's entity.
const FOLLOWED_TRACKED = new WeakMap();
// The tracked handle whose follow is being started (any aircraft layer):
// the engine raises followChanged inside follow(), before the follow
// handle is known, and every layer's listener must see the new owner.
let followStarting = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The renderer // The renderer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -849,7 +859,7 @@ export function createAircraftNavaraRenderer({
}; };
// --- follow --------------------------------------------------------------- // --- follow ---------------------------------------------------------------
const ownFollows = new WeakMap(); // follow handle → tracked handle const ownFollows = FOLLOWED_TRACKED; // follow handle → tracked handle
// The follow carries the tracked readout's card as plain data (the // The follow carries the tracked readout's card as plain data (the
// handle's annotation fields, read when the card paints). // handle's annotation fields, read when the card paints).
const followOf = (handle) => { const followOf = (handle) => {
@@ -864,17 +874,15 @@ export function createAircraftNavaraRenderer({
label: card, label: card,
}); });
}; };
// The tracked handle whose follow is being started: the engine raises
// followChanged inside follow(), before the handle is known here.
let starting = null;
const follow = { const follow = {
start(handle) { start(handle) {
starting = handle; const outer = followStarting;
followStarting = handle;
let followHandle; let followHandle;
try { try {
followHandle = followOf(handle); followHandle = followOf(handle);
} finally { } finally {
starting = null; followStarting = outer;
} }
handle.gevFollowHandle = followHandle; handle.gevFollowHandle = followHandle;
ownFollows.set(followHandle, handle); ownFollows.set(followHandle, handle);
@@ -892,7 +900,7 @@ export function createAircraftNavaraRenderer({
Boolean(handle?.gevFollowHandle) && Boolean(handle?.gevFollowHandle) &&
engine.camera.following() === handle.gevFollowHandle, engine.camera.following() === handle.gevFollowHandle,
current() { current() {
if (starting) return starting; if (followStarting) return followStarting;
const handle = engine.camera.following(); const handle = engine.camera.following();
return handle ? (ownFollows.get(handle) ?? handle) : undefined; return handle ? (ownFollows.get(handle) ?? handle) : undefined;
}, },
+2 -36
View File
@@ -1,4 +1,5 @@
import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js';
import { aircraftQaDrawn } from '../aircraft/qaDrawn.js';
import { displayedKinematics } from '../../data/motionModel.js'; import { displayedKinematics } from '../../data/motionModel.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
@@ -817,42 +818,7 @@ export function createQueries({
* @returns {Array<object>} * @returns {Array<object>}
*/ */
qaDrawn() { qaDrawn() {
const out = []; return aircraftQaDrawn(flightState);
for (const [id, sprite] of flightState._billboards) {
const model = flightState._models.get(id);
out.push({
id,
tracked: id === flightState._trackedIcao,
image: sprite.image,
scale: sprite.scale,
width: sprite.width,
show: sprite.show,
rotation: sprite.rotation,
alpha: sprite.color?.alpha ?? null,
position: sprite.position
? {
x: sprite.position.x,
y: sprite.position.y,
z: sprite.position.z,
}
: null,
model: model
? { scale: model.scale, show: model.show, ready: model.ready }
: null,
});
}
const tracked = flightState._trackedModel;
if (flightState._trackedIcao && tracked)
out.push({
id: flightState._trackedIcao,
trackedModel: true,
model: {
scale: tracked.scale,
show: tracked.show,
ready: tracked.ready,
},
});
return out;
}, },
/** /**
+22 -1
View File
@@ -1,4 +1,5 @@
import { displayedKinematics } from '../../data/motionModel.js'; import { displayedKinematics } from '../../data/motionModel.js';
import { aircraftQaDrawn } from '../aircraft/qaDrawn.js';
import * as geo from '../../geo/index.js'; import * as geo from '../../geo/index.js';
import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js';
import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js';
@@ -640,8 +641,18 @@ export function createQueries({
if (!flightState._billboards.has(id)) return false; if (!flightState._billboards.has(id)) return false;
if (parts.tracking._isExplicitTrackingOrigin(origin)) if (parts.tracking._isExplicitTrackingOrigin(origin))
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
if (flightState._trackedIcao === id) if (flightState._trackedIcao === id) {
// Selected but no longer followed (a camera command took the camera:
// a search flight, the startup view; on Navara any command ends a
// follow): asking to track it again follows it again (as flights).
const entity = flightState._trackedEntity;
if (entity && !flightState._renderer?.follow.isFollowing(entity)) {
flightState._trackedCameraFrameStop?.();
flightState._trackedCameraFrameStop =
flightState._renderer.follow.refocus(entity);
}
return parts.tracking._publishTrackedSelection(id, origin); return parts.tracking._publishTrackedSelection(id, origin);
}
parts.tracking._trackFlight(id, { origin }); parts.tracking._trackFlight(id, { origin });
return true; return true;
}, },
@@ -720,6 +731,16 @@ export function createQueries({
return true; return true;
}, },
/**
* What this layer draws, as plain data (QA: the engine-neutral probe,
* window.__godsEyeView.layers.drawn): per contact its glyph and its 3D
* model, read from the renderer's handles — the same on every engine.
* @returns {Array<object>}
*/
qaDrawn() {
return aircraftQaDrawn(flightState);
},
/** /**
* Stop tracking the currently followed aircraft (no-op if none). * Stop tracking the currently followed aircraft (no-op if none).
* @returns {boolean} Always true. * @returns {boolean} Always true.
+22
View File
@@ -0,0 +1,22 @@
// The military flights layer's renderer on the Navara engine: the shared
// aircraft renderer (src/layers/aircraft/render/navara.js) with the
// military's sprite slot (data/spriteOrder's order on Cesium) and jet.glb's
// nose offset. Registered for 'navara' by the Navara mode after its
// import() (src/app/navaraMode.js).
import { createAircraftNavaraRenderer } from '../../aircraft/render/navara.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
import { MODEL_HEADING_OFFSET_DEG } from '../policy.js';
/**
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
*/
export function createMilitaryNavaraRenderer(engine) {
return createAircraftNavaraRenderer({
engine,
layerId: 'military',
order: SPRITE_LAYER_ORDER.indexOf('military'),
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
});
}
+152
View File
@@ -140,3 +140,155 @@ test('the military logic draws, selects and follows only through its renderer',
assert.ok(renderer.calls.some(([name]) => name === 'destroy')); assert.ok(renderer.calls.some(([name]) => name === 'destroy'));
assert.equal(keyListeners.size, 0, 'the dispatcher left the keyboard'); assert.equal(keyListeners.size, 0, 'the dispatcher left the keyboard');
}); });
test('the military logic draws and follows on the Navara renderer; the follow passes between flights and military', async () => {
const { createAircraftNavaraRenderer, aircraftAtlasLayout } =
await import('../aircraft/render/navara.js');
const { createApplicationFlights } =
await import('../../app/layers/flights.js');
const { openSkySnapshot } = await import('../../sources/live/aircraft.js');
const layout = aircraftAtlasLayout();
const drawAtlas = async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
});
const engine = createFakeEngine();
const renderers = {};
const navara = (layerId, order) => ({
create: ({ engine: given }) =>
(renderers[layerId] = createAircraftNavaraRenderer({
engine: given,
layerId,
order,
headingOffsetDeg: 180,
drawAtlas,
})),
});
const military = createApplicationMilitary({
surface: defaultSurface,
source: {
label: 'Fixture military',
getSnapshot: async () => militarySnapshotOf(['ae0001', 'ae0002']),
},
militaryRegistry,
renderer: navara('military', 7),
});
const nowSec = Math.floor(Date.now() / 1000);
const flights = createApplicationFlights({
surface: defaultSurface,
source: {
label: 'Fixture aircraft',
getSnapshot: async () =>
openSkySnapshot({
time: nowSec,
states: [
[
'abc123',
'TST0 ',
'United States',
nowSec,
nowSec,
-97.9,
30.4,
10_668,
false,
250,
95,
5,
null,
10_700,
null,
null,
null,
5,
],
],
}),
},
militaryRegistry,
renderer: navara('flights', 8),
});
military.init(null, { engine });
flights.init(null, { engine });
military.enable();
flights.enable();
const awareness = [];
const listen = (event) => awareness.push([event.type, event.detail]);
const realDispatch = globalThis.window.dispatchEvent;
globalThis.window.dispatchEvent = (event) => {
listen(event);
return true;
};
try {
await renderers.military.whenReady();
await renderers.flights.whenReady();
await military.update(null);
await flights.update(null);
await Promise.resolve();
const sprites = engine.movers
.list()
.filter(({ id }) => id.startsWith('military-sprites'));
assert.equal(sprites.length, 1);
assert.equal(sprites[0].count, 2);
const drawn = military.qaDrawn();
assert.deepEqual(drawn.map((row) => row.id).sort(), ['ae0001', 'ae0002']);
// The fleet's colour reaches the engine as the layer's amber.
const { store } = engine.fake.moverSet(sprites[0].id);
assert.deepEqual([...store.pickIds.slice(0, 2)].sort(), [
'ae0001',
'ae0002',
]);
// A click on a military contact selects it; the engine follows.
engine.fake.script('pick', () => ({ id: 'ae0002' }));
engine.fake.script('drillPick', () => [{ id: 'ae0002' }]);
click(engine);
assert.equal(military.getTrackedInfo()?.icao24, 'ae0002');
assert.equal(engine.camera.following()?.label?.id, 'military:ae0002');
engine.fake.frame();
assert.equal(
engine.movers.list().find(({ id }) => id.startsWith('military-tracked'))
.count,
1,
);
// A click on a civil flight takes the follow: military lets go, with
// the new owner's origin (as Cesium's trackedEntityChanged gives it).
engine.fake.script('pick', () => ({ id: 'abc123' }));
engine.fake.script('drillPick', () => [{ id: 'abc123' }]);
click(engine);
assert.equal(flights.getTrackedInfo()?.icao24, 'abc123');
assert.equal(military.getTrackedInfo(), null);
assert.equal(engine.camera.following()?.label?.id, 'flights:abc123');
const cleared = awareness.find(
([type, detail]) =>
type === 'gev:awareness-subject-cleared' &&
detail.layerId === 'military',
);
assert.equal(cleared?.[1].origin, 'user');
// And back: tracking a military contact by voice releases flights.
assert.equal(military.trackById('ae0001', { origin: 'voice' }), true);
assert.equal(flights.getTrackedInfo(), null);
assert.equal(engine.camera.following()?.label?.id, 'military:ae0001');
// A camera command ends the follow; tracking it again follows it again.
engine.camera.setView({
destination: engine.camera.pose().position,
});
assert.equal(engine.camera.following(), null);
assert.equal(military.getTrackedInfo()?.icao24, 'ae0001');
assert.equal(military.trackById('ae0001', { origin: 'voice' }), true);
assert.equal(engine.camera.following()?.label?.id, 'military:ae0001');
assert.equal(military.stopTracking(), true);
assert.equal(engine.camera.following(), null);
} finally {
globalThis.window.dispatchEvent = realDispatch;
flights.disable();
military.disable();
flights.destroy(null);
military.destroy(null);
}
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
engine.destroy();
});