From 876cd5e4a5c55582fc4ca4e2167ddddbc5aa19c8 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Tue, 20 Oct 2026 03:22:18 -0400 Subject: [PATCH 1/2] fix(flights): bound route labels to the scheduled leg --- docs/CURRENT-STATE.md | 7 ++ scripts/format-scope.json | 1 + src/data/routePlausible.js | 29 +++++- src/data/routeSegment.test.mjs | 174 +++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/data/routeSegment.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..e8345fe34 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2986,6 +2986,13 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > position getter reads only `_trackedDisplayCached`, the same authoritative > Cartesian already consumed by the tracked visual and camera, and never > advances dead reckoning from the host frame. +> - **Scheduled route plausibility:** Aircraft route labels and selected-route +> context reject the distant backwards, forwards and antipodal extensions of +> an airport pair's great circle. The existing 130 km endpoint admission and +> 200 km cross-track tolerance remain; a projected fix outside the finite +> origin-to-destination minor arc also has a generous 200 km extension at +> either end. Missing coordinates and coincident or antipodal endpoint pairs +> retain their loose, unjudgeable behavior; vertical-trend checks are unchanged. > - **Aircraft label convention (both flight layers, 2026-08-18):** every civil > and military label surface resolves **callsign → registration → icao24** > (`_contactLabel()` in `flights.js`; the same chain inline in diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..0afad9e74 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", + "src/data/routeSegment.test.mjs", "src/terrainHeightsBounds.test.mjs" ] diff --git a/src/data/routePlausible.js b/src/data/routePlausible.js index 238c2e6cb..d52b44ef8 100644 --- a/src/data/routePlausible.js +++ b/src/data/routePlausible.js @@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) { return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM; } +/** Signed distance along the great circle from the origin toward the destination. */ +function alongTrackKm(position, origin, destination) { + const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM; + const direction = + bearingRad(origin.lat, origin.lon, position.lat, position.lon) - + bearingRad(origin.lat, origin.lon, destination.lat, destination.lon); + return ( + Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) * + EARTH_RADIUS_KM + ); +} + const NEAR_ENDPOINT_KM = 130; const CROSS_TRACK_KM = 200; const LOW_ALT_M = 3700; // ~12 000 ft @@ -66,6 +78,18 @@ export function routePlausible({ const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM; let geomOk = (haveO && near(origin)) || (haveD && near(destination)); if (!geomOk && haveO && haveD) { + const routeKm = greatCircleKm(origin, destination); + const alongKm = alongTrackKm(position, origin, destination); + // atan2 returns the principal arc in [-πR, πR]. Near an antipodal + // destination, its allowed forward extension can cross that branch cut. + const segmentAlongKm = + alongKm < -CROSS_TRACK_KM + ? alongKm + 2 * Math.PI * EARTH_RADIUS_KM + : alongKm; + // Coincident or antipodal endpoints do not define one minor arc. Keep the + // existing loose check when the segment cannot be judged reliably. + const segmentKnown = + routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001; geomOk = Math.abs( crossTrackKm( @@ -76,7 +100,10 @@ export function routePlausible({ destination.lat, destination.lon, ), - ) < CROSS_TRACK_KM; + ) < CROSS_TRACK_KM && + (!segmentKnown || + (segmentAlongKm >= -CROSS_TRACK_KM && + segmentAlongKm <= routeKm + CROSS_TRACK_KM)); } else if (!geomOk) { geomOk = true; // only one endpoint known and not near — cannot judge, allow } diff --git a/src/data/routeSegment.test.mjs b/src/data/routeSegment.test.mjs new file mode 100644 index 000000000..caa02c4ea --- /dev/null +++ b/src/data/routeSegment.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { routePlausible } from './routePlausible.js'; +import { createTracking } from '../layers/flights/tracking.js'; + +const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' }; +const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' }; +const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 }; +const behind = { lat: 35.818227816411174, lon: 113.83352603632906 }; +const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 }; +const midpoint = { lat: 35.8, lon: -120.4 }; + +const plausible = (position, origin = SFO, destination = LAX) => + routePlausible({ + latDeg: position.lat, + lonDeg: position.lon, + altitudeM: 10000, + verticalRateMps: 0, + origin, + destination, + }); + +// Independent spherical oracle: native unit-vector angular distances, without +// the production bearing/cross-track/along-track calculations. +const unit = ({ lat, lon }) => { + const latitude = Cesium.Math.toRadians(lat); + const longitude = Cesium.Math.toRadians(lon); + return new Cesium.Cartesian3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude), + ); +}; +const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b)); +const excess = (position, origin, destination) => + angle(origin, position) + + angle(position, destination) - + angle(origin, destination); + +test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => { + assert.ok(excess(beyond, SFO, LAX) > 2.9); + assert.equal(plausible(beyond), false); +}); + +test('backwards and antipodal extensions are not the scheduled airport leg', () => { + for (const position of [behind, antipode]) { + assert.ok(excess(position, SFO, LAX) > 3); + assert.equal(plausible(position), false); + } +}); + +test('ordinary route offsets and generous endpoint extensions stay plausible', () => { + assert.ok(excess(midpoint, SFO, LAX) < 0.001); + for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) { + assert.equal(plausible(position), true); + } + // ~167 km past either endpoint is outside the 130 km endpoint circle, + // but remains inside the 200 km coarse-route tolerance. + const origin = { lat: 0, lon: 0 }; + const destination = { lat: 0, lon: 10 }; + assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true); + assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true); +}); + +test('the generous endpoint tolerance still excludes a clearly different leg', () => { + const origin = { lat: 0, lon: 0 }; + const destination = { lat: 0, lon: 10 }; + assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false); + assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false); +}); + +test('minor-arc admission works across the antimeridian and near the pole', () => { + const dateline = [ + { lat: 10, lon: 170 }, + { lat: 10, lon: -170 }, + ]; + assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true); + assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false); + const polar = [ + { lat: 80, lon: -60 }, + { lat: 80, lon: 60 }, + ]; + const top = { lat: 84.96163122670251, lon: 0 }; + assert.ok(excess(top, ...polar) < 1e-8); + assert.equal(plausible(top, ...polar), true); + assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false); +}); + +test('the forward tolerance survives the signed-angle branch cut in both directions', () => { + const origin = { lat: 0, lon: 0 }; + for (const direction of [-1, 1]) { + const destination = { lat: 0, lon: direction * 179.9 }; + const within = { lat: 0, lon: direction * -178.8 }; + const endpointGapKm = angle(destination, within) * 6371.0088; + assert.ok(endpointGapKm > 130 && endpointGapKm < 200); + assert.equal(plausible(within, origin, destination), true); + assert.equal( + plausible({ lat: 0, lon: direction * -178 }, origin, destination), + false, + ); + } +}); + +test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => { + assert.equal( + plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }), + true, + ); + assert.equal( + plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }), + true, + ); + assert.equal(plausible(beyond, SFO, null), true); + assert.equal(plausible(beyond, null, null), true); +}); + +test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => { + const collection = new Cesium.BillboardCollection(); + t.after(() => collection.destroy()); + const flightState = { + records: { data: new Map(), missingPolls: new Map() }, + feed: { _backoff: false }, + _billboards: new Map(), + _scratchCarto: new Cesium.Cartographic(), + }; + // Only application host services are supplied. Route admission, WGS84 + // billboard conversion and tracked-label formatting are the actual owners. + const services = Object.fromEntries( + [ + 'context', + 'trails', + 'geoid', + 'groundFloor', + 'focus', + 'militaryRegistry', + 'readout', + 'camera', + 'picking', + ].map((name) => [name, {}]), + ); + services.aircraftPresentation = { isTr3b: () => false }; + const parts = { queries: { _contactLabel: (_, info) => info.callsign } }; + const tracking = createTracking({ flightState, services, parts }); + for (const [position, expected] of [ + [beyond, false], + [midpoint, true], + ]) { + const id = 'abc001'; + const route = { origin: SFO, destination: LAX }; + flightState.records.data.set(id, { + callsign: 'OWNED001', + altitude: 10000, + verticalRate: 0, + route, + }); + flightState._billboards.set( + id, + collection.add({ + id, + position: Cesium.Cartesian3.fromDegrees( + position.lon, + position.lat, + 10000, + ), + }), + ); + assert.equal( + tracking._trackedLabelText(id).includes('SFO → LAX'), + expected, + ); + assert.equal(tracking._routeIsPlausible(id, route), expected); + } +}); From 140c18c55aa312c88dfdf06e15ab632d985e9f5a Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Tue, 20 Oct 2026 03:35:27 -0400 Subject: [PATCH 2/2] docs: add scheduled route plausibility changelog --- changelog.d/1284-route-plausibility-segment.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1284-route-plausibility-segment.md diff --git a/changelog.d/1284-route-plausibility-segment.md b/changelog.d/1284-route-plausibility-segment.md new file mode 100644 index 000000000..4ab211957 --- /dev/null +++ b/changelog.d/1284-route-plausibility-segment.md @@ -0,0 +1,3 @@ +- Keep aircraft route labels on the scheduled airport leg instead of accepting + its distant great-circle extensions, preserving generous endpoint tolerance + across the signed-angle branch cut (rudycelekli, #1284).