mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(flights): bound route labels to the scheduled leg
This commit is contained in:
1 parent
de2138e84c
commit
876cd5e4a5
4 files changed
+210
-1
No files matched your search
@@ -2986,6 +2986,13 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
|||||||
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
> position getter reads only `_trackedDisplayCached`, the same authoritative
|
||||||
> Cartesian already consumed by the tracked visual and camera, and never
|
> Cartesian already consumed by the tracked visual and camera, and never
|
||||||
> advances dead reckoning from the host frame.
|
> 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
|
> - **Aircraft label convention (both flight layers, 2026-08-18):** every civil
|
||||||
> and military label surface resolves **callsign → registration → icao24**
|
> and military label surface resolves **callsign → registration → icao24**
|
||||||
> (`_contactLabel()` in `flights.js`; the same chain inline in
|
> (`_contactLabel()` in `flights.js`; the same chain inline in
|
||||||
|
|||||||
@@ -1070,5 +1070,6 @@
|
|||||||
"scripts/assemble-changelog.mjs",
|
"scripts/assemble-changelog.mjs",
|
||||||
"src/tooling/changelogFragments.test.mjs",
|
"src/tooling/changelogFragments.test.mjs",
|
||||||
"src/data/gbfsProxyRateLimit.test.mjs",
|
"src/data/gbfsProxyRateLimit.test.mjs",
|
||||||
|
"src/data/routeSegment.test.mjs",
|
||||||
"src/terrainHeightsBounds.test.mjs"
|
"src/terrainHeightsBounds.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -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;
|
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 NEAR_ENDPOINT_KM = 130;
|
||||||
const CROSS_TRACK_KM = 200;
|
const CROSS_TRACK_KM = 200;
|
||||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
|||||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||||
if (!geomOk && haveO && haveD) {
|
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 =
|
geomOk =
|
||||||
Math.abs(
|
Math.abs(
|
||||||
crossTrackKm(
|
crossTrackKm(
|
||||||
@@ -76,7 +100,10 @@ export function routePlausible({
|
|||||||
destination.lat,
|
destination.lat,
|
||||||
destination.lon,
|
destination.lon,
|
||||||
),
|
),
|
||||||
) < CROSS_TRACK_KM;
|
) < CROSS_TRACK_KM &&
|
||||||
|
(!segmentKnown ||
|
||||||
|
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||||
|
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||||
} else if (!geomOk) {
|
} else if (!geomOk) {
|
||||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user