From 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH] feat(flights): publish verticalRateMps on the tracked-aircraft descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapAnalystRecord` has always published `verticalRateMps`, but the tracked seam — `getTrackedInfo()` / `getTrackedSubject()`, built by `_describeFlight` — did not, so anything needing climb/descent had to reach past the public descriptor into the raw poll record. Both layers now map it, which matters because they store it under different keys: the civil records write `verticalRate` (records.js), the military ones write `verticalRateMps`. Mapping only one would leave `resolveTrackedAircraftInfo()` handing back two different shapes, and a consumer reading the field off a civil contact would silently read every military contact as level flight. Reported, never derived. `stickyNumber` seeds the field with null, so an aircraft that has not transmitted a rate publishes null rather than a level-flight 0 — absent is not level, and a flight-phase classifier that cannot tell them apart calls a climbing aircraft cruise. Tests: both layers, both halves (a reported rate arrives with its sign; an absent one stays null). Mutation-verified — removing either field or defaulting it to 0 turns them red. `docs/CURRENT-STATE.md` records the descriptor field, the two record keys it reconciles, and the reported-never-derived rule. --- CHANGELOG.md | 5 ++++ docs/CURRENT-STATE.md | 7 +++++ src/data/flights.test.mjs | 49 ++++++++++++++++++++++++++++++- src/data/militaryFlights.test.mjs | 45 ++++++++++++++++++++++++++++ src/layers/flights/queries.js | 13 ++++++-- src/layers/military/queries.js | 11 +++++-- 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..ae79c5dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,11 @@ Native point, billboard and label highlighting uses GPU draw commands; there is no scene-dimming effect selector. Unsupported shaders retain native contact rendering, and leaving Cyber restores the standard shell and contact treatment. +- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the + civil and military layers, so a consumer reading `getTrackedInfo()` no longer + has to reach past it into the raw poll record for climb/descent. Reported, + never derived: an aircraft that has not transmitted a rate reads as `null` + rather than a level-flight `0`. - Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). Select a box (drag, the current view, or around a pin; up to 1,000 km a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..87db7a7ac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -567,6 +567,13 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +The tracked-aircraft descriptor both flight layers hand to the cockpit carries +`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as +`verticalRate` and military records as `verticalRateMps`; the query seam maps both to +one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The +rate is reported, never derived: a blank poll keeps the last transmitted value and an +absent rate stays null rather than reading as level flight. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 2834e8833..48fb05737 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -487,7 +487,15 @@ const LABEL_ICAO = 'ae1fa4'; const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); /** Seed one civil contact through the production refresh seam. */ -function seedLabelContact({ callsign, registration, tracked = false }) { +function seedLabelContact({ + callsign, + registration, + tracked = false, + // Per-test metadata overrides, spread last so a test can set a field to + // `undefined` to model "the feed never sent it" — a default parameter + // could not express that, it would just re-supply the default. + meta: metaOverrides = {}, +}) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, @@ -509,6 +517,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { turnRateDps: 0, rawLat: 30.21, rawLon: -97.71, + ...metaOverrides, }, }); } @@ -574,6 +583,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration', assert.equal(flightsLayer.getTrackedInfo()?.registration, null); }); +test('the tracked descriptor publishes the reported vertical rate', () => { + // `mapAnalystRecord` has always published `verticalRateMps`; the tracked + // seam did not, so a consumer that needs climb/descent (a flight-phase + // classifier, the cockpit) had to reach past getTrackedInfo() into the + // raw record. Both seams now answer with the same field name and unit. + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: -6.4 }, + }); + assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4); + assert.equal( + mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps, + -6.4, + 'the analyst seam and the tracked seam must agree', + ); +}); + +test('an unreported vertical rate stays null, never a level-flight zero', () => { + // Absent is not level. `stickyNumber` seeds this field with null, so a + // contact that has never transmitted a rate must not read as 0 m/s — a + // phase classifier would call that cruise while the aircraft climbs. + for (const missing of [undefined, null, Number.NaN]) { + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: missing }, + }); + assert.equal( + flightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); + test('civil label chain: identity stays icao24 while the label moves', () => { for (const fixture of [ { callsign: 'SWA696', registration: 'N123AB' }, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 122b6b132..faf9722fc 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -603,3 +603,48 @@ test('two contacts matching at the same strength resolve deterministically', () assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); assert.equal(second, first, 'and the same query resolves the same way every time'); }); + +test('the military tracked descriptor publishes the reported vertical rate', () => { + // Shape parity with the civil layer's getTrackedInfo(). The two layers store + // the rate under different record keys (`verticalRateMps` here, + // `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one + // shape if BOTH descriptors map it — a consumer that reads the field off the + // civil seam and finds it missing on the military one would silently treat + // every military contact as level flight. + const seedRate = (verticalRateMps) => + _setTrackedMilitaryRefreshStateForTest({ + icao24: 'ae7f01', + entity: { gevLabelModel: { title: 'TEST', details: [] } }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), + color: Cesium.Color.WHITE, + show: true, + }, + billboardCollection: { show: true, remove() {} }, + viewer: { camera: { positionCartographic: null }, scene: {} }, + meta: { + callsign: 'RCH512', + rawLat: 31, + rawLon: -97, + altitudeFt: 26_000, + speedMps: 220, + track: 95, + onGround: false, + verticalRateMps, + }, + }); + + seedRate(12.7); + assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7); + + // Absent is not level: a contact that has never transmitted a rate must read + // as null, not as a 0 m/s a phase classifier would call cruise. + for (const missing of [undefined, null, Number.NaN]) { + seedRate(missing); + assert.equal( + militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..423f43788 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -113,7 +113,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -163,6 +163,15 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Climb/descent rate, the one kinematic field `mapAnalystRecord` already + // published (as `verticalRateMps`) but this descriptor did not, so a + // consumer reading the tracked seam had to reach back into `_flightData` + // for it. Reported, never derived: `stickyNumber` in records.js keeps the + // last value across a blank poll, and an absent rate stays null rather + // than becoming a level-flight 0 the aircraft never transmitted. + verticalRateMps: Number.isFinite(info?.verticalRate) + ? info.verticalRate + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -834,7 +843,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..9553cf181 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -86,7 +86,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -129,6 +129,13 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Same field, same name, same null-when-absent rule as the civil + // descriptor — the two layers store the record under different keys + // (`verticalRateMps` here, `verticalRate` there), so only the mapping + // makes `resolveTrackedAircraftInfo` hand back one shape for both. + verticalRateMps: Number.isFinite(info?.verticalRateMps) + ? info.verticalRateMps + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -755,7 +762,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() {