From d9737d94f213826bce63ecf7d249faa812c2844f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 6 Oct 2026 12:01:56 -0700 Subject: [PATCH] fix(military): start new contacts at their delayed display position The military fleet also renders behind real time and projects a fresh contact's first fix back to that delayed time, but its billboard was created at the raw fix. A new contact therefore appeared ahead of where the next fleet tick put it and jumped back. The snapshot renderer now receives the layer's motion and creates the billboard at the dead-reckoned delayed position. --- src/layers/military/index.js | 1 + src/layers/military/ownership.test.mjs | 98 +++++++++++++++++++++++++ src/layers/military/snapshotRenderer.js | 9 ++- 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/layers/military/index.js b/src/layers/military/index.js index 283c9b7f8..53a1a25b4 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -33,6 +33,7 @@ export function createMilitaryFlightLayer({ militaryRegistry: services.militaryRegistry, rendering: parts.rendering, tracking: parts.tracking, + motion: parts.motion, queries: parts.queries, }); parts.ingestion = createIngestion({ diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 167900b7c..41081c137 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -113,3 +113,101 @@ test('a normalized source can retain its stale reason without changing standalon assert.equal(layer.getStats().error, null); assert.equal(layer.getStats().stale, true); }); + +test('a newly seen military contact appears at its delayed position, not ahead of it', async () => { + // The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh + // contact's first displayed position is its fix projected back to that + // delayed time. Creating the billboard at the raw fix drew it ahead until + // the next fleet tick jumped it back. + const Cesium = await import('cesium'); + const { RENDER_DELAY_SEC } = await import('./policy.js'); + // The application's own scene services, as src/app/layers/militaryFlights.js + // composes them; only the surface samplers and registry are stubbed. + const supplied = { + picking: await import('../../data/pickRegistry.js'), + sprites: await import('../../data/spriteOrder.js'), + trails: await import('../../data/trailRenderer.js'), + aircraftPresentation: await import('../../data/tr3bRegistry.js'), + camera: await import('../../data/trackedCamera.js'), + labels: await import('../../data/detectionDraw.js'), + geoid: await import('../../data/geoid.js'), + focus: await import('../../data/focusDeemphasis.js'), + readout: await import('../../data/trackedReadout.js'), + context: await import('../../data/contextStore.js'), + render: await import('../../renderGovernor.js'), + recession: await import('../../data/aircraftRecession.js'), + groundFloor: await import('../../data/groundFloor.js'), + groundSnap: await import('../../data/groundSnap.js'), + meshFloor: { sampleMeshFloorCells: () => {} }, + militaryRegistry: { registerMilitaryIcaos: () => {} }, + }; + const fixLon = -97.6; + const fixLat = 30.3; + const speedMps = 200; + const fixAgeMs = 2_000; + const nowMs = Date.now(); + const layer = createMilitaryFlightLayer({ + services: supplied, + source: { + label: 'Fixture aircraft', + async getSnapshot() { + return { + source: 'Fixture aircraft', + complete: true, + observedAtMs: nowMs, + records: [ + { + id: 'ae0001', + latitude: fixLat, + longitude: fixLon, + onGround: false, + baroAltitudeM: 9_000, + courseDeg: 90, + speedMps, + positionTimeMs: nowMs - fixAgeMs, + contactTimeMs: nowMs - fixAgeMs, + }, + ], + }; + }, + }, + }); + const added = []; + layer.testing._setTrackedMilitaryRefreshStateForTest({ + icao24: 'seed00', + entity: null, + meta: { rawLat: 0, rawLon: 0, onGround: false }, + billboard: { + show: false, + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + billboardCollection: { + show: false, + add(options) { + const billboard = { ...options }; + added.push(billboard); + return billboard; + }, + remove() {}, + }, + viewer: { camera: { positionCartographic: null }, scene: {} }, + tracked: false, + }); + await layer.update({ camera: { positionCartographic: null }, scene: {} }); + const billboard = added.find((entry) => entry.id === 'ae0001'); + assert.ok(billboard, 'the new contact gets a billboard'); + const shown = Cesium.Cartographic.fromCartesian(billboard.position); + const behindM = Cesium.Cartesian3.distance( + Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0), + Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0), + ); + const expectedM = speedMps * (RENDER_DELAY_SEC - fixAgeMs / 1000); + assert.ok( + Cesium.Math.toDegrees(shown.longitude) < fixLon, + 'drawn behind the fix', + ); + assert.ok( + Math.abs(behindM - expectedM) < speedMps * 3, + `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, + ); +}); diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index d36576cf7..58eb57972 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -23,6 +23,7 @@ export function createMilitarySnapshotRenderer({ militaryRegistry, rendering, tracking, + motion, queries, }) { const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } = @@ -142,8 +143,14 @@ export function createMilitarySnapshotRenderer({ // Round 5: depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. } else { + // Start where the fleet pass will draw it: the fleet renders behind + // real time, so a fresh contact's displayed position is its fix + // projected back to that delayed time. Creating it at the raw fix drew + // it ahead until the next fleet tick snapped it back. + const displayed = + motion._deadReckon(icao24, new Cesium.Cartesian3()) || position; const bb = flightState._billboardCollection.add({ - position, + position: displayed, image: aircraftIcon(rendering._iconKind(icao24, meta.klass)), width: isTracked ? 24 : 20, height: isTracked ? 24 : 20,