From 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH 1/2] 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() { From d5534c4bda6b538986dc1a69b6a12f8042cb6474 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 16 Oct 2026 08:39:10 +0700 Subject: [PATCH 2/2] docs(changelog): move the verticalRateMps entry to the top of the file On current main the line it was anchored to sits under the released 0.2.0 heading, so the entry landed there. It now leads the unreleased entries. --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1824296bb..4797d2966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- 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`. + - Cyclone advisories no longer go dark while NHC publishes an advisory ahead of its nominal time: advisory and position times up to 90 minutes ahead of the server clock are accepted (previously 5 minutes), so the whole feed is @@ -719,11 +725,6 @@ 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