diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index abb047384..6f2544034 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -168,6 +168,77 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a } }); +test('a newly seen aircraft appears at its delayed position, not ahead of it', async () => { + // The 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, which then jumped it back about one delay of travel. + const added = []; + const billboardCollection = { + show: false, + add(options) { + const billboard = { ...options }; + added.push(billboard); + return billboard; + }, + remove() {}, + }; + const viewer = { camera: { positionCartographic: null }, scene: {} }; + _setTrackedFlightRefreshStateForTest({ + icao24: 'seed00', + entity: null, + billboard: { show: false, position: Cesium.Cartesian3.fromDegrees(0, 0, 0) }, + billboardCollection, + viewer, + meta: { rawLat: 0, rawLon: 0, onGround: false }, + tracked: false, + }); + const icao24 = 'fresh1'; + const fixLon = -97.6; + const fixLat = 30.3; + const speedMps = 250; + const nowSec = Math.floor(Date.now() / 1000); + const realFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (!String(url).startsWith('/api/flights')) { + return { ok: true, status: 200, json: async () => ({ ac: [] }) }; + } + return { + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ + time: nowSec, + states: [[ + icao24, 'NEW123 ', 'United States', nowSec - 2, nowSec - 2, + fixLon, fixLat, 10_668, false, speedMps, 90, 0, null, 10_700, + null, null, null, 5, + ]], + }), + }; + }; + try { + await flightsLayer.update(viewer); + const billboard = added.find((entry) => entry.id === icao24); + assert.ok(billboard, 'the new contact gets a billboard'); + const shown = Cesium.Cartographic.fromCartesian(billboard.position); + const fix = Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 10_700); + const behindM = Cesium.Cartesian3.distance( + Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 10_700), + fix, + ); + // Eastbound, so the delayed position is west of the fix by the travel + // between the delayed time and the fix (about 28 s). + assert.ok(Cesium.Math.toDegrees(shown.longitude) < fixLon, 'drawn behind the fix'); + assert.ok( + behindM > speedMps * 25 && behindM < speedMps * 31, + `expected about ${speedMps * 28} m behind the fix, got ${Math.round(behindM)} m`, + ); + } finally { + globalThis.fetch = realFetch; + } +}); + test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STALE', async () => { const icao24 = 'a1b2c3'; const entity = { gevLabelModel: { title: 'OLD', details: [] } }; diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 636e691fd..369fb4af2 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -179,8 +179,14 @@ export function createFlightSnapshotRenderer({ // as the enrichment path — the class's GLB/scale may have changed. if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); } 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, 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,