From 9dc446bd240c20c1915f2e57b2bc1c9f89eba96e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 05:05:40 +0400 Subject: [PATCH] feat(military): render on Navara MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- scripts/format-scope.json | 3 + scripts/package-boundaries.json | 13 ++- src/app/layers/qaSources.js | 20 +++- src/app/layers/qaSources.test.mjs | 21 ++++ src/app/navaraLayers.test.mjs | 29 +++++ src/app/navaraMode.js | 25 ++++- src/bench/w1Feeds.js | 62 ++++++++++- src/layers/aircraft/qaDrawn.js | 49 +++++++++ src/layers/aircraft/render/navara.js | 22 ++-- src/layers/flights/queries.js | 38 +------ src/layers/military/queries.js | 23 +++- src/layers/military/render/navara.js | 22 ++++ src/layers/military/renderer.test.mjs | 152 ++++++++++++++++++++++++++ 13 files changed, 423 insertions(+), 56 deletions(-) create mode 100644 src/app/navaraLayers.test.mjs create mode 100644 src/layers/aircraft/qaDrawn.js create mode 100644 src/layers/military/render/navara.js diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f6afb7c88..b2139818d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -352,6 +352,9 @@ "src/layers/military/queries.js", "src/layers/military/index.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/classification.js", "src/layers/military/ownership.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 95855dce0..cb6ef8514 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -897,7 +897,8 @@ "src/time/clock.js", "src/globe/contract.js", "src/globe/cesium/engineFromViewer.js", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/layers/aircraft/qaDrawn.js" ], "external": ["cesium"] }, @@ -954,7 +955,8 @@ "src/geo/developerError.js", "src/time/clock.js", "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/globe/cesium/engineFromViewer.js", + "src/layers/aircraft/qaDrawn.js" ], "external": ["cesium"] }, @@ -2596,7 +2598,9 @@ "src/data/selectionDispatcher.js", "src/layers/shared/color.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": [ "hls.js", @@ -3172,7 +3176,8 @@ "src/data/selectionDispatcher.js", "src/layers/shared/color.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": [ "hls.js", diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index f8f351062..c06e3911e 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -5,16 +5,24 @@ // shape. Times are relative to now, so the objects stay fresh. // // 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 import { createFlightSource, + createMilitarySource, createVesselSource, } from '../../sources/live/standalone.js'; -import { syntheticFlights, syntheticVessels } from '../../bench/w1Feeds.js'; +import { + syntheticFlights, + syntheticMilitary, + syntheticVessels, +} from '../../bench/w1Feeds.js'; const KINDS = Object.freeze({ flights: new Set(['synthetic']), + military: new Set(['synthetic']), vessels: new Set(['fixture']), }); @@ -30,7 +38,7 @@ export function parseQaFeed(search = '') { (count !== null && !(Number.isInteger(count) && count > 0)) ) 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 }; }); @@ -52,6 +60,14 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) { fetchImpl: async () => 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') sources.vessels = createVesselSource({ fetchImpl: async (url) => { diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs index 0685be701..d8e519ddf 100644 --- a/src/app/layers/qaSources.test.mjs +++ b/src/app/layers/qaSources.test.mjs @@ -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.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, []); +}); diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs new file mode 100644 index 000000000..bf1d93400 --- /dev/null +++ b/src/app/navaraLayers.test.mjs @@ -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); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 8d185fac2..7401848e4 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -54,11 +54,26 @@ const SHELL_CREDIT_KEYS = new Set([ * here, through import(), so Cesium mode never fetches them. */ export async function registerNavaraLayerRenderers() { - if (layerAvailableOn('flights', 'navara')) return; - const { createFlightsNavaraRenderer } = - await import('../layers/flights/render/navara.js'); - if (!layerAvailableOn('flights', 'navara')) - registerLayerRenderers('navara', { flights: createFlightsNavaraRenderer }); + const loaders = { + flights: async () => + (await import('../layers/flights/render/navara.js')) + .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); } /** diff --git a/src/bench/w1Feeds.js b/src/bench/w1Feeds.js index 03f4b2bfc..49b778057 100644 --- a/src/bench/w1Feeds.js +++ b/src/bench/w1Feeds.js @@ -1,5 +1,5 @@ // 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 // 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 @@ -123,6 +123,66 @@ export function syntheticVessels({ 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. */ export function syntheticLocalReceivers({ nowMs = W1_FROZEN_MS, diff --git a/src/layers/aircraft/qaDrawn.js b/src/layers/aircraft/qaDrawn.js new file mode 100644 index 000000000..bca923a46 --- /dev/null +++ b/src/layers/aircraft/qaDrawn.js @@ -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} + */ +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; +} diff --git a/src/layers/aircraft/render/navara.js b/src/layers/aircraft/render/navara.js index 5d77f2a76..dcc9fba31 100644 --- a/src/layers/aircraft/render/navara.js +++ b/src/layers/aircraft/render/navara.js @@ -189,6 +189,16 @@ const scalarKey = (scalar) => ? `${scalar.near},${scalar.nearValue},${scalar.far},${scalar.farValue}` : '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 // --------------------------------------------------------------------------- @@ -849,7 +859,7 @@ export function createAircraftNavaraRenderer({ }; // --- 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 // handle's annotation fields, read when the card paints). const followOf = (handle) => { @@ -864,17 +874,15 @@ export function createAircraftNavaraRenderer({ 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 = { start(handle) { - starting = handle; + const outer = followStarting; + followStarting = handle; let followHandle; try { followHandle = followOf(handle); } finally { - starting = null; + followStarting = outer; } handle.gevFollowHandle = followHandle; ownFollows.set(followHandle, handle); @@ -892,7 +900,7 @@ export function createAircraftNavaraRenderer({ Boolean(handle?.gevFollowHandle) && engine.camera.following() === handle.gevFollowHandle, current() { - if (starting) return starting; + if (followStarting) return followStarting; const handle = engine.camera.following(); return handle ? (ownFollows.get(handle) ?? handle) : undefined; }, diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 8a9136f02..b1b330bdb 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -1,4 +1,5 @@ import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; +import { aircraftQaDrawn } from '../aircraft/qaDrawn.js'; import { displayedKinematics } from '../../data/motionModel.js'; import * as geo from '../../geo/index.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; @@ -817,42 +818,7 @@ export function createQueries({ * @returns {Array} */ qaDrawn() { - const out = []; - 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; + return aircraftQaDrawn(flightState); }, /** diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 2b03ae9f3..7961bcce8 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -1,4 +1,5 @@ import { displayedKinematics } from '../../data/motionModel.js'; +import { aircraftQaDrawn } from '../aircraft/qaDrawn.js'; import * as geo from '../../geo/index.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; @@ -640,8 +641,18 @@ export function createQueries({ if (!flightState._billboards.has(id)) return false; if (parts.tracking._isExplicitTrackingOrigin(origin)) 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); + } parts.tracking._trackFlight(id, { origin }); return true; }, @@ -720,6 +731,16 @@ export function createQueries({ 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} + */ + qaDrawn() { + return aircraftQaDrawn(flightState); + }, + /** * Stop tracking the currently followed aircraft (no-op if none). * @returns {boolean} Always true. diff --git a/src/layers/military/render/navara.js b/src/layers/military/render/navara.js new file mode 100644 index 000000000..a8b4bd1a9 --- /dev/null +++ b/src/layers/military/render/navara.js @@ -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, + }); +} diff --git a/src/layers/military/renderer.test.mjs b/src/layers/military/renderer.test.mjs index 9e002594a..c5e0c795a 100644 --- a/src/layers/military/renderer.test.mjs +++ b/src/layers/military/renderer.test.mjs @@ -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.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(); +});