Merge branch 'main' into sameh/route-corridor

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-10 11:45:29 -08:00
commit d1cd7f63ef
50 files changed
+6493 -363

No files matched your search

+181 -8
View File
@@ -198,6 +198,157 @@ export function staleCoastLimitSeconds({
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
}
/** @constant {number} Dead reckoning never moves a contact faster than this
* (1,500 kt, the trail's continuity limit), so a corrupt reported speed
* cannot fly a coasting plane hundreds of kilometres. */
export const COAST_MAX_SPEED_MPS = (1_500 * 1852) / 3600;
/** @constant {number} A contact with no fresh report coasts at most this long past it, whatever its source limit; there is no distance cap. */
export const STALE_COAST_MAX_SEC = 300;
/** @constant {number} A report this close to a known ground height holds
* instead of coasting. */
export const STALE_HOLD_MAX_HEIGHT_M = 150;
/**
* Whether a contact whose reports have stopped holds at its last reported
* position instead of coasting: it reported on the ground, or a known ground
* floor puts it within STALE_HOLD_MAX_HEIGHT_M of the ground (landing or
* taking off). A plane that low has no evidence of being anywhere but where
* it was last heard. Where the ground is unknown, barometric altitude is not
* read as height above it; the contact coasts under the stale cap.
* @param {object} report
* @param {boolean} [report.onGround]
* @param {number} [report.heightM] - Render height (ellipsoidal metres).
* @param {number} [report.groundM] - Ellipsoidal ground under the report, if known.
* @returns {boolean}
*/
export function staleHoldsAtLastReport({ onGround, heightM, groundM } = {}) {
if (onGround === true) return true;
return (
Number.isFinite(heightM) &&
Number.isFinite(groundM) &&
heightM - groundM < STALE_HOLD_MAX_HEIGHT_M
);
}
/**
* The hold-or-coast decision for a report, made once and kept on the report,
* so a ground floor that resolves while the contact coasts cannot flip it and
* snap the icon back. Height and position come from the report itself.
* @param {{position: Cesium.Cartesian3, staleHolds?: boolean}} fix - History
* fix; `staleHolds` is stored on it.
* @param {object} args
* @param {boolean} [args.onGround] - The contact's ground flag.
* @param {(latDeg: number, lonDeg: number) => number|null} [args.cachedGroundFloor]
* @returns {boolean}
*/
export function staleHoldsForFix(fix, { onGround, cachedGroundFloor } = {}) {
if (typeof fix.staleHolds === 'boolean') return fix.staleHolds;
const carto = Cesium.Cartographic.fromCartesian(
fix.position,
Cesium.Ellipsoid.WGS84,
_holdCarto,
);
const groundM =
carto && typeof cachedGroundFloor === 'function'
? cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
)
: null;
fix.staleHolds = staleHoldsAtLastReport({
onGround,
heightM: carto?.height,
groundM,
});
return fix.staleHolds;
}
const _holdCarto = new Cesium.Cartographic();
/**
* Seconds a contact coasts past `newest` at a display time `elapsedSec`
* after it, counting any coast a synthetic fix already took since the real
* report it was built from (`reportEpochMs`), so a chain of synthetic fixes
* never coasts further than one report would.
* @param {object} args
* @param {{epochMs: number, reportEpochMs?: number, velocity?: number}} args.newest
* @param {number} args.elapsedSec - Display time past `newest`.
* @param {number} args.coastLimitSec - Source limit past `newest`.
* @param {boolean} args.holds
* @returns {number}
*/
export function staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}) {
const alreadySec = Number.isFinite(newest?.reportEpochMs)
? Math.max(0, (newest.epochMs - newest.reportEpochMs) / 1000)
: 0;
return Math.max(
0,
staleCoastSeconds({
elapsedSec: elapsedSec + alreadySec,
coastLimitSec: coastLimitSec + alreadySec,
holds,
}) - alreadySec,
);
}
/** @constant {number} A coasted position sits at least this far (m) above the ground. */
export const COAST_FLOOR_LIFT_M = 1.5;
/**
* Lift a coasted position to COAST_FLOOR_LIFT_M above the ground where the
* ground is known; a coast runs level from the report and terrain may rise
* under it.
* @param {Cesium.Cartesian3} position - Mutated in place.
* @param {(latDeg: number, lonDeg: number) => number|null} [cachedGroundFloor]
* @returns {Cesium.Cartesian3}
*/
export function floorCoastedPosition(position, cachedGroundFloor) {
if (typeof cachedGroundFloor !== 'function') return position;
const carto = Cesium.Cartographic.fromCartesian(
position,
Cesium.Ellipsoid.WGS84,
_coastCarto,
);
if (!carto) return position;
const ground = cachedGroundFloor(
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
);
if (!Number.isFinite(ground) || carto.height >= ground + COAST_FLOOR_LIFT_M)
return position;
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
ground + COAST_FLOOR_LIFT_M,
Cesium.Ellipsoid.WGS84,
position,
);
}
const _coastCarto = new Cesium.Cartographic();
/**
* Seconds to coast past the last report: the elapsed time, bounded by the
* source's coast limit and STALE_COAST_MAX_SEC; none for a contact that
* holds.
* @param {object} args
* @param {number} args.elapsedSec - Display time past the last report.
* @param {number} args.coastLimitSec - From `staleCoastLimitSeconds`.
* @param {boolean} args.holds - From `staleHoldsAtLastReport`.
* @returns {number}
*/
export function staleCoastSeconds({ elapsedSec, coastLimitSec, holds }) {
if (holds || !(elapsedSec > 0)) return 0;
return Math.max(0, Math.min(elapsedSec, coastLimitSec, STALE_COAST_MAX_SEC));
}
/**
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
@@ -446,12 +597,21 @@ const _forwardFixOffset = new Cesium.Cartesian3();
* kinematics. This prevents a late course change from reprojecting the entire
* stale interval and snapping the rendered contact by kilometres.
*
* The same stale rules as the display apply: no fix for a contact that holds
* at its report (`staleHoldsForFix`, decided before calling), a coast of at
* most STALE_COAST_MAX_SEC counted from the real report
* (`reportEpochMs` carries it along a chain), and never below known ground.
* The result is a motion fix only, never a report for the trail.
*
* @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3,
* velocity?: number, track?: number}} newest Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next
* velocity?: number, track?: number, staleHolds?: boolean, reportEpochMs?: number}} newest
* Existing immutable history fix.
* @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number,
* cachedGroundFloor?: Function}} next
* New kinematics and the wall/source epoch at which they become authoritative.
* @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3,
* velocity: number, track: number}|null} Forward synthetic fix, or null.
* velocity: number, track: number, staleHolds?: boolean, reportEpochMs: number}|null}
* Forward synthetic fix, or null.
*/
export function synthesizeForwardKinematicsFix(newest, next = {}) {
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs))
@@ -461,12 +621,18 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
: Cesium.JulianDate.toDate(newest.time).getTime();
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs)
return null;
if (newest.staleHolds === true) return null;
const previousVelocity = Number.isFinite(newest.velocity)
? Math.max(0, newest.velocity)
: 0;
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
const dtSec = (next.epochMs - startEpochMs) / 1000;
const dtSec = staleCoastSecondsFrom({
newest: { epochMs: startEpochMs, reportEpochMs: newest.reportEpochMs },
elapsedSec: (next.epochMs - startEpochMs) / 1000,
coastLimitSec: Infinity,
holds: false,
});
arcOffsetEnu(
previousVelocity,
previousTrack,
@@ -485,14 +651,21 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) {
Cesium.Ellipsoid.WGS84,
_forwardFixEnu,
);
const position = Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
const position = floorCoastedPosition(
Cesium.Matrix4.multiplyByPoint(
enu,
_forwardFixOffset,
new Cesium.Cartesian3(),
),
next.cachedGroundFloor,
);
return {
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
epochMs: next.epochMs,
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: startEpochMs,
staleHolds: newest.staleHolds,
position,
velocity: Number.isFinite(next.velocity)
? Math.max(0, next.velocity)
+4 -1
View File
@@ -140,7 +140,10 @@ test('the actual native tracked label hides the wrong leg and keeps a healthy ro
].map((name) => [name, {}]),
);
services.aircraftPresentation = { isTr3b: () => false };
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
const parts = {
queries: { _contactLabel: (_, info) => info.callsign },
motion: { _signalLost: () => null },
};
const tracking = createTracking({ flightState, services, parts });
for (const [position, expected] of [
[beyond, false],
+607
View File
@@ -0,0 +1,607 @@
/**
* @module trailContinuity
* @description Where a tracked aircraft's trail lifts the pen.
*
* A trail is a chronological list of fixes `{position, timeMs, nullAltitude,
* breakBefore, drawn}`: reports, and (`drawn`) points where the tracked icon
* was drawn between them. Between two consecutive fixes that are not one path, a new
* segment starts and nothing is drawn between them: no dashes, no arcs.
* Nothing is dropped except the oldest fixes past the layer's point limit.
*
* Two points are not one path when time does not advance, when the gap is
* longer than 48 minutes (a coverage hole or a separate flight), when the
* implied great-circle speed is over 1,500 kt, or when either point has no
* reported altitude. A parked aircraft is the exception: a pair under three
* hours apart within 0.1 nm stays one path.
*
* Civil and military flight tracking share these rules.
*/
import * as Cesium from 'cesium';
/** @constant {number} A gap longer than this is a coverage hole or a separate flight. */
export const TRAIL_CONTINUITY_MAX_GAP_MS = 48 * 60_000;
/** @constant {number} A pair implying more than this great-circle speed is not one path. */
export const TRAIL_CONTINUITY_MAX_SPEED_KT = 1_500;
/** @constant {number} A parked aircraft may stop reporting this long and stay one path. */
export const TRAIL_STATIONARY_HOLD_MAX_GAP_MS = 3 * 60 * 60_000;
/** @constant {number} How close (nautical miles) two points must be to count as parked. */
export const TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM = 0.1;
/** @constant {number} The head never joins a body point farther than this from the aircraft. */
export const TRAIL_HEAD_JOIN_MAX_M = 150_000;
const METRES_PER_NM = 1852;
/** @constant {number} Mean Earth radius (m) for great-circle trail distances. */
const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/**
* Whether two consecutive trail points belong to different paths. Time that
* does not advance, a gap over TRAIL_CONTINUITY_MAX_GAP_MS or an implied
* speed over TRAIL_CONTINUITY_MAX_SPEED_KT breaks the line, except for a
* parked aircraft: a pair under three hours apart within 0.1 nm.
* @param {number} deltaMs - Later point's time minus the earlier one's.
* @param {number} distanceM - Great-circle distance between the points.
* @returns {boolean}
*/
export function trailPairBreaks(deltaMs, distanceM) {
if (!(deltaMs > 0) || !Number.isFinite(distanceM)) return true;
const distanceNm = distanceM / METRES_PER_NM;
if (
deltaMs <= TRAIL_STATIONARY_HOLD_MAX_GAP_MS &&
distanceNm <= TRAIL_STATIONARY_HOLD_MAX_DISTANCE_NM
)
return false;
return (
deltaMs > TRAIL_CONTINUITY_MAX_GAP_MS ||
distanceNm / (deltaMs / 3_600_000) > TRAIL_CONTINUITY_MAX_SPEED_KT
);
}
/**
* Whether the head segment may join the last body point to the aircraft:
* within TRAIL_HEAD_JOIN_MAX_M and not broken under `trailPairBreaks`.
* @param {number} deltaMs - Current time minus the body point's time.
* @param {number} distanceM - Distance from the body point to the aircraft.
* @returns {boolean}
*/
export function trailHeadJoins(deltaMs, distanceM) {
return (
distanceM <= TRAIL_HEAD_JOIN_MAX_M && !trailPairBreaks(deltaMs, distanceM)
);
}
/**
* Great-circle distance between two trail positions, in metres.
* @param {Cesium.Cartesian3} a
* @param {Cesium.Cartesian3} b
* @returns {number}
*/
export function trailDistanceM(a, b) {
return Cesium.Cartesian3.angleBetween(a, b) * EARTH_MEAN_RADIUS_M;
}
/**
* Whether the pen lifts between two consecutive fixes.
* @param {object|undefined} prev - Earlier fix, if any.
* @param {object} next - Later fix.
* @returns {boolean}
*/
export function trailFixBreaks(prev, next) {
if (!prev || prev.nullAltitude || next.nullAltitude) return true;
// A recorded point is where the icon was drawn; the speed between it and a
// report it was eased onto is not a flight speed, so only the gap counts.
if (prev.drawn || next.drawn)
return Math.abs(next.timeMs - prev.timeMs) > TRAIL_CONTINUITY_MAX_GAP_MS;
return trailPairBreaks(
next.timeMs - prev.timeMs,
trailDistanceM(prev.position, next.position),
);
}
/**
* Build backfill fixes from chronological waypoints. Every waypoint with a
* position is kept. A waypoint without one (no altitude and no known ground)
* is skipped, and the line still breaks there.
* @param {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean}[]} waypoints
* @returns {{fixes: object[], breakAfter: boolean}} The fixes, and whether the
* line breaks after the last one because trailing waypoints were skipped.
*/
export function trailBackfillFixes(waypoints) {
const fixes = [];
let skipped = false;
for (const { position, timeMs, nullAltitude } of waypoints) {
if (!position) {
skipped = true;
continue;
}
const fix = { position, timeMs, nullAltitude: !!nullAltitude };
fix.breakBefore = skipped || trailFixBreaks(fixes[fixes.length - 1], fix);
skipped = false;
fixes.push(fix);
}
return { fixes, breakAfter: skipped };
}
/**
* Put backfill fixes ahead of the live trail. The join follows the same
* rule, so a distant history tail never connects to the first live fix.
* Past `maxPoints` the oldest recorded points go first, then the oldest
* reports.
* @param {{fixes: object[], breakAfter: boolean}} backfill
* @param {object[]} live - Live fixes; the first one's break is recomputed.
* @param {number} maxPoints - Point limit across all segments.
* @returns {object[]} The joined trail.
*/
export function joinTrailBackfill({ fixes, breakAfter }, live, maxPoints) {
if (!fixes.length) return live;
if (live.length)
live[0].breakBefore =
breakAfter || trailFixBreaks(fixes[fixes.length - 1], live[0]);
return keepTrailLimit(fixes.concat(live), maxPoints);
}
/**
* How many fixes the drawn body holds: every fix at or before the display
* time, where the aircraft is drawn. Fixes ahead of it (reports newer than
* the delayed display) are reached by the head segment instead, so the trail
* never runs past the aircraft, and once the display passes the newest
* report the body ends there.
* @param {object[]} fixes
* @param {number} displayMs - Time the aircraft is drawn at.
* @returns {number}
*/
export function trailBodyCount(fixes, displayMs) {
let count = fixes.length;
while (count > 0 && fixes[count - 1].timeMs > displayMs) count -= 1;
return count;
}
/**
* Leave out the newest recorded points that lie inside the aircraft's 3D
* model, so the body never runs through the fuselage; the head then runs
* from the newest point outside the model to the plane. Reports are kept.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @param {Cesium.Cartesian3|null} modelCenter - The model's centre, or null
* when no model is the visual.
* @param {number|null} envelopeM - The model's radius (m).
* @returns {number}
*/
export function trailBodyCountOutsideModel(
fixes,
count,
modelCenter,
envelopeM,
) {
if (!modelCenter || !(envelopeM > 0)) return count;
while (
count > 0 &&
fixes[count - 1].drawn &&
Cesium.Cartesian3.distance(fixes[count - 1].position, modelCenter) <
envelopeM
)
count -= 1;
return count;
}
/**
* The drawn trail body, split at breaks: the first `count` fixes.
* @param {object[]} fixes
* @param {number} count - From `trailBodyCount`.
* @returns {Cesium.Cartesian3[][]}
*/
export function trailBodySegments(fixes, count) {
const segments = [];
for (let i = 0; i < count; i++) {
const fix = fixes[i];
// A live report with no altitude keeps no position; it only breaks the line.
if (!fix.position) continue;
if (fix.breakBefore || !segments.length) segments.push([]);
segments[segments.length - 1].push(fix.position);
}
return segments;
}
/**
* Draw body segments. A trail without breaks support draws only the newest
* segment, never a line across a break.
* @param {{setSegments?: Function, setPositions: Function}} trail
* @param {Cesium.Cartesian3[][]} segments
*/
export function drawTrailSegments(trail, segments) {
if (typeof trail.setSegments === 'function') trail.setSegments(segments);
else trail.setPositions(segments[segments.length - 1] ?? []);
}
/** @constant {number} A head shorter than this (m) is not drawn: the aircraft is holding at its last report. */
const TRAIL_HEAD_MIN_M = 0.5;
/**
* The body fix the head segment starts from, or null when the head is not
* drawn: no body yet, the aircraft holding at that fix, a body point more
* than 150 km from the aircraft, or one too long ago or too far away to be
* one path.
* @param {object[]} fixes
* @param {Cesium.Cartesian3} head - Displayed aircraft position.
* @param {number} nowMs - Current time.
* @param {number} count - Body fixes, from `trailBodyCount`.
* @returns {object|null}
*/
export function trailHeadStartFix(fixes, head, nowMs, count) {
if (count < 1 || !head) return null;
const last = fixes[count - 1];
if (!last.position) return null;
if (Cesium.Cartesian3.distance(last.position, head) < TRAIL_HEAD_MIN_M)
return null;
return trailHeadJoins(
nowMs - last.timeMs,
trailDistanceM(last.position, head),
)
? last
: null;
}
/**
* Epoch ms of a position-history fix (`{epochMs?, time}`).
* @param {object|undefined} fix
* @returns {number} NaN when unknown.
*/
export function historyFixEpochMs(fix) {
if (Number.isFinite(fix?.epochMs)) return fix.epochMs;
return fix?.time ? Cesium.JulianDate.toDate(fix.time).getTime() : NaN;
}
/** @constant {number} The tracked icon's drawn position is recorded at least this often (ms of display time)... */
export const TRAIL_RECORD_STEP_MS = 1_000;
/** @constant {number} ...or whenever it has moved this far (m), whichever comes first. */
export const TRAIL_RECORD_STEP_M = 150;
/** @constant {number} A recorded point is kept only where dropping it would move the line by more than this (m) across the track or in height. */
export const TRAIL_THIN_TOLERANCE_M = 30;
/** @constant {number} A recorded point is kept where dropping it would leave a straight piece longer than this (m), so a long leg still has vertices. */
export const TRAIL_THIN_MAX_SPAN_M = 10_000;
/** @constant {number} A kept point stands in for at most this many dropped positions; past it the point is kept. */
const TRAIL_THIN_MAX_ABSORBED = 512;
/** @constant {number} A recorded point is dropped where the line turns back sharper than this (degrees)... */
export const TRAIL_SPUR_TURN_DEG = 120;
/** @constant {number} ...over a step shorter than this (m). */
export const TRAIL_SPUR_MAX_STEP_M = 500;
/** @constant {number} A recorded point closer than this (m) to the previous one is not kept: the aircraft is holding. */
const TRAIL_RECORD_MIN_STEP_M = 1;
const _u = new Cesium.Cartesian3();
const _v = new Cesium.Cartesian3();
/** Cosine of the turn at `b` on the way a → b → c, or NaN for a zero step. */
function turnCos(a, b, c) {
Cesium.Cartesian3.subtract(b, a, _u);
Cesium.Cartesian3.subtract(c, b, _v);
const lu = Cesium.Cartesian3.magnitude(_u);
const lv = Cesium.Cartesian3.magnitude(_v);
if (!(lu > 0 && lv > 0)) return NaN;
return Cesium.Cartesian3.dot(_u, _v) / (lu * lv);
}
const SPUR_COS = Math.cos(Cesium.Math.toRadians(TRAIL_SPUR_TURN_DEG));
/** Whether entry `i` and its neighbours are one drawn piece of line. */
function joinedAt(fixes, i) {
const a = fixes[i - 1];
const b = fixes[i];
const c = fixes[i + 1];
return (
a?.position &&
b?.position &&
c?.position &&
!b.breakBefore &&
!c.breakBefore
);
}
/** Whether the line turns back on itself at entry `i` over a short step. */
function spurAt(fixes, i) {
if (!joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const b = fixes[i].position;
const c = fixes[i + 1].position;
if (
Math.min(
Cesium.Cartesian3.distance(a, b),
Cesium.Cartesian3.distance(b, c),
) > TRAIL_SPUR_MAX_STEP_M
)
return false;
return turnCos(a, b, c) < SPUR_COS;
}
/** Remove entry `i` and re-judge the break before the entry that takes its place. */
function removeAt(fixes, i) {
fixes.splice(i, 1);
if (i < fixes.length)
fixes[i].breakBefore = trailFixBreaks(fixes[i - 1], fixes[i]);
}
/**
* Keep the point limit: drop the oldest recorded point first, and the oldest
* report only when no recorded point is left.
* @param {object[]} fixes - The trail, mutated.
* @param {number} maxPoints
* @returns {object[]} `fixes`.
*/
function keepTrailLimit(fixes, maxPoints) {
let excess = fixes.length - maxPoints;
if (excess <= 0) return fixes;
for (let i = 0; i < fixes.length && excess > 0;) {
if (fixes[i].drawn) {
removeAt(fixes, i);
excess -= 1;
} else i += 1;
}
if (excess > 0) fixes.splice(0, excess);
return fixes;
}
const _ab = new Cesium.Cartesian3();
const _ap = new Cesium.Cartesian3();
const _off = new Cesium.Cartesian3();
const _up = new Cesium.Cartesian3();
/** Whether `p` lies within the thinning tolerance of segment a → b, across the track and in height. */
function nearSegment(p, a, b) {
Cesium.Cartesian3.subtract(b, a, _ab);
Cesium.Cartesian3.subtract(p, a, _ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(_ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(_ap, _ab) / len2))
: 0;
Cesium.Cartesian3.multiplyByScalar(_ab, t, _off);
Cesium.Cartesian3.subtract(_ap, _off, _off);
Cesium.Cartesian3.normalize(p, _up);
const vertical = Cesium.Cartesian3.dot(_off, _up);
const across = Math.sqrt(
Math.max(0, Cesium.Cartesian3.magnitudeSquared(_off) - vertical * vertical),
);
return (
Math.abs(vertical) <= TRAIL_THIN_TOLERANCE_M &&
across <= TRAIL_THIN_TOLERANCE_M
);
}
/**
* Drop recorded point `i` when the line without it stays within the
* tolerance of every position it stood for, and the straight piece left is
* no longer than TRAIL_THIN_MAX_SPAN_M. The positions it stood for go on to
* the next entry. Reports are never dropped.
* @returns {boolean} Whether it was dropped.
*/
function thinAt(fixes, i) {
const entry = fixes[i];
if (!entry?.drawn || entry.sealed || !joinedAt(fixes, i)) return false;
const a = fixes[i - 1].position;
const next = fixes[i + 1];
if (next.sealed) return false;
const b = next.position;
if (Cesium.Cartesian3.distance(a, b) > TRAIL_THIN_MAX_SPAN_M) return false;
const stood = entry.absorbed ?? [];
const nextStood = next.absorbed ?? [];
if (stood.length + nextStood.length + 1 > TRAIL_THIN_MAX_ABSORBED)
return false;
if (!nearSegment(entry.position, a, b)) return false;
for (const p of stood) if (!nearSegment(p.position, a, b)) return false;
for (const p of nextStood) if (!nearSegment(p.position, a, b)) return false;
next.absorbed = stood.concat(
[{ position: entry.position, timeMs: entry.timeMs }],
nextStood,
);
removeAt(fixes, i);
return true;
}
/**
* Thin the recorded points either side of entry `i`, reaching past a
* neighbouring report: the point recorded just before a report is only
* judged once the line goes on beyond it.
*/
function thinAround(fixes, i) {
if (!thinAt(fixes, i + 1) && fixes[i + 1] && !fixes[i + 1].drawn)
thinAt(fixes, i + 2);
if (thinAt(fixes, i - 1)) i -= 1;
else if (fixes[i - 1] && !fixes[i - 1].drawn) thinAt(fixes, i - 2);
}
/**
* Drop recorded points that make a spur next to entry `i`. Reports are never
* dropped; at a report the recorded neighbour is dropped instead.
* @returns {number} The index entry `i` ends up at.
*/
function removeSpursAround(fixes, i) {
for (let guard = 0; guard < 64; guard++) {
let changed = false;
for (const j of [i - 1, i, i + 1]) {
if (!spurAt(fixes, j)) continue;
let drop = -1;
if (fixes[j].drawn) drop = j;
else if (fixes[j + 1]?.drawn) drop = j + 1;
else if (fixes[j - 1]?.drawn) drop = j - 1;
if (drop < 0) continue;
removeAt(fixes, drop);
if (drop < i) i -= 1;
changed = true;
break;
}
if (!changed) break;
}
return i;
}
/** Index a fix at `timeMs` goes in at: after every entry at or before it. */
function insertionIndex(fixes, timeMs) {
let i = fixes.length;
while (i > 0 && fixes[i - 1].timeMs > timeMs) i -= 1;
return i;
}
/**
* Insert an entry at `i`, judge both breaks, remove spurs, thin the recorded
* points either side and keep the limit.
*/
function insertAt(fixes, i, entry, maxPoints) {
fixes.splice(i, 0, entry);
entry.breakBefore = trailFixBreaks(fixes[i - 1], entry);
const after = fixes[i + 1];
if (after) {
after.breakBefore = trailFixBreaks(entry, after);
// Positions the next entry stood for that come before this one are now
// on this entry's side.
if (after.absorbed?.length) {
const mine = after.absorbed.filter((p) => p.timeMs < entry.timeMs);
if (mine.length) {
entry.absorbed = mine;
after.absorbed = after.absorbed.filter((p) => p.timeMs >= entry.timeMs);
}
}
}
i = removeSpursAround(fixes, i);
thinAround(fixes, i);
keepTrailLimit(fixes, maxPoints);
}
/** Index of the newest report before entry `i`, or -1. */
function reportBefore(fixes, i) {
let j = i - 1;
while (j >= 0 && fixes[j].drawn) j -= 1;
return j;
}
/** Index of the oldest report at or after `i`, or `fixes.length`. */
function reportFrom(fixes, i) {
let j = i;
while (j < fixes.length && fixes[j].drawn) j += 1;
return j;
}
/**
* Put a report into the trail in time order. Reports are the record: they
* are kept as anchors among the recorded points.
*
* - A report the display has already passed replaces the guess drawn after
* it: the recorded points timed after it go, and nothing is recorded until
* the icon has eased onto the correction (`displayMs` + `settleMs`), so the
* line reads arc, report, corrected path.
* - Recorded points never join two reports that break under
* `trailPairBreaks` (too long or too fast apart to be one flight): the
* points between them go and the line breaks.
* - A live report with no altitude keeps no position and only breaks the
* line.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned position.
* @param {number} timeMs - The report's epoch ms.
* @param {number} maxPoints - Point limit across all segments.
* @param {{nullAltitude?: boolean, displayMs?: number, settleMs?: number}} [options]
* `displayMs`: the time the aircraft is drawn at as the report arrives;
* `settleMs`: how long the icon takes to ease onto a correction.
* @returns {object[]} `fixes`.
*/
export function insertTrailFix(
fixes,
position,
timeMs,
maxPoints,
{ nullAltitude = false, displayMs = NaN, settleMs = 0 } = {},
) {
const entry = {
position: nullAltitude ? null : position,
timeMs,
nullAltitude,
drawn: false,
breakBefore: false,
};
let i = insertionIndex(fixes, timeMs);
// The guess drawn after a report the display already passed.
fixes.splice(i, reportFrom(fixes, i) - i);
if (displayMs > timeMs) entry.settleUntilMs = displayMs + settleMs;
// Recorded points across a pair of reports that are not one flight.
const before = reportBefore(fixes, i);
if (before >= 0 && trailFixBreaks(fixes[before], entry)) {
fixes.splice(before + 1, i - before - 1);
i = before + 1;
}
insertAt(fixes, i, entry, maxPoints);
return fixes;
}
/**
* Record where the tracked aircraft was drawn at a display time. Between
* reports this recorded path is the trail, so the line follows every turn
* the icon made instead of a straight chord between two reports. Points are
* thinned by shape: one stays only where dropping it would move the line by
* more than TRAIL_THIN_TOLERANCE_M across the track or in height, or leave a
* straight piece longer than TRAIL_THIN_MAX_SPAN_M. Nothing is recorded
* while the aircraft holds.
* @param {object[]} fixes - The trail, mutated.
* @param {Cesium.Cartesian3} position - Owned drawn position.
* @param {number} timeMs - Display time it was drawn for.
* @param {number} maxPoints - Point limit across all segments.
* @returns {boolean} Whether the trail changed.
*/
export function recordTrailPoint(fixes, position, timeMs, maxPoints) {
const i = insertionIndex(fixes, timeMs);
const prev = fixes[i - 1];
if (
prev?.position &&
Cesium.Cartesian3.distance(prev.position, position) <
TRAIL_RECORD_MIN_STEP_M
)
return false;
const entry = {
position,
timeMs,
nullAltitude: false,
drawn: true,
breakBefore: false,
};
insertAt(fixes, i, entry, maxPoints);
// Entries before the second report behind this point can never be thinned
// again, so they let go of the positions they stood for.
const passed = reportBefore(fixes, fixes.lastIndexOf(entry));
if (passed >= 0) sealThrough(fixes, reportBefore(fixes, passed));
return true;
}
/** Release the absorbed positions of entry `i` and everything before it. */
function sealThrough(fixes, i) {
for (let j = i; j >= 0 && !fixes[j].sealed; j--) {
fixes[j].sealed = true;
delete fixes[j].absorbed;
}
}
/**
* Whether the icon's drawn position must not be recorded at a display time:
* a report on either side of it has no altitude (the icon is drawn at an
* invented height), the reports on either side are not one flight
* (`trailPairBreaks`), or the icon is still easing onto a report that
* arrived after the display had passed it.
* @param {object[]} fixes
* @param {number} displayMs
* @returns {boolean}
*/
export function trailRecordingBlocked(fixes, displayMs) {
const i = insertionIndex(fixes, displayMs);
const before = fixes[reportBefore(fixes, i)];
const after = fixes[reportFrom(fixes, i)];
if (before?.nullAltitude || after?.nullAltitude) return true;
if (before?.settleUntilMs > displayMs) return true;
return !!(before && after && trailFixBreaks(before, after));
}
+269 -58
View File
@@ -14,6 +14,18 @@
* the whole history renders at one readable alpha (dimmer where it passes
* behind/below geometry).
*
* A trail with breaks draws each segment separately and nothing between
* them. Every segment is cut into chunks of up to CHUNK_POINTS points;
* adjacent chunks share their boundary point, so the line has no seam. A
* chunk that can no longer change (every chunk except the newest one of the
* newest segment) is FROZEN: an entity with constant positions, built once as
* static geometry, so it costs nothing per frame. Only the newest chunk stays
* dynamic. A freshly frozen chunk is also drawn dynamically until its static
* geometry is in the scene, so freezing never shows a blank frame. A chunk is
* identified by its position objects, so a chunk survives every update that
* keeps those points: appends, a backfill prepended ahead of it, or the
* oldest points dropping off the front (which rebuilds only the first chunk).
*
* Trails update at poll cadence (~15-60 s) plus once on history backfill, so
* assigning a fresh positions array per update is cheap. `allowPicking` has
* no entity equivalent; the polyline entity is excluded from clicks by never
@@ -42,6 +54,13 @@ const TRAIL_ALPHA = 0.85;
const TRAIL_OCCLUDED_ALPHA = 0.4;
/** @constant {number} Squared distance (m^2) below which consecutive points are merged. */
const MIN_SEGMENT_DISTANCE_SQ = 0.01;
/** @constant {Cesium.Cartesian3[]} What a spare dynamic polyline draws. */
const NO_POSITIONS = Object.freeze([]);
/** @constant {number} Points per chunk; adjacent chunks share one point. */
export const CHUNK_POINTS = 128;
/** @constant {number} Visible frames a frozen chunk's stand-in waits for its
* static geometry before it is dropped anyway. */
const STAND_IN_MAX_FRAMES = 600;
/**
* Create an always-visible polyline trail bound to a viewer.
@@ -49,93 +68,285 @@ const MIN_SEGMENT_DISTANCE_SQ = 0.01;
* @param {object} options - Trail options.
* @param {string} options.color - CSS color string for the trail hue.
* @param {number} [options.width=2.5] - Polyline width in pixels.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry, setVisible temporarily
* hides it without discarding history, clear empties it, and destroy removes
* the entity permanently.
* @returns {{setPositions: function(Cesium.Cartesian3[]): void, setSegments: function(Cesium.Cartesian3[][]): void, setVisible: function(boolean): void, clear: function(): void, destroy: function(): void}}
* Trail handle: setPositions replaces the geometry with one line,
* setSegments with one line per segment, setVisible temporarily hides it
* without discarding history, clear empties it, and destroy removes the
* entities permanently.
*/
export function createTrail(viewer, { color, width = 2.5 }) {
const baseColor = Cesium.Color.fromCssColorString(color);
/** @type {Cesium.Cartesian3[]} Current deduped positions (owned copy). */
let current = [];
const material = baseColor.withAlpha(TRAIL_ALPHA);
const depthFailMaterial = baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA);
let destroyed = false;
let visible = true;
/** @type {Cesium.Entity|null} */
let entity = null;
/** @type {{positions: Cesium.Cartesian3[], entity: Cesium.Entity, standIn: boolean, frames: number}[]} */
let frozen = [];
/** Newest chunk of the newest segment: the only part that changes. */
let tail = NO_POSITIONS;
/** What the dynamic polylines draw: the tail, then frozen chunks whose
* static geometry is not in the scene yet. */
let dynamicRuns = [];
/** @type {Cesium.Entity[]} Pooled dynamic polylines; spares draw nothing. */
const dynamicEntities = [];
let removeReadyListener = null;
const scratchSphere = new Cesium.BoundingSphere();
function ensureEntity() {
if (entity || destroyed || !viewer || viewer.isDestroyed()) return;
entity = viewer.entities.add({
const usable = () => !destroyed && viewer && !viewer.isDestroyed();
function polyline(positions) {
return {
positions,
width,
material,
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial,
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
};
}
function addEntity(positions) {
return viewer.entities.add({
id: `gev-trail:${++_trailSeq}`,
show: visible,
polyline: {
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
positions: new Cesium.CallbackProperty(() => current, false),
width,
material: baseColor.withAlpha(TRAIL_ALPHA),
// The owner-locked rule (round 6): a segment below the photoreal
// mesh renders dimmed — it must never disappear into the ground.
depthFailMaterial: baseColor.withAlpha(TRAIL_OCCLUDED_ALPHA),
// Round 8 (owner: UAL1104's Pacific trail "cutting through the
// globe"): NONE draws straight 3D chords between waypoints — over a
// sparse trans-oceanic trace a single segment spans hundreds of km
// and tunnels through the planet. GEODESIC subdivides each segment
// along the curved surface (heights interpolated), so long legs hug
// the globe instead of chording through it.
arcType: Cesium.ArcType.GEODESIC,
},
polyline: polyline(positions),
});
}
function removeEntity(entity) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
}
function syncDynamic() {
dynamicRuns = [];
if (tail.length >= 2) dynamicRuns.push(tail);
for (const chunk of frozen)
if (chunk.standIn) dynamicRuns.push(chunk.positions);
while (dynamicEntities.length < Math.max(1, dynamicRuns.length)) {
const index = dynamicEntities.length;
dynamicEntities.push(
addEntity(
// CallbackProperty so a positions swap never rebuilds the entity —
// Cesium re-reads on change; `false` marks it non-constant.
new Cesium.CallbackProperty(
() => dynamicRuns[index] ?? NO_POSITIONS,
false,
),
),
);
}
}
/** Whether a frozen chunk's static geometry is drawn in the scene. */
function staticReady(chunk) {
const display = viewer.dataSourceDisplay;
if (typeof display?.getBoundingSphere !== 'function') return true;
if (!visible) return false;
chunk.frames += 1;
if (chunk.frames > STAND_IN_MAX_FRAMES) return true;
try {
return (
display.getBoundingSphere(chunk.entity, false, scratchSphere) ===
Cesium.BoundingSphereState.DONE
);
} catch {
return false;
}
}
/** Drop stand-ins whose static geometry is in; stop listening when none are left. */
function resolveStandIns() {
if (!usable()) return;
let changed = false;
for (const chunk of frozen) {
if (chunk.standIn && staticReady(chunk)) {
chunk.standIn = false;
changed = true;
}
}
if (changed) syncDynamic();
if (!frozen.some((chunk) => chunk.standIn)) stopListening();
}
function stopListening() {
if (removeReadyListener) removeReadyListener();
removeReadyListener = null;
}
function listenForStandIns() {
if (removeReadyListener) return;
const event = viewer.scene?.postRender;
if (typeof event?.addEventListener !== 'function') {
resolveStandIns();
return;
}
removeReadyListener = event.addEventListener(resolveStandIns);
}
/** Drop consecutive near-duplicates: zero-length segments add nothing. */
function dedupe(cartesians) {
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
return positions;
}
/** Whether `chunk` holds exactly the same position objects as `seg` from `start`. */
function sameRun(chunk, seg, start) {
const positions = chunk.positions;
if (start + positions.length > seg.length) return false;
for (let k = 0; k < positions.length; k++)
if (positions[k] !== seg[start + k]) return false;
return true;
}
/**
* Cut segments into chunks, reusing every frozen chunk whose points are
* still drawn in the same order. New chunks end where a reusable one begins,
* so dropping the oldest point rebuilds only the first chunk.
*/
function reconcile(segments) {
const byStart = new Map();
for (const chunk of frozen) byStart.set(chunk.positions[0], chunk);
const kept = new Set();
const reusable = (seg, j) => {
const chunk = byStart.get(seg[j]);
return chunk && !kept.has(chunk) && sameRun(chunk, seg, j) ? chunk : null;
};
const next = [];
let nextTail = NO_POSITIONS;
segments.forEach((seg, s) => {
const newest = s === segments.length - 1;
let i = 0;
while (i < seg.length - 1) {
const existing = reusable(seg, i);
if (existing) {
kept.add(existing);
next.push(existing);
i += existing.positions.length - 1;
continue;
}
if (newest && seg.length - i <= CHUNK_POINTS) {
nextTail = seg.slice(i);
break;
}
let end = Math.min(seg.length - 1, i + CHUNK_POINTS - 1);
for (let j = i + 1; j < end; j++) {
if (reusable(seg, j)) {
end = j;
break;
}
}
const positions = seg.slice(i, end + 1);
next.push({
positions,
entity: addEntity(positions),
standIn: true,
frames: 0,
});
i = end;
}
});
for (const chunk of frozen)
if (!kept.has(chunk)) removeEntity(chunk.entity);
frozen = next;
tail = nextTail;
syncDynamic();
if (frozen.some((chunk) => chunk.standIn)) listenForStandIns();
else stopListening();
}
function release() {
stopListening();
for (const chunk of frozen) removeEntity(chunk.entity);
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
}
return {
/**
* Replace the trail geometry with a chronological position list
* Replace the trail geometry with one chronological position list
* (oldest first). Fewer than 2 distinct positions clears the trail.
* @param {Cesium.Cartesian3[]} cartesians - Positions, oldest -> newest.
*/
setPositions(cartesians) {
if (destroyed || !viewer || viewer.isDestroyed()) return;
// Drop consecutive near-duplicates: zero-length segments add nothing.
const positions = [];
for (const position of Array.isArray(cartesians) ? cartesians : []) {
if (!position) continue;
const last = positions[positions.length - 1];
if (
last &&
Cesium.Cartesian3.distanceSquared(last, position) <
MIN_SEGMENT_DISTANCE_SQ
)
continue;
positions.push(position);
}
current = positions.length >= 2 ? positions : [];
ensureEntity();
this.setSegments([cartesians]);
},
/**
* Replace the trail geometry with chronological segments (oldest first).
* Nothing is drawn between segments. A segment with fewer than 2 distinct
* positions draws nothing. Pass the same position objects on every
* update: a chunk whose points are unchanged is never rebuilt.
* @param {Cesium.Cartesian3[][]} list - Segments, each oldest -> newest.
*/
setSegments(list) {
if (!usable()) return;
reconcile(
(Array.isArray(list) ? list : [])
.map(dedupe)
.filter((positions) => positions.length >= 2),
);
},
/** Temporarily hide/show the trail without discarding accumulated history. */
setVisible(nextVisible) {
visible = nextVisible !== false;
if (entity) entity.show = visible;
for (const entity of dynamicEntities) entity.show = visible;
for (const chunk of frozen) chunk.entity.show = visible;
},
/** Empty the trail without removing the entity (cheap re-arm). */
/** Empty the trail and release its frozen geometry; dynamic polylines stay for re-arm. */
clear() {
current = [];
if (!usable()) return;
release();
},
/** Remove the trail entity permanently (layer disable/teardown). */
/** Remove every trail entity permanently (layer disable/teardown). */
destroy() {
destroyed = true;
current = [];
if (entity && viewer && !viewer.isDestroyed()) {
try {
viewer.entities.remove(entity);
} catch {
/* torn down */
}
if (usable()) {
release();
for (const entity of dynamicEntities) removeEntity(entity);
}
entity = null;
destroyed = true;
frozen = [];
tail = NO_POSITIONS;
dynamicRuns = [];
dynamicEntities.length = 0;
stopListening();
},
/** Test seam: frozen chunks (positions, stand-in state) and the dynamic runs. */
_chunksForTest() {
return {
frozen: frozen.map((chunk) => ({
positions: chunk.positions,
standIn: chunk.standIn,
entity: chunk.entity,
})),
dynamic: dynamicRuns.slice(),
};
},
};
}
+251 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createTrail } from './trailRenderer.js';
import { CHUNK_POINTS, createTrail } from './trailRenderer.js';
test('trail visibility can change without discarding its accumulated geometry', () => {
const added = [];
@@ -37,3 +37,253 @@ test('trail visibility can change without discarding its accumulated geometry',
trail.destroy();
assert.deepEqual(removed, [added[0]]);
});
/**
* A fake viewer. `ready` holds the frozen-chunk entities whose static
* geometry is in the scene; `frame()` runs one post-render pass.
*/
function fakeViewer({ scene = true } = {}) {
const added = [];
const removed = [];
const ready = new Set();
let listeners = [];
const viewer = {
added,
removed,
ready,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove(entity) {
removed.push(entity);
},
},
listenerCount: () => listeners.length,
frame() {
for (const listener of listeners.slice()) listener();
},
};
if (scene) {
viewer.scene = {
postRender: {
addEventListener(listener) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
},
};
viewer.dataSourceDisplay = {
getBoundingSphere: (entity) =>
ready.has(entity)
? Cesium.BoundingSphereState.DONE
: Cesium.BoundingSphereState.PENDING,
};
}
return viewer;
}
const valueOf = (positions) =>
typeof positions?.getValue === 'function' ? positions.getValue() : positions;
/** Every line the scene can draw now: live entities with two or more points. */
function drawnRuns(viewer) {
return viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.map((entity) => valueOf(entity.polyline.positions))
.filter((positions) => positions.length >= 2);
}
/** Distinct position objects across all drawn lines. */
function drawnPoints(viewer) {
return new Set(drawnRuns(viewer).flat());
}
function line(count, offset = 0) {
return Array.from(
{ length: count },
(_, i) => new Cesium.Cartesian3(6_378_137 + offset, i * 1000, 0),
);
}
test('a trail with breaks draws each segment and nothing between them', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const first = line(2, 0);
const lone = line(1, 50_000);
const second = line(3, 100_000);
trail.setSegments([first, lone, second]);
assert.deepEqual(
drawnRuns(viewer).sort((a, b) => a[0].x - b[0].x),
[first, second],
);
for (const entity of viewer.added) {
assert.match(String(entity.id), /^gev-trail:/);
assert.equal(entity.polyline.width, 2.5);
}
// The first segment is gone later: its geometry is released.
trail.setSegments([second]);
assert.deepEqual(drawnRuns(viewer), [second]);
trail.setVisible(false);
assert.ok(
viewer.added
.filter((entity) => !viewer.removed.includes(entity))
.every((entity) => entity.show === false),
);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
});
test('chunks share their boundary points and only the newest chunk is dynamic', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(300);
trail.setPositions(points);
const { frozen, dynamic } = trail._chunksForTest();
assert.deepEqual(
frozen.map((chunk) => chunk.positions.length),
[CHUNK_POINTS, CHUNK_POINTS],
);
assert.equal(frozen[0].positions.at(-1), frozen[1].positions[0]);
assert.equal(dynamic.length, 1);
assert.equal(frozen[1].positions.at(-1), dynamic[0][0]);
assert.equal(dynamic[0].at(-1), points.at(-1));
assert.equal(drawnPoints(viewer).size, points.length);
// A frozen chunk is constant geometry, not a per-frame callback.
for (const chunk of frozen)
assert.ok(Array.isArray(chunk.entity.polyline.positions));
});
test('freezing a chunk keeps every point drawn, and the dynamic copy goes only once the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(CHUNK_POINTS + 20);
for (let n = 2; n <= points.length; n++) {
trail.setPositions(points.slice(0, n));
assert.equal(drawnPoints(viewer).size, n, `points drawn at ${n}`);
}
const { frozen } = trail._chunksForTest();
assert.equal(frozen.length, 1);
// Not in the scene yet: the chunk is still drawn by a dynamic polyline.
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
assert.ok(
trail._chunksForTest().dynamic.includes(frozen[0].positions),
'the dynamic copy stays until the static geometry is ready',
);
viewer.ready.add(frozen[0].entity);
viewer.frame();
const after = trail._chunksForTest();
assert.equal(after.frozen[0].standIn, false);
assert.ok(!after.dynamic.includes(frozen[0].positions));
assert.equal(after.dynamic.length, 1);
assert.equal(viewer.listenerCount(), 0, 'no listener once nothing waits');
// The frozen entity is the same object: freezing built it once.
assert.equal(after.frozen[0].entity, frozen[0].entity);
});
test('a hidden trail keeps its dynamic copy until it is shown and the static geometry is in', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setVisible(false);
trail.setPositions(line(CHUNK_POINTS + 5));
const [chunk] = trail._chunksForTest().frozen;
viewer.ready.add(chunk.entity);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, true);
trail.setVisible(true);
viewer.frame();
assert.equal(trail._chunksForTest().frozen[0].standIn, false);
});
test('dropping the oldest point rebuilds only the first chunk', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points);
const before = trail._chunksForTest().frozen;
trail.setPositions(points.slice(1));
const after = trail._chunksForTest().frozen;
assert.equal(after.length, before.length);
assert.notEqual(after[0].entity, before[0].entity);
assert.ok(viewer.removed.includes(before[0].entity));
for (let k = 1; k < before.length; k++)
assert.equal(after[k].entity, before[k].entity);
assert.equal(after[0].positions.at(-1), after[1].positions[0]);
assert.equal(drawnPoints(viewer).size, 399);
});
test('a backfill prepended ahead of frozen chunks keeps them and joins at a shared point', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const live = line(300, 0);
trail.setPositions(live);
const before = trail._chunksForTest().frozen;
// History older than every live point, joined as one path.
const history = Array.from(
{ length: 200 },
(_, i) => new Cesium.Cartesian3(6_378_137, -(200 - i) * 1000, 0),
);
trail.setPositions(history.concat(live));
const after = trail._chunksForTest().frozen;
for (const chunk of before)
assert.ok(after.some((c) => c.entity === chunk.entity));
for (let k = 0; k + 1 < after.length; k++)
assert.equal(after[k].positions.at(-1), after[k + 1].positions[0]);
assert.equal(drawnPoints(viewer).size, 500);
// A backfill that breaks from the live trail is its own segment.
trail.setSegments([history, live]);
assert.equal(drawnPoints(viewer).size, 500);
const runs = drawnRuns(viewer);
assert.ok(
!runs.some((run) => run.includes(history.at(-1)) && run.includes(live[0])),
'no line across the break',
);
});
test('clearing and re-tracking release every frozen chunk', () => {
const viewer = fakeViewer();
const trail = createTrail(viewer, { color: '#ffffff' });
trail.setPositions(line(600));
const frozen = trail._chunksForTest().frozen.map((chunk) => chunk.entity);
assert.ok(frozen.length >= 4);
assert.equal(viewer.listenerCount(), 1);
trail.clear();
for (const entity of frozen) assert.ok(viewer.removed.includes(entity));
assert.equal(viewer.listenerCount(), 0);
assert.equal(drawnRuns(viewer).length, 0);
// Another aircraft: nothing of the first one survives.
const other = line(200, 900_000);
trail.setPositions(other);
assert.equal(drawnPoints(viewer).size, 200);
trail.destroy();
assert.equal(new Set(viewer.removed).size, viewer.added.length);
assert.equal(viewer.listenerCount(), 0);
});
test('replacing the newest point and removing points inside the trail draws exactly the new trail', () => {
const viewer = fakeViewer({ scene: false });
const trail = createTrail(viewer, { color: '#ffffff' });
const points = line(400);
trail.setPositions(points.slice());
// A recorded point replaces the newest one; a spur point inside a frozen
// chunk and one at a chunk boundary go.
points[points.length - 1] = new Cesium.Cartesian3(6_378_137, 399_500, 10);
points.splice(200, 1);
points.splice(CHUNK_POINTS - 1, 1);
trail.setPositions(points.slice());
assert.deepEqual(drawnPoints(viewer), new Set(points));
// Every drawn line runs through consecutive trail points only.
const next = new Map(points.slice(0, -1).map((p, i) => [p, points[i + 1]]));
for (const run of drawnRuns(viewer))
for (let i = 1; i < run.length; i++) assert.equal(next.get(run[i - 1]), run[i]);
});
+165
View File
@@ -0,0 +1,165 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
recordTrailPoint,
trailBodySegments,
trailPairBreaks,
trailDistanceM,
trailRecordingBlocked,
} from './trailContinuity.js';
// Reports decide where the line may run: recorded points never join two
// reports too far apart to be one flight, a report the display has already
// passed replaces the guess drawn after it, and old entries let go of the
// positions they stood for.
const SEC = 1000;
const LIMIT = 2_000;
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
Cesium.Cartesian3.fromDegrees(-100, 40, 0),
);
const at = (e, n, u = 10) =>
Cesium.Matrix4.multiplyByPoint(
enu,
new Cesium.Cartesian3(e, n, u),
new Cesium.Cartesian3(),
);
/** No two neighbours in one drawn segment are too far apart to be one flight. */
function assertNoBrokenJoin(fixes) {
const reports = fixes.filter((fix) => !fix.drawn && fix.position);
for (const segment of trailBodySegments(fixes, fixes.length)) {
const inSegment = reports.filter((r) =>
segment.some((p) => Cesium.Cartesian3.distance(p, r.position) < 0.01),
);
for (let i = 1; i < inSegment.length; i++)
assert.equal(
trailPairBreaks(
inSegment[i].timeMs - inSegment[i - 1].timeMs,
trailDistanceM(inSegment[i - 1].position, inSegment[i].position),
),
false,
`one segment joins reports at ${inSegment[i - 1].timeMs / SEC} s and ${
inSegment[i].timeMs / SEC
} s`,
);
}
}
test('two reports 60 s and 51 km apart are not joined, even by recorded points', () => {
const A = Cesium.Cartesian3.fromDegrees(-100, 40, 9_000);
const B = Cesium.Cartesian3.fromDegrees(-99.4, 40, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
insertTrailFix(fixes, B, 60 * SEC, LIMIT); // ahead of the display
for (let t = 1; t < 60; t++) {
// Nothing is recorded across the pair.
assert.equal(trailRecordingBlocked(fixes, t * SEC), true);
}
assert.equal(trailBodySegments(fixes, fixes.length).length, 2);
// Points recorded before the far report was known are dropped.
const late = [];
insertTrailFix(late, A, 0, LIMIT);
for (let t = 1; t < 60; t++)
recordTrailPoint(
late,
Cesium.Cartesian3.lerp(A, B, t / 60, new Cesium.Cartesian3()),
t * SEC,
LIMIT,
);
insertTrailFix(late, B, 60 * SEC, LIMIT);
assert.equal(late.filter((fix) => fix.drawn).length, 0);
assert.equal(trailBodySegments(late, late.length).length, 2);
assertNoBrokenJoin(late);
});
test('a coast, a hold and then a far report: nothing is drawn across', () => {
const A = at(0, 0, 9_000);
const fixes = [];
insertTrailFix(fixes, A, 0, LIMIT);
// The icon coasts east for 90 s at 230 m/s, then holds.
for (let t = 1; t <= 200; t++)
recordTrailPoint(
fixes,
at(230 * Math.min(t, 90), 0, 9_000),
t * SEC,
LIMIT,
);
assert.ok(fixes.filter((fix) => fix.drawn).length > 0);
// The next report is 150 km away two minutes later: over 1,500 kt.
const B = at(0, 150_000, 9_000);
insertTrailFix(fixes, B, 120 * SEC, LIMIT);
assertNoBrokenJoin(fixes);
const segments = trailBodySegments(fixes, fixes.length);
const segmentOf = (p) =>
segments.findIndex((s) =>
s.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(segmentOf(A), segmentOf(B));
// No recorded point between the two reports remains.
assert.equal(
fixes.filter((fix) => fix.drawn && fix.timeMs < 120 * SEC).length,
0,
);
});
for (const off of [200, 400, 600, 1_000]) {
test(`a report the display already passed, ${off} m off a 2 km turn, leaves no spike`, () => {
const R = 2_000;
const w = 100 / R;
const pos = (t, r = R) =>
at(r * Math.sin(w * t), R - r * Math.cos(w * t), 3_000);
const fixes = [];
insertTrailFix(fixes, pos(0), 0, LIMIT);
for (let t = 1; t <= 60; t++)
recordTrailPoint(fixes, pos(t), t * SEC, LIMIT);
const report = pos(50, R - off);
// It arrives with the display at 60 s; the ease takes 900 ms.
insertTrailFix(fixes, report, 50 * SEC, LIMIT, {
displayMs: 60 * SEC,
settleMs: 900,
});
// The guess drawn after the report is gone...
assert.deepEqual(
fixes.filter((fix) => fix.drawn && fix.timeMs > 50 * SEC),
[],
);
// ...nothing is recorded while the icon eases onto the correction...
assert.equal(trailRecordingBlocked(fixes, 60.5 * SEC), true);
assert.equal(trailRecordingBlocked(fixes, 61 * SEC), false);
// ...and the line reads arc, report, corrected path.
for (let t = 61; t <= 70; t++)
recordTrailPoint(fixes, pos(t, R - off), t * SEC, LIMIT);
const line = trailBodySegments(fixes, fixes.length).flat();
const r = line.findIndex(
(p) => Cesium.Cartesian3.distance(p, report) < 0.01,
);
assert.ok(r > 0);
for (const p of line.slice(0, r))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - R) < 31,
'a point before the report is off the arc',
);
for (const p of line.slice(r + 1))
assert.ok(
Math.abs(Cesium.Cartesian3.distance(p, at(0, R, 3_000)) - (R - off)) <
31,
'a point after the report is off the corrected path',
);
});
}
test('entries behind the report before the display let go of the positions they stood for', () => {
const fixes = [];
for (let s = 0; s <= 2 * 3_600; s++) {
const p = at(230 * s, 0, 10_000);
if (s % 15 === 0) insertTrailFix(fixes, p, s * SEC, LIMIT);
else recordTrailPoint(fixes, p, s * SEC, LIMIT);
}
let held = 0;
for (const fix of fixes) held += fix.absorbed?.length ?? 0;
// Only the newest stretch, up to two reports back, keeps what it stood for.
assert.ok(held <= 30, `${held} positions still held`);
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
TRAIL_THIN_MAX_SPAN_M,
TRAIL_THIN_TOLERANCE_M,
} from './trailContinuity.js';
// Recorded points are thinned by shape: a point stays only where the line
// would move without it. Reports always stay.
const SEC = 1000;
const LIMIT = 2_000;
const LAT0 = 40;
const LON0 = -100;
const M_PER_DEG_LAT = 111_320;
const mPerDegLon = (lat) => M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
/** Straight and level east at `speed` m/s, `tSec` after the start. */
const eastbound = (speed, tSec, heightM = 10_000) =>
Cesium.Cartesian3.fromDegrees(
LON0 + (speed * tSec) / mPerDegLon(LAT0),
LAT0,
heightM,
);
/** A standard-rate (3°/s) left turn from north, radius v / ω, `tSec` in. */
function turning(speed, tSec, heightM = 10_000) {
const omega = (3 * Math.PI) / 180;
const radius = speed / omega;
// Centre west of the start; heading north, turning left (towards west).
const angle = omega * tSec;
const east = -radius + radius * Math.cos(angle);
const north = radius * Math.sin(angle);
return Cesium.Cartesian3.fromDegrees(
LON0 + east / mPerDegLon(LAT0),
LAT0 + north / M_PER_DEG_LAT,
heightM,
);
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
const drawnCount = (fixes) => fixes.filter((fix) => fix.drawn).length;
test('one hour straight and level with a report every 15 s adds under 60 recorded points', () => {
const speed = 230;
const fixes = [];
for (const t of [0, 15, 30])
insertTrailFix(fixes, eastbound(speed, t), t * SEC, LIMIT);
// Live order: a report arrives 30 s before the display reaches it, and the
// drawn icon is recorded once a second.
for (let t = 1; t <= 3_600; t++) {
const reportAt = t + 30;
if (reportAt % 15 === 0 && reportAt <= 3_600)
insertTrailFix(fixes, eastbound(speed, reportAt), reportAt * SEC, LIMIT);
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
}
assert.ok(
drawnCount(fixes) < 60,
`${drawnCount(fixes)} recorded points kept`,
);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 241);
});
test('with no report at all, a straight leg keeps one point per 10 km and no more', () => {
const speed = 230;
const fixes = [];
insertTrailFix(fixes, eastbound(speed, 0), 0, LIMIT);
for (let t = 1; t <= 3_600; t++)
recordTrailPoint(fixes, eastbound(speed, t), t * SEC, LIMIT);
const legKm = (speed * 3_600) / 1_000;
assert.equal(TRAIL_THIN_MAX_SPAN_M, 10_000);
assert.ok(
drawnCount(fixes) <= Math.ceil(legKm / 10) + 1,
`${drawnCount(fixes)} points over ${legKm} km`,
);
for (let i = 1; i < fixes.length; i++)
assert.ok(
Cesium.Cartesian3.distance(fixes[i - 1].position, fixes[i].position) <=
TRAIL_THIN_MAX_SPAN_M + speed,
);
});
test('a standard-rate 180° turn keeps its curve: every recorded position stays within 30 m of the kept line', () => {
const speed = 230;
const fixes = [];
const recorded = [];
insertTrailFix(fixes, turning(speed, 0), 0, LIMIT);
for (let t = 1; t <= 60; t++) {
const position = turning(speed, t);
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
assert.equal(TRAIL_THIN_TOLERANCE_M, 30);
for (const position of recorded) {
const off = distanceToPath(position, line);
assert.ok(off <= TRAIL_THIN_TOLERANCE_M + 1, `${off.toFixed(1)} m off`);
}
assert.ok(drawnCount(fixes) < recorded.length, 'nothing was thinned');
assert.ok(drawnCount(fixes) >= 6, 'the curve was flattened');
});
test('a climb off the straight line keeps its vertices', () => {
const fixes = [];
insertTrailFix(fixes, eastbound(200, 0, 1_000), 0, LIMIT);
// 30 s level, then a 60 s climb of 20 m/s, then level again.
const height = (t) => 1_000 + 20 * Math.min(60, Math.max(0, t - 30));
const recorded = [];
for (let t = 1; t <= 150; t++) {
const position = eastbound(200, t, height(t));
recorded.push(position);
recordTrailPoint(fixes, position, t * SEC, LIMIT);
}
const line = fixes.map((fix) => fix.position);
for (const position of recorded)
assert.ok(distanceToPath(position, line) <= TRAIL_THIN_TOLERANCE_M + 1);
});
test('at the limit, old recorded points go before any report from the backfill', () => {
const max = 300;
const backfill = [];
for (let i = 0; i < 100; i++)
backfill.push({
position: eastbound(200, i * 60 - 20_000),
timeMs: (i * 60 - 20_000) * SEC,
nullAltitude: false,
});
let fixes = [];
insertTrailFix(fixes, turning(150, 0), 0, max);
// A long series of turns keeps many recorded points.
for (let t = 1; t <= 2_000; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(drawnCount(fixes) > 150);
fixes = joinTrailBackfill(
{
fixes: backfill.map((b) => ({ ...b, breakBefore: false })),
breakAfter: false,
},
fixes,
max,
);
assert.ok(fixes.length <= max);
const reports = fixes.filter((fix) => !fix.drawn);
assert.equal(reports.length, 101, 'a report was dropped');
// More recording at the limit drops recorded points, never reports.
for (let t = 2_001; t <= 2_400; t++)
recordTrailPoint(fixes, turning(150, t), t * SEC, max);
assert.ok(fixes.length <= max);
assert.equal(fixes.filter((fix) => !fix.drawn).length, 101);
// The recorded points that remain are the newest.
const drawnTimes = fixes.filter((fix) => fix.drawn).map((fix) => fix.timeMs);
assert.ok(Math.min(...drawnTimes) > 1_000 * SEC);
});
+1
View File
@@ -615,6 +615,7 @@
"LOADING": "جارٍ التحميل",
"DEGRADED": "متدهور",
"STALE": "متقادم",
"SIGNAL LOST · {time}": "انقطعت الإشارة · {time}",
"FALLBACK": "بديل",
"PARTIAL": "جزئي",
"UNCERTAIN": "غير مؤكد",
+1
View File
@@ -72,6 +72,7 @@
"LOADING": "LÄDT",
"DEGRADED": "EINGESCHRÄNKT",
"STALE": "VERALTET",
"SIGNAL LOST · {time}": "SIGNAL VERLOREN · {time}",
"PARTIAL": "TEILWEISE",
"FALLBACK": "FALLBACK",
"SUMMARY": "ÜBERSICHT",
+1
View File
@@ -500,6 +500,7 @@
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
"FALLBACK": "RESPALDO",
"PARTIAL": "PARCIAL",
"UNCERTAIN": "INCIERTO",
+1
View File
@@ -329,6 +329,7 @@
"LOADING": "CHARGEMENT",
"DEGRADED": "DÉGRADÉ",
"STALE": "OBSOLÈTE",
"SIGNAL LOST · {time}": "SIGNAL PERDU · {time}",
"PARTIAL": "PARTIEL",
"FALLBACK": "SECOURS",
"view band|STREET": "RUE",
+1
View File
@@ -328,6 +328,7 @@
"LOADING": "טוען",
"DEGRADED": "מופחת",
"STALE": "מיושן",
"SIGNAL LOST · {time}": "האות אבד · {time}",
"PARTIAL": "חלקי",
"FALLBACK": "חלופה",
"view band|STREET": "רחוב",
+1
View File
@@ -329,6 +329,7 @@
"LOADING": "CARICAMENTO",
"DEGRADED": "DEGRADATO",
"STALE": "OBSOLETO",
"SIGNAL LOST · {time}": "SEGNALE PERSO · {time}",
"PARTIAL": "PARZIALE",
"FALLBACK": "RISERVA",
"view band|STREET": "STRADA",
+1
View File
@@ -323,6 +323,7 @@
"LOADING": "読込中",
"DEGRADED": "性能低下",
"STALE": "更新遅延",
"SIGNAL LOST · {time}": "信号途絶 · {time}",
"PARTIAL": "部分的",
"FALLBACK": "フォールバック",
"view band|STREET": "街路",
+1
View File
@@ -329,6 +329,7 @@
"LOADING": "CARREGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESATUALIZADO",
"SIGNAL LOST · {time}": "SINAL PERDIDO · {time}",
"PARTIAL": "PARCIAL",
"FALLBACK": "ALTERNATIVO",
"view band|STREET": "RUA",
+1
View File
@@ -143,6 +143,7 @@
"{count}h ago": "{count} ч назад",
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
"STALE": "УСТАРЕЛ",
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
"refreshing...": "обновление...",
"loading...": "загрузка...",
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
+1
View File
@@ -332,6 +332,7 @@
"LOADING": "ЗАВАНТАЖЕННЯ",
"DEGRADED": "ПЕРЕБОЇ",
"STALE": "ЗАСТАРІЛО",
"SIGNAL LOST · {time}": "СИГНАЛ ВТРАЧЕНО · {time}",
"PARTIAL": "ЧАСТКОВО",
"FALLBACK": "РЕЗЕРВ",
"view band|STREET": "ВУЛИЦЯ",
+1
View File
@@ -323,6 +323,7 @@
"LOADING": "加载中",
"DEGRADED": "降级",
"STALE": "过期",
"SIGNAL LOST · {time}": "信号丢失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "备用",
"view band|STREET": "街道",
+1
View File
@@ -323,6 +323,7 @@
"LOADING": "加載中",
"DEGRADED": "降級",
"STALE": "過期",
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
"PARTIAL": "部分",
"FALLBACK": "備用",
"view band|STREET": "街道",
+83 -17
View File
@@ -5,11 +5,15 @@ import {
lerpAngleDeg,
speedRamp,
courseBetweenCartesians,
floorCoastedPosition,
staleCoastLimitSeconds,
staleCoastSecondsFrom,
staleHoldsForFix,
arcOffsetEnu,
courseSlewCapDps,
limitCourseStep,
corridorPathLatLon,
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
@@ -248,30 +252,88 @@ export function createMotion({
);
}
// Newest POSITION is older than renderTime. OpenSky can still be receiving
// fresh contact/kinematic messages for that aircraft; freezing at a hard
// 60 s-after-position boundary produced the visible stop → catch-up → stop
// cadence. Coast through the latest real contact plus a bounded grace
// window, with an absolute cap so a stale cached feed cannot drift forever.
// Newest POSITION is older than renderTime. Coast on the last speed and
// heading for five minutes past it, then stop, dimmed as SIGNAL LOST.
// A contact last heard on or near the ground holds there instead, and a
// coast never goes below the ground.
const { coastSec } = _coastPlan(newest, info, elapsedSec);
_extrapolateFix(
newest,
info,
coastSec,
out,
(info && info.turnRateDps) || 0,
);
return coastSec > 0 ? floorCoastedPosition(out, cachedGroundFloor) : out;
}
/**
* How far a contact coasts past its newest fix at a display time
* `elapsedSec` after it: up to five minutes, or not at all for a contact
* that holds at a report on or near the ground. The hold decision is made once
* per report and kept on it.
* @param {{epochMs?: number, time: Cesium.JulianDate}} newest
* @param {object|undefined} info - The contact's record.
* @param {number} elapsedSec
* @returns {{coastSec: number, holds: boolean}}
*/
function _coastPlan(newest, info, elapsedSec) {
const coastLimitSec = staleCoastLimitSeconds({
// `epochMs` is captured once when the poll is normalized. Avoid allocating
// a Date per aircraft on every 12 Hz fleet tick.
// a Date per aircraft on every fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Permit one minute of contact grace but cap any cached-feed drift at
// five minutes. Source backoff is exposed separately as a STALE cue.
minimumSec: 60,
// A plane that stops reporting keeps flying for five minutes, whatever
// its contact time, then stops dimmed as SIGNAL LOST. A missed update
// shows STALE throughout.
minimumSec: 300,
maximumSec: 300,
});
return _extrapolateFix(
const holds = staleHoldsForFix(newest, {
onGround: info?.onGround,
cachedGroundFloor,
});
return {
coastSec: staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}),
holds,
};
}
/**
* Whether a contact has stopped where its coast ended (or held at its last
* report) with no newer report, and since when it has had none.
* @param {string} icao24
* @returns {{reportEpochMs: number, holds: boolean}|null} Null while a
* report is ahead of the display time or the contact is still coasting.
*/
function _signalLost(icao24) {
const newest = flightState._positionHistory.get(icao24)?.at(-1);
if (!newest) return null;
const newestMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
const elapsedSec =
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
if (!(elapsedSec > 0)) return null;
const { coastSec, holds } = _coastPlan(
newest,
info,
Math.min(elapsedSec, coastLimitSec),
out,
(info && info.turnRateDps) || 0,
flightState.records.data.get(icao24),
elapsedSec,
);
if (!(elapsedSec > coastSec)) return null;
return {
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: newestMs,
holds,
};
}
/**
@@ -286,9 +348,12 @@ export function createMotion({
*/
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0;
const speed = Math.min(
Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.velocity) || 0,
COAST_MAX_SPEED_MPS,
);
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.true_track) || 0;
@@ -1131,6 +1196,7 @@ export function createMotion({
return {
_refloorStaleGroundedContacts,
_deadReckon,
_signalLost,
_extrapolateFix,
_isTrackWarmingUp,
_trackedFocusSizePx,
+4 -1
View File
@@ -132,7 +132,7 @@ export const TRAIL_COLOR = '#00d4ff';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MAX_POINTS = 2000;
// ---------------------------------------------------------------------------
// Render-behind smoothing (PRD WS-C C2 — approved product decision):
@@ -471,3 +471,6 @@ export const FLOOR_SEED_GRACE_MS = 90_000;
* reloads synchronously. */
export const IR_RELOAD_BATCH = 40;
/** Alpha of a contact that has missed an update or lost its signal: the stale fade, for the fleet and the tracked aircraft alike. */
export const STALE_ALPHA = 0.45;
+4
View File
@@ -9,6 +9,10 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
export const MISSING_POLL_LIMIT = 3;
/** The tracked contact is kept this long after its newest fix: five minutes
* coasting, then five stopped as SIGNAL LOST. */
export const TRACKED_RETENTION_MS = 10 * 60_000;
/** Cooldown in milliseconds after a transient source error. */
export const ERROR_BACKOFF_INTERVAL = 20000;
+66 -7
View File
@@ -11,6 +11,7 @@ import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
TRACKED_RETENTION_MS,
} from './recordPolicy.js';
/** Source-independent aircraft metadata and bounded missing-poll retention. */
@@ -23,10 +24,45 @@ export class FlightRecords {
};
this.data = new Map();
this.missingPolls = new Map();
// Contacts whose last poll re-sent a fix no newer than the one before.
this.fixRepeated = new Set();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
this.geoidNCache = new Map();
this.geoidReady = false;
}
/**
* Start reading a snapshot taken at `observedAtMs`. A fix re-sent in a
* newer snapshot marks its contact as not updated; the same snapshot read
* again does not.
* @param {number} observedAtMs
*/
beginSnapshot(observedAtMs) {
this.snapshotAdvanced =
!Number.isFinite(observedAtMs) ||
!Number.isFinite(this.lastSnapshotMs) ||
observedAtMs > this.lastSnapshotMs;
if (Number.isFinite(observedAtMs))
this.lastSnapshotMs = Math.max(
this.lastSnapshotMs ?? -Infinity,
observedAtMs,
);
}
/** Note a received fix: the newest fix time, and whether it advanced. */
_noteFix(id, prevMeta, fixEpochMs) {
const prevFixMs = prevMeta?.newestFixMs;
// A fix counts as new only when it moves on by a second: feeds that
// derive fix time from receipt (readsb `now − seen_pos`, 0.1 s) jitter.
if (!Number.isFinite(prevFixMs) || fixEpochMs >= prevFixMs + 1000) {
this.fixRepeated.delete(id);
return fixEpochMs;
}
if (this.snapshotAdvanced) this.fixRepeated.add(id);
return prevFixMs;
}
receive(
observation,
{ viewerLatDeg, viewerLonDeg, trackedId, floorWarmPoints },
@@ -158,6 +194,12 @@ export class FlightRecords {
// (2) otherwise the SAME default policy `alt` already uses, so the two
// never disagree on the genuine "no data yet" case (a never-reported
// aircraft has no prior render height, so it lands here unchanged).
// Neither altitude this poll nor a held render height: the height below
// is the airborne default, invented. The tracked trail keeps no point there.
const renderAltitudeInvented =
pickedAltM == null &&
!onGround &&
!Number.isFinite(prevMeta?.renderAltitudeM);
let renderAltitudeM;
if (pickedAltM != null) {
renderAltitudeM = pickedAltM;
@@ -204,9 +246,15 @@ export class FlightRecords {
const routeHeld =
Boolean(prevMeta?.routeCallsign) &&
prevMeta.routeCallsign === nextCallsign.toUpperCase();
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
const meta = {
sourceReference: observation.reference,
observedReceiptMs: Date.now(),
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
callsign: nextCallsign,
altitude: alt,
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
@@ -215,6 +263,7 @@ export class FlightRecords {
// still read `altitude`/baro).
geoAltitudeM,
renderAltitudeM,
renderAltitudeInvented,
onGround,
// Round 7: sticky airborne history — the landed fast-cull only
// applies to contacts that actually flew this session.
@@ -276,15 +325,25 @@ export class FlightRecords {
};
this.data.set(icao24, meta);
const fixEpochMs =
Number.isFinite(observation.positionTimeMs) &&
observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
}
absence(id, { complete, retainIds, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
const misses = (this.missingPolls.get(id) || 0) + 1;
// The tracked contact has missed an update: it is stale from the first
// one, and kept (so the camera keeps its target) until ten minutes after
// its newest fix, whatever the snapshot says. With no fix time known it
// takes the ordinary limits below.
const meta = this.data.get(id);
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
if (tracked && Number.isFinite(fixMs)) {
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
this.missingPolls.set(id, misses);
return 'stale';
}
this.missingPolls.delete(id);
return 'remove';
}
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
@@ -293,7 +352,6 @@ export class FlightRecords {
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
const misses = (this.missingPolls.get(id) || 0) + 1;
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
this.missingPolls.set(id, misses);
@@ -306,6 +364,7 @@ export class FlightRecords {
forget(id) {
this.data.delete(id);
this.missingPolls.delete(id);
this.fixRepeated.delete(id);
this.geoidNCache.delete(id);
}
}
+96
View File
@@ -164,3 +164,99 @@ test('a route belongs to its callsign: a new callsign drops it, a blank one keep
assert.equal(next.meta.airline, null);
assert.equal(next.meta.routeCallsign, null);
});
test('a contact with no altitude yet is marked as drawn at an invented height; a held one is not', () => {
const store = records();
const none = store.receive(
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
view(),
);
assert.equal(none.meta.renderAltitudeInvented, true);
const known = store.receive(
observation({ positionTimeMs: 1700000005000 }),
view(),
);
assert.equal(known.meta.renderAltitudeInvented, false);
const held = store.receive(
observation({
baroAltitudeM: null,
ellipsoidAltitudeM: null,
positionTimeMs: 1700000010000,
}),
view(),
);
assert.equal(held.meta.renderAltitudeInvented, false);
const parked = records().receive(
observation({ id: 'abc124', baroAltitudeM: null, onGround: true }),
view(),
);
assert.equal(parked.meta.renderAltitudeInvented, false);
});
test('the tracked contact is stale from its first missed update and kept until ten minutes after its newest fix', () => {
const realNow = Date.now;
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
let clock = fixMs + 5_000;
Date.now = () => clock;
try {
for (const snapshot of [
{ complete: true },
{ complete: true, retainIds: new Set(['abc123']) },
{ complete: false },
]) {
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
const absent = (tracked) =>
store.absence('abc123', { ...snapshot, likelyLanded: false, tracked });
clock = fixMs + 15_000;
assert.equal(absent(true), 'stale');
assert.equal(store.missingPolls.get('abc123'), 1);
// Kept long after the untracked limits, measured from the fix time.
clock = fixMs + 9 * 60_000;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000 - 1;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000;
assert.equal(absent(true), 'remove');
assert.equal(store.missingPolls.has('abc123'), false);
}
// Untracked contacts keep their own limits.
const store = records();
store.receive(observation({ positionTimeMs: fixMs }), view());
clock = fixMs + 15_000;
const untracked = (snapshot) =>
store.absence('abc123', { ...snapshot, likelyLanded: false });
assert.equal(untracked({ complete: false }), 'retain');
assert.deepEqual(
[1, 2, 3].map(() => untracked({ complete: true })),
['stale', 'stale', 'remove'],
);
} finally {
Date.now = realNow;
}
});
test('a repeated fix time marks the contact as not updated; removal is unchanged', () => {
const store = records();
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
store.beginSnapshot(fixMs + 2_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
// The next poll re-sends the same position.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
assert.equal(store.missingPolls.has('abc123'), false);
// The same snapshot read again is not a missed update.
store.beginSnapshot(fixMs + 12_000);
store.receive(observation({ positionTimeMs: fixMs }), view());
assert.equal(store.fixRepeated.has('abc123'), true);
// A newer fix clears it.
store.beginSnapshot(fixMs + 22_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
assert.equal(store.fixRepeated.has('abc123'), false);
store.beginSnapshot(fixMs + 32_000);
store.receive(observation({ positionTimeMs: fixMs + 10_000 }), view());
store.forget('abc123');
assert.equal(store.fixRepeated.has('abc123'), false);
});
+25 -1
View File
@@ -39,6 +39,7 @@ import {
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
STALE_ALPHA,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -680,6 +681,19 @@ export function createRendering({
* follow-camera's bounding sphere is ALWAYS ready — toggling 3D, or tracking while already zoomed
* in, can never stall or freeze the centering (the old model-graphic-on-entity failure mode). */
/** Fade the tracked 3D model like the billboard while the plane is stale. */
function _applyTrackedModelFade(model) {
const alpha = parts.tracking._trackedStaleCue?.(flightState._trackedIcao)
?.dimmed
? STALE_ALPHA
: 1;
if (model.color?.alpha === alpha) return;
model.color = (
flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN
).withAlpha(alpha);
}
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
@@ -823,6 +837,7 @@ export function createRendering({
parts.motion._trackedDisplayCourse(),
flightState._trackedModel.modelMatrix,
);
_applyTrackedModelFade(flightState._trackedModel);
if (!flightState._trackedModel.ready) return;
const spec = _modelSpec(
flightState.records.data.get(flightState._trackedIcao)?.klass,
@@ -849,6 +864,13 @@ export function createRendering({
}
function _fleetTick() {
// Every frame, shown or not: the tracked trail records where its icon is
// drawn and moves its body on (the head callback only reads the result),
// and the tracked label's stale cue follows the clock.
if (flightState._viewer) {
parts.tracking._trailTick?.();
parts.tracking._staleCueTick?.();
}
if (
!flightState._viewer ||
!flightState._billboardCollection ||
@@ -1081,7 +1103,9 @@ export function createRendering({
flightState._cockpitContactMode && !isCockpitNear
? 1
: _fleetBillboardScale(icao24, info?.klass),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseAlpha: flightState.records.missingPolls.get(icao24)
? STALE_ALPHA
: 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
+21 -5
View File
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
staleHoldsForFix,
synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
@@ -22,7 +23,7 @@ export function createFlightSnapshotRenderer({
queries,
}) {
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
const { warmGroundFloor } = groundFloor;
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
const { sampleMeshFloorCells } = meshFloor;
return function applySnapshot(snapshot, viewer) {
const currentIcaos = new Set();
@@ -39,6 +40,7 @@ export function createFlightSnapshotRenderer({
? Cesium.Math.toDegrees(viewerCarto.longitude)
: null;
const floorWarmPoints = [];
records.beginSnapshot(snapshot.observedAtMs);
// Classification and display policy consume source-independent observations.
refreshMilitaryRegistryIfStale();
@@ -112,6 +114,9 @@ export function createFlightSnapshotRenderer({
position: position.clone(),
velocity: meta.velocity,
track: meta.true_track,
// Drawn at an invented height: a trail seeded from history keeps no
// position for it.
inventedHeight: meta.renderAltitudeInvented === true,
});
if (history.length > POSITION_HISTORY_LIMIT) {
history.shift();
@@ -125,7 +130,10 @@ export function createFlightSnapshotRenderer({
// Round 2 (owner): ground traffic appends too — taxi history stays
// live after touchdown (grounded flights positions are already
// surface-clamped via the surfaceM chain, so the ground leg drapes).
if (isTracked) tracking._appendTrailFix(position.clone());
if (isTracked)
tracking._appendTrailFix(position.clone(), fixEpochMs, {
nullAltitude: meta.renderAltitudeInvented === true,
});
} else {
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
if (!modelOwnsGroundVisual) {
@@ -133,21 +141,28 @@ export function createFlightSnapshotRenderer({
}
// Apply fresh kinematics only from a forward synthetic fix. Mutating
// the historical fix reprojects the entire stale interval and snaps
// the rendered aircraft when course or speed changes late.
// the rendered aircraft when course or speed changes late. The stale
// rules apply: none for a contact that holds at its report, a coast
// capped from the real report, never below the ground. It is a motion
// aid, not a report, so it never joins the trail.
const kinematicsChanged =
newest.velocity !== meta.velocity || newest.track !== meta.true_track;
if (kinematicsChanged) {
const holds = staleHoldsForFix(newest, {
onGround: meta.onGround,
cachedGroundFloor,
});
if (kinematicsChanged && !holds) {
const synthetic = synthesizeForwardKinematicsFix(newest, {
epochMs: Date.now(),
velocity: meta.velocity,
track: meta.true_track,
turnRateDps: meta.turnRateDps,
cachedGroundFloor,
});
if (synthetic) {
history.push(synthetic);
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
meta.turnRateDps = turnRateFromFixHistory(history);
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
}
}
}
@@ -252,6 +267,7 @@ export function createFlightSnapshotRenderer({
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
tracked: icao24 === flightState._trackedIcao,
});
if (absence === 'retain') continue;
if (absence === 'stale') {
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightSnapshotRenderer } from './snapshotRenderer.js';
// A poll that repeats a contact's last position with new speed or course
// must not move it past where the stale rules allow, and never adds a
// report to the tracked trail.
const ICAO = '00a002';
const SEC = 1000;
/** Any property is a no-op function unless given. */
function stub(overrides = {}) {
return new Proxy(overrides, {
get: (target, key) => (key in target ? target[key] : () => undefined),
});
}
function harness({ ground = () => null }) {
const appended = [];
const history = [];
const flightState = {
_trackedIcao: ICAO,
_trackedEntity: null,
_positionHistory: new Map([[ICAO, history]]),
_billboards: new Map([[ICAO, { position: new Cesium.Cartesian3() }]]),
_billboardCollection: stub(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: stub(),
_displayFloorState: new Map(),
_models: new Map(),
_viewer: null,
_cockpitContactMode: false,
records: { data: new Map() },
};
let meta = null;
let fixEpochMs = 0;
const records = stub({
receive: () => {
flightState.records.data.set(ICAO, meta);
return { prevMeta: meta, meta, groundFlipped: false, fixEpochMs };
},
absence: () => 'retain',
});
const apply = createFlightSnapshotRenderer({
flightState,
records,
militaryRegistry: stub({ isMilitaryIcao: () => false }),
groundFloor: stub({ cachedGroundFloor: ground }),
meshFloor: stub(),
rendering: stub({
_modelOwnsVisual: () => false,
_fleetBillboardColor: () => Cesium.Color.WHITE,
}),
tracking: stub({
_militaryLayerSuppresses: () => false,
_appendTrailFix: (position, epochMs) =>
appended.push({ position, epochMs }),
}),
motion: stub(),
enrichment: stub(),
queries: stub({ _likelyLanded: () => false }),
});
return {
history,
appended,
poll(nextMeta, epochMs) {
meta = nextMeta;
fixEpochMs = epochMs;
apply({ records: [{ id: ICAO }], complete: true }, null);
},
};
}
const lightAircraft = (velocity, track) => ({
rawLat: 38.1436,
rawLon: -122.5561,
renderAltitudeM: 10,
altitude: 40,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'light',
});
test('a low contact whose position repeats while its speed changes is not moved and adds nothing to the trail', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({ ground: () => -30 });
h.poll(lightAircraft(36.4, 63), t0);
const first = h.history[0].position;
for (const [i, velocity] of [30, 22, 14, 9].entries()) {
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(velocity, 63 + i * 20), t0);
}
assert.equal(h.history.length, 1, 'a synthetic fix moved a holding contact');
assert.equal(h.history[0].position, first);
assert.equal(h.appended.length, 1, 'only the real report joins the trail');
});
test('a cruising contact whose position repeats coasts no further than the stale cap, and the synthetic fix is not a report', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
const cruise = (velocity, track) => ({
rawLat: 36,
rawLon: -115,
renderAltitudeM: 9_000,
altitude: 9_000,
velocity,
true_track: track,
onGround: false,
wasAirborne: true,
klass: 'jet',
});
h.poll(cruise(220, 90), t0);
const report = h.history[0].position;
t.mock.timers.tick(300 * SEC);
h.poll(cruise(200, 100), t0);
assert.equal(h.history.length, 2);
const moved = Cesium.Cartesian3.distance(report, h.history[1].position);
assert.ok(
moved <= 220 * 300 + 1,
`the synthetic fix coasted ${moved.toFixed(0)} m`,
);
assert.equal(h.appended.length, 1, 'the synthetic fix joined the trail');
});
test('a report drawn at an invented height says so on its history fix', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll({ ...lightAircraft(60, 90), renderAltitudeInvented: true }, t0);
h.poll({ ...lightAircraft(60, 90), rawLon: -122.55 }, t0 + 10 * SEC);
assert.equal(h.history[0].inventedHeight, true);
assert.equal(h.history[1].inventedHeight, false);
});
test('with the ground unknown, a low barometric altitude alone does not hold a contact', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll(lightAircraft(36.4, 63), t0);
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(30, 80), t0);
// It coasts under the stale cap instead.
assert.equal(h.history.length, 2);
assert.equal(h.history[0].staleHolds, false);
assert.equal(h.appended.length, 1);
});
+421
View File
@@ -0,0 +1,421 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import {
staleCoastSeconds,
staleHoldsAtLastReport,
} from '../../data/motionModel.js';
import { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
import { RENDER_DELAY_SEC } from './policy.js';
const SEC = 1000;
// Ellipsoidal ground at a sea-level airfield (geoid about -32 m).
const FIELD_GROUND_M = -30;
/** A motion module over one contact's history, with a fixed clock. */
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
const flightState = {
records: { data: new Map([['00a001', info]]) },
_positionHistory: new Map([['00a001', fixes]]),
_focusEvidenceIds: new Set(),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
_scratchCoastCarto: new Cesium.Cartographic(),
now: () => nowMs,
};
const motion = createMotion({
flightState,
services: {
groundFloor: {
cachedGroundFloor: ground,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) ? Math.max(alt, g + 1.5) : alt,
},
camera: {},
focus: {},
},
parts: {},
});
return { flightState, motion };
}
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
const height = (position) => Cesium.Cartographic.fromCartesian(position).height;
test('a stale contact holds at its last report when it reported on the ground or low', () => {
assert.equal(staleHoldsAtLastReport({ onGround: true }), true);
// A light aircraft on final: -30 m barometric, its render height 1.5 m above the known ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: FIELD_GROUND_M + 1.5,
groundM: FIELD_GROUND_M,
}),
true,
);
// Ground unknown: a low barometric altitude alone does not hold it; it
// coasts under the cap.
assert.equal(
staleHoldsAtLastReport({ onGround: false, heightM: 120 }),
false,
);
// A high airfield: 1,600 m barometric but 60 m above its ground.
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 1_660,
groundM: 1_600,
}),
true,
);
assert.equal(
staleHoldsAtLastReport({
onGround: false,
heightM: 10_000,
groundM: 20,
}),
false,
);
});
test('a stale contact coasts up to its source limit at any speed, and a holding one not at all', () => {
const coast = (over) =>
staleCoastSeconds({
elapsedSec: 400,
coastLimitSec: 300,
speedMps: 230,
holds: false,
...over,
});
assert.equal(coast(), 300);
assert.equal(coast({ elapsedSec: 200 }), 200);
assert.equal(coast({ coastLimitSec: 60 }), 60);
assert.equal(coast({ speedMps: 1_000 }), 300);
assert.equal(coast({ holds: true }), 0);
assert.equal(coast({ elapsedSec: -5 }), 0);
});
test('a light aircraft on short final goes stale after a low, slow, descending report, the icon holds at that report, not below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 30 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 60 * SEC, 38.1436, -122.5561, FIELD_GROUND_M + 1.5, 36.4, 63),
];
const info = {
altitude: -30,
renderAltitudeM: FIELD_GROUND_M + 1.5,
rawLat: 38.1436,
rawLon: -122.5561,
velocity: 36.4,
true_track: 63,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 60 * SEC,
};
for (const afterSec of [5, 45, 90, 240]) {
const nowMs = t0 + 60 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: () => FIELD_GROUND_M,
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(
Cesium.Cartesian3.distance(drawn, fixes[2].position) < 0.01,
`${afterSec} s after the report the icon is at the report`,
);
assert.ok(height(drawn) >= FIELD_GROUND_M);
}
});
function cruiser(lastContactAfterSec) {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 40, -100, 10_000, 230, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 230, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 230,
true_track: 90,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + lastContactAfterSec * SEC,
};
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
return { at, d };
}
test('a cruising contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
// Contact kept arriving for four minutes after the last position.
const { at, d } = cruiser(240);
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
assert.ok(near(d(at(90)), 230 * 90));
assert.ok(near(d(at(200)), 230 * 200), 'still moving at 200 s');
assert.ok(near(d(at(300)), 230 * 300));
assert.ok(near(d(at(400)), 230 * 300), 'stopped at 300 s');
assert.ok(Cesium.Cartesian3.distance(at(400), at(600)) < 0.01);
});
test('a cruising contact that stops reporting coasts five minutes, then stops', () => {
const { at, d } = cruiser(0);
assert.ok(Math.abs(d(at(60)) - 230 * 60) < 120);
assert.ok(Math.abs(d(at(300)) - 230 * 300) < 600);
assert.ok(Cesium.Cartesian3.distance(at(301), at(420)) < 0.01);
});
test('a bad reported speed coasts no faster than the 1,500 kt continuity limit', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// 2,000 m/s (about 3,900 kt): a corrupt velocity, not a real aircraft.
const fixes = [
fix(t0, 40, -100, 10_000, 2_000, 90),
fix(t0 + 15 * SEC, 40, -99.96, 10_000, 2_000, 90),
];
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -99.96,
velocity: 2_000,
true_track: 90,
onGround: false,
wasAirborne: true,
};
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC;
const { motion } = motionFixture({ info, fixes, nowMs });
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
const limitMps = (TRAIL_CONTINUITY_MAX_SPEED_KT * 1852) / 3600;
const d = Cesium.Cartesian3.distance(drawn, fixes[1].position);
assert.ok(Math.abs(d - limitMps * 300) < 1_500, `${d.toFixed(0)} m`);
});
test('coasting never takes a contact below the ground', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -105.5, 3_400, 80, 270),
fix(t0 + 15 * SEC, 39.7, -105.514, 3_400, 80, 270),
];
const info = {
altitude: 3_400,
renderAltitudeM: 3_400,
rawLat: 39.7,
rawLon: -105.514,
velocity: 80,
true_track: 270,
onGround: false,
wasAirborne: true,
};
// 3,000 m under the report, rising to 3,600 m along the coast west.
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + 60 * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => (lon < -105.52 ? 3_600 : 3_000),
});
const drawn = motion._deadReckon('00a001', new Cesium.Cartesian3());
assert.ok(height(drawn) >= 3_600, `height ${height(drawn)}`);
});
// ---------------------------------------------------------------------------
// The trail and head across a stale gap.
function services(trail) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: () => null,
floorAltitudeM: (alt) => alt,
},
};
}
function trailHarness({ fixes, nowMs, head }) {
const calls = [];
const trail = {
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
flightState._trackedIcao = '00a001';
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set('00a001', fixes);
let headPosition = head;
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => headPosition,
_trackedDisplayPosition: () => headPosition,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return {
tracking,
flightState,
drawn: () => (calls.at(-1) ?? []).flat(),
head: () => headEntity().polyline.positions.getValue(),
setClock: (ms) => {
clock = ms;
},
setHead: (position) => {
headPosition = position;
},
};
}
test('a holding contact draws no head, and the trail ends at the last report, never past the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 38.1678, -122.527, 303, 45, 200),
fix(t0 + 30 * SEC, 38.1279, -122.5425, 122, 40, 340),
fix(t0 + 60 * SEC, 38.133, -122.5494, 38, 37, 10),
fix(t0 + 90 * SEC, 38.1436, -122.5561, 0, 36.4, 63),
];
const last = fixes.at(-1).position;
// The display time is long past the last report; the icon holds there.
const h = trailHarness({
fixes,
nowMs: t0 + 90 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC,
head: Cesium.Cartesian3.clone(last),
});
h.tracking._startTrail('00a001');
// Every report, including the last, is in the trail: the head no longer
// cuts from the one before it to the icon.
assert.equal(h.drawn().length, 4);
assert.equal(h.drawn().at(-1), h.flightState._trailFixes.at(-1).position);
assert.deepEqual(h.head(), []);
});
test('while the display is between reports, the trail stops at the report behind the icon', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
// Reports every 10 s; the icon is drawn 30 s behind, between the 2nd and 3rd.
const fixes = [0, 1, 2, 3, 4, 5].map((i) =>
fix(t0 + i * 10 * SEC, 40, -100 + i * 0.03, 10_000, 230, 90),
);
const nowMs = t0 + 25 * SEC + RENDER_DELAY_SEC * SEC;
const icon = Cesium.Cartesian3.fromDegrees(-100 + 0.075, 40, 10_000);
const h = trailHarness({ fixes, nowMs, head: icon });
h.tracking._startTrail('00a001');
// Reports at 0, 10 and 20 s are behind the icon (display time 25 s).
assert.equal(h.drawn().length, 3);
const head = h.head();
assert.equal(head.length, 2);
assert.ok(Cesium.Cartesian3.distance(head[0], fixes[2].position) < 0.01);
});
test('the hold-or-coast decision is made once per report: a floor that resolves mid-coast does not snap the icon back', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 39.7, -104.6, 1_900, 70, 270),
fix(t0 + 15 * SEC, 39.7, -104.6123, 1_900, 70, 270),
];
const info = {
altitude: 1_950,
renderAltitudeM: 1_900,
rawLat: 39.7,
rawLon: -104.6123,
velocity: 70,
true_track: 270,
onGround: false,
wasAirborne: true,
lastContactEpochMs: t0 + 15 * SEC + 120 * SEC,
};
// No floor known when the contact goes stale: baro says well clear of the
// ground, so it coasts. Mid-coast the floor resolves 100 m under the report.
let ground = () => null;
const at = (afterSec) => {
const nowMs = t0 + 15 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
const { motion } = motionFixture({
info,
fixes,
nowMs,
ground: (lat, lon) => ground(lat, lon),
});
return motion._deadReckon('00a001', new Cesium.Cartesian3());
};
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
const before = d(at(30));
assert.ok(Math.abs(before - 70 * 30) < 50, `coasted ${before.toFixed(0)} m`);
ground = () => 1_800;
const after = d(at(31));
assert.ok(
after > before,
`the icon went from ${before.toFixed(0)} m to ${after.toFixed(0)} m`,
);
});
+25 -2
View File
@@ -195,9 +195,32 @@ export function createFlightState({ source, services }) {
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
/** @type {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean, breakBefore: boolean, drawn?: boolean}[]}
* Chronological tracked-aircraft trail (oldest first): reports, and
* (`drawn`) where the icon was drawn between them; `breakBefore` starts a
* new segment. */
flightState._trailPositions = [];
flightState._trailFixes = [];
/** @type {number} Body fixes drawn: those at or before the display time. */
flightState._trailBodyCount = 0;
/** @type {Cesium.Cartesian3|null} Where the tracked icon was last recorded into the trail. */
flightState._trailRecordPosition = null;
/** @type {number} Display time (epoch ms) of that recording. */
flightState._trailRecordMs = -Infinity;
/** @type {string|undefined} The stale cue last written into the tracked label (see `_staleCueTick`). */
flightState._trackedStaleCueText = undefined;
/** @type {{key: string, cue: {text: string, dimmed: boolean}}|null} This frame's tracked stale cue (see `_trackedStaleCue`). */
flightState._trackedStaleCueCache = null;
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
+273 -85
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
@@ -21,12 +20,31 @@ import {
TRACKED_MODEL_MAX_LOAD_FAILS,
TRACKED_MODEL_RETRY_BACKOFF_MS,
TRAIL_MAX_POINTS,
DR_CORRECTION_MS,
RENDER_DELAY_SEC,
TRAIL_COLOR,
CYAN_TRANSPARENT,
STALE_ALPHA,
} from './policy.js';
import {
drawTrailSegments,
historyFixEpochMs,
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
trailBackfillFixes,
trailBodyCount,
trailBodyCountOutsideModel,
trailBodySegments,
trailHeadStartFix,
trailRecordingBlocked,
TRAIL_RECORD_STEP_M,
TRAIL_RECORD_STEP_MS,
} from '../../data/trailContinuity.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const STALE_CYAN = Cesium.Color.CYAN.withAlpha(STALE_ALPHA);
export function createTracking({
flightState,
services,
@@ -333,18 +351,81 @@ export function createTracking({
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
* Put one report into the tracked aircraft's trail, in time order, and
* refresh the rendered trail. Caller passes an owned (cloned) Cartesian3. A
* report that breaks from its neighbour starts a new segment (see
* trailContinuity).
* @param {Cesium.Cartesian3} position - The report's position.
* @param {number} [epochMs] - The report's time; defaults to the tracked
* aircraft's newest position-history fix, which every caller has just pushed.
* @param {{nullAltitude?: boolean}} [options] - `nullAltitude`: the report
* had no altitude, so its position is not kept and the line breaks there.
* A report for a time the display has already passed replaces what was
* drawn after it (see `insertTrailFix`).
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(position);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS)
flightState._trailPositions.shift();
function _appendTrailFix(position, epochMs, { nullAltitude = false } = {}) {
const history = flightState._positionHistory.get(flightState._trackedIcao);
const timeMs = Number.isFinite(epochMs)
? epochMs
: historyFixEpochMs(history?.[history.length - 1]);
insertTrailFix(
flightState._trailFixes,
position,
timeMs,
TRAIL_MAX_POINTS,
{
nullAltitude,
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
settleMs: DR_CORRECTION_MS,
},
);
_refreshTrailDisplay();
}
/**
* Per-frame trail work, run from the layer's preRender pass whether or not
* the head is visible: record where the tracked icon is drawn (at most once
* a second or every 150 m) and move the body on as the display time passes
* reports. The head callback only reads the result.
*/
function _trailTick() {
if (!flightState._trackedIcao || !flightState._trail) return;
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
let changed = false;
if (
flightState._trailFixes.length &&
!parts.motion._isTrackWarmingUp() &&
!trailRecordingBlocked(flightState._trailFixes, displayMs)
) {
const drawn = parts.motion._trackedDisplayPosition(
flightState._trackedIcao,
);
const last = flightState._trailRecordPosition;
if (
drawn &&
(!last ||
displayMs - flightState._trailRecordMs >= TRAIL_RECORD_STEP_MS ||
Cesium.Cartesian3.distance(last, drawn) >= TRAIL_RECORD_STEP_M)
) {
flightState._trailRecordMs = displayMs;
flightState._trailRecordPosition = Cesium.Cartesian3.clone(
drawn,
flightState._trailRecordPosition ?? undefined,
);
changed = recordTrailPoint(
flightState._trailFixes,
Cesium.Cartesian3.clone(drawn),
displayMs,
TRAIL_MAX_POINTS,
);
}
}
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
_refreshTrailDisplay();
}
/**
* Renders the trail with its head clamped to the render-behind display
* position. Raw newest fixes run up to RENDER_DELAY_SEC ahead of the
@@ -354,17 +435,33 @@ export function createTracking({
*/
function _refreshTrailDisplay() {
// The trail BODY is the accumulated fixes EXCLUDING the newest raw one — that newest
// fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
// now − RENDER_DELAY_SEC), so drawing it would push the trail in front of the plane.
// The cheap per-frame _trailHeadEntity segment bridges the last body point to the
// delayed dead-reckoned head, so the body primitive only rebuilds on a real fix
// (poll cadence), never at motion cadence.
// The trail BODY is the accumulated fixes at or before the delayed display time
// (now − RENDER_DELAY_SEC); newer fixes are AHEAD of the icon, so drawing them
// would push the trail in front of the plane. The cheap per-frame
// _trailHeadEntity segment bridges the last body point to the delayed
// dead-reckoned head, so the body rebuilds only on a real fix or when the
// display time passes one, never at motion cadence.
// A break lifts the pen: each segment is its own line, nothing between them.
if (!flightState._trail) return;
flightState._trail.setPositions(
flightState._trailPositions.length > 1
? flightState._trailPositions.slice(0, -1)
: flightState._trailPositions,
flightState._trailBodyCount = _trailBodyCountNow();
drawTrailSegments(
flightState._trail,
trailBodySegments(flightState._trailFixes, flightState._trailBodyCount),
);
}
/** Fixes at or before the time the aircraft is drawn (now − RENDER_DELAY_SEC),
* less the newest recorded points inside its 3D model. */
function _trailBodyCountNow() {
return trailBodyCountOutsideModel(
flightState._trailFixes,
trailBodyCount(
flightState._trailFixes,
flightState.now() - RENDER_DELAY_SEC * 1000,
),
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
);
}
@@ -377,21 +474,24 @@ export function createTracking({
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
// Seed every report. The body only draws those at or before the delayed
// display time (now − RENDER_DELAY_SEC); newer ones join it as the display
// passes them. A synthetic forward fix (it carries `reportEpochMs`) is a
// motion aid, never a report. A report drawn at an invented height keeps
// no position and breaks the line.
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position));
}
if (Number.isFinite(fix.reportEpochMs)) continue;
insertTrailFix(
flightState._trailFixes,
Cesium.Cartesian3.clone(fix.position),
historyFixEpochMs(fix),
TRAIL_MAX_POINTS,
{ nullAltitude: fix.inventedHeight === true },
);
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, {
@@ -412,14 +512,13 @@ export function createTracking({
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — which is ~now, AHEAD of the delayed icon —
// so the segment would draw IN FRONT of the plane. Likewise during warm-up the
// icon predates all real history, so there is no valid body point behind it.
// The head starts at the last DISPLAYED body point, the newest fix at or
// before the delayed display time; a fix ahead of the icon would draw the
// segment IN FRONT of the plane. During warm-up the icon predates all real
// history, so there is no valid body point behind it.
if (
!flightState._trackedIcao ||
flightState._trailPositions.length < 2 ||
!flightState._trailFixes.length ||
parts.motion._isTrackWarmingUp()
)
return [];
@@ -427,12 +526,19 @@ export function createTracking({
parts.motion._trackedTrailCached() ||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start =
flightState._trailPositions[
flightState._trailPositions.length - 2
];
// The last body point (a report, or where the icon was drawn a
// moment ago) → the icon. The body is moved on by _trailTick, never
// here: a callback must not add or remove entities.
// No head across a break: a body point more than 150 km away, or
// too long ago or too far to be one path, stays detached; none while
// the aircraft holds at its last point.
const last = trailHeadStartFix(
flightState._trailFixes,
head,
flightState.now(),
flightState._trailBodyCount,
);
if (!last) return [];
// On a contact that has not moved this segment runs from inside the
// model out to its own anchor — a line through the fuselage. The END
// never gives, so a moving trail still terminates on the tail; the
@@ -441,7 +547,7 @@ export function createTracking({
// See trailHeadStart. Read `head` in place and clone only on the draw
// path — a suppressed parked contact runs this every frame.
const from = trailHeadStart(
start,
last.position,
head,
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
@@ -520,7 +626,13 @@ export function createTracking({
const time = observedAtMs / 1000;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAlt });
parsed.push({
lat,
lon,
baroAlt,
onGround: waypoint.onGround === true,
timeMs: observedAtMs,
});
}
if (!parsed.length) return;
@@ -543,27 +655,30 @@ export function createTracking({
)
return;
const older = [];
let lastAltM = null; // carry-forward for no-baro points whose cell isn't warm yet
for (const { lat, lon, baroAlt } of parsed) {
const baroM = Number.isFinite(baroAlt)
? baroAlt + geoidHeight(lat, lon)
: null;
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// No baro + unresolved floor: hold the previous waypoint's altitude
// (continuity — never a dive/spike to a made-up height). Leading points
// with nothing to carry keep the old 10 km airborne default.
if (altM == null) altM = lastAltM != null ? lastAltM : 10000;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(
flightState._trailPositions.length - TRAIL_MAX_POINTS,
);
}
// Every waypoint is kept, each leg its own segment. A ground waypoint is
// at altitude 0, floored at the ground. A waypoint with no altitude is a
// break on both sides: it sits on the ground where the floor is known and
// is skipped otherwise; a skipped point still breaks the line.
const backfill = trailBackfillFixes(
parsed.map(({ lat, lon, baroAlt, onGround, timeMs }) => {
const known = Number.isFinite(baroAlt) ? baroAlt : onGround ? 0 : null;
const altM = floorAltitudeM(
known == null ? null : known + geoidHeight(lat, lon),
cachedGroundFloor(lat, lon),
);
return {
position:
altM == null ? null : Cesium.Cartesian3.fromDegrees(lon, lat, altM),
timeMs,
nullAltitude: known == null,
};
}),
);
flightState._trailFixes = joinTrailBackfill(
backfill,
flightState._trailFixes,
TRAIL_MAX_POINTS,
);
_refreshTrailDisplay();
}
@@ -573,7 +688,9 @@ export function createTracking({
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
if (flightState._trail) flightState._trail.clear();
if (
flightState._trailHeadEntity &&
@@ -737,14 +854,90 @@ export function createTracking({
flightState._pendingTrackingRestore = null;
}
/**
* The tracked plane's stale cue and whether it is drawn with the stale fade.
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer
* report has come.
* - STALE, dimmed: it has missed an update and still coasts.
* - STALE, not dimmed: the whole feed is backing off.
* @param {string} icao24
* @returns {{text: string, dimmed: boolean}}
*/
function _trackedStaleCue(icao24) {
const records = flightState.records;
const misses = records.missingPolls.get(icao24) || 0;
const repeated = records.fixRepeated?.has(icao24) === true;
const backoff = flightState.feed._backoff === true;
// Computed once per frame for the label, the icon colour and the model
// fade; a poll between frames changes the key.
const frame = flightState._viewer?.scene?.frameState?.frameNumber;
const key = Number.isFinite(frame)
? `${frame}|${icao24}|${misses}|${repeated}|${backoff}`
: null;
const cached = flightState._trackedStaleCueCache;
if (key !== null && cached?.key === key) return cached.cue;
const cue = _computeTrackedStaleCue(
icao24,
misses > 0 || repeated,
backoff,
);
flightState._trackedStaleCueCache = key === null ? null : { key, cue };
return cue;
}
/** `_trackedStaleCue` without the per-frame cache. A repeated fix counts as missed. */
function _computeTrackedStaleCue(icao24, missed, backoff) {
const lost = parts.motion._signalLost(icao24);
if (lost && (missed || !lost.holds)) {
const sec = Math.max(
0,
Math.floor((flightState.now() - lost.reportEpochMs) / 1000),
);
const time =
sec < 60
? `${formatNumber(sec, { useGrouping: false })} ${unitLabel('s')}`
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
}
if (missed) return { text: t('STALE'), dimmed: true };
if (backoff) return { text: t('STALE'), dimmed: false };
return { text: '', dimmed: false };
}
/** The tracked billboard's colour: cyan, faded while stale, clear while the 3D model is the visual. */
function _trackedIconColor() {
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
return CYAN_TRANSPARENT;
return _trackedStaleCue(flightState._trackedIcao).dimmed
? STALE_CYAN
: Cesium.Color.CYAN;
}
/**
* Re-word the tracked label when its stale cue changes (STALE, SIGNAL LOST,
* each new second or minute of it). Run from the layer's per-frame pass.
*/
function _staleCueTick() {
const icao24 = flightState._trackedIcao;
if (!icao24 || !flightState._trackedEntity) return;
const text = _trackedStaleCue(icao24).text;
if (text === flightState._trackedStaleCueText) return;
flightState._trackedStaleCueText = text;
_updateTrackedLabelModel(icao24);
}
/** Multi-line tracked presentation text: "CS · FL · kts" + "Airline · Type" +
* "ORIG → DEST". The route line is gated by
* routePlausible so a wrong-leg adsbdb route is hidden, not displayed.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata), the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* velocity/altitude as live. */
* The first line carries the stale cue (`_trackedStaleCue`): STALE while
* the plane coasts on dead reckoning after a missed update, SIGNAL LOST
* once the coast has ended, so last-known velocity/altitude are never
* presented as live. */
function _trackedLabelText(icao24) {
const info = flightState.records.data.get(icao24);
@@ -765,10 +958,7 @@ export function createTracking({
const spd = info.velocity
? quantity(Math.round(info.velocity * 1.944), 'kts')
: '';
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? t('STALE')
: '';
const stale = _trackedStaleCue(icao24).text;
const lines = [[cs, fl, spd, stale].filter(Boolean).join(' · ')];
// Converted contacts report their class as TR-3B and nothing else — the
// operator/type identity is exactly what the Easter egg is replacing.
@@ -967,15 +1157,9 @@ export function createTracking({
CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1,
// Solid cyan when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
() =>
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
? CYAN_TRANSPARENT
: Cesium.Color.CYAN,
false,
),
// Solid cyan (faded while stale) when the billboard is the visual (zoomed out, 3D off,
// or model still loading); transparent once the STANDALONE tracked model is actually up.
color: new Cesium.CallbackProperty(_trackedIconColor, false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
@@ -1257,6 +1441,7 @@ export function createTracking({
_trackedModelRegimeActive,
_syncTracked2dRotation,
_appendTrailFix,
_trailTick,
_refreshTrailDisplay,
_startTrail,
_backfillTrail,
@@ -1267,6 +1452,9 @@ export function createTracking({
_applyPendingTrackingRestore,
_cancelPendingTrackingRestore,
_trackedLabelText,
_trackedStaleCue,
_trackedIconColor,
_staleCueTick,
_updateTrackedLabelModel,
_syncTrackedBillboardImage,
_refreshTr3bContact,
+414
View File
@@ -0,0 +1,414 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_MAX_POINTS } from './policy.js';
import { trailHeadJoins, trailPairBreaks } from '../../data/trailContinuity.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const NM = 1852;
function services({ trails, floor = () => null } = {}) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: floor,
floorAltitudeM: (alt, ground) =>
Number.isFinite(alt)
? alt
: Number.isFinite(ground)
? ground + 1
: null,
},
};
}
/** A fake trail that records every segment list it is given. */
function recordingTrail() {
const calls = [];
return {
calls,
// What draws: a lone point is a segment with no line.
last: () =>
(calls[calls.length - 1] ?? []).filter((segment) => segment.length >= 2),
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {
calls.push([]);
},
destroy() {},
}),
};
}
function viewer() {
const added = [];
return {
added,
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
}
/**
* A tracked aircraft whose live history holds `liveFixes` ({timeMs, lat, lon}),
* with a track endpoint answering `waypoints`.
*/
function trackedHarness({
liveFixes = [],
waypoints = null,
nowMs,
floor,
head = null,
} = {}) {
const trail = recordingTrail();
const svc = services({ trails: trail, floor });
const flightState = createFlightState({
services: svc,
source: {
label: 'Fixture aircraft',
async getTrack() {
if (!waypoints) throw new Error('404');
return { records: waypoints };
},
},
});
const v = viewer();
flightState._viewer = v;
flightState._trackedIcao = 'abc123';
flightState.now = () => nowMs;
flightState._positionHistory.set(
'abc123',
liveFixes.map((fix) => ({
time: Cesium.JulianDate.fromDate(new Date(fix.timeMs)),
epochMs: fix.timeMs,
position: Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 10_000),
})),
);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => head,
_trackedDisplayPosition: () => head,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
return { trail, tracking, flightState, viewer: v };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function waypoint(timeMs, lat, lon, baroAltitudeM = 10_000) {
return { observedAtMs: timeMs, latitude: lat, longitude: lon, baroAltitudeM };
}
/** `count` waypoints one minute apart flying east at ~420 kt from (lat, lon). */
function leg(startMs, lat, lon, count) {
return Array.from({ length: count }, (_, i) =>
waypoint(startMs + i * MINUTE, lat, lon + i * 0.12),
);
}
function headPositions(v) {
const entity = v.added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
return entity.polyline.positions.getValue();
}
test('a pair 40 minutes and 300 km apart stays joined', () => {
assert.equal(trailPairBreaks(40 * MINUTE, 300_000), false);
});
test('a pair 1 minute and 200 km apart breaks: faster than 1,500 kt', () => {
assert.equal(trailPairBreaks(MINUTE, 200_000), true);
});
test('a gap longer than 48 minutes breaks, and time that does not advance breaks', () => {
assert.equal(trailPairBreaks(49 * MINUTE, 10_000), true);
assert.equal(trailPairBreaks(48 * MINUTE, 10_000), false);
assert.equal(trailPairBreaks(0, 0), true);
assert.equal(trailPairBreaks(-MINUTE, 100), true);
});
test('a parked aircraft two hours apart within 0.1 nm stays joined', () => {
assert.equal(trailPairBreaks(2 * HOUR, 0.09 * NM), false);
assert.equal(trailPairBreaks(3 * HOUR + MINUTE, 0.09 * NM), true);
assert.equal(trailPairBreaks(2 * HOUR, 0.2 * NM), true);
});
test('the head joins within 150 km under the time and speed test', () => {
assert.equal(trailHeadJoins(30_000, 5_000), true);
assert.equal(trailHeadJoins(30 * MINUTE, 151_000), false);
assert.equal(trailHeadJoins(HOUR, 5_000), false);
});
test('a backfill with gaps of 10 h, 2 h, 1.2 h and 6.5 h draws five segments and no line across a gap', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const legs = [];
let start = t0;
const gaps = [10 * HOUR, 2 * HOUR, 1.2 * HOUR, 6.5 * HOUR];
const anchors = [
[30, 31],
[12, 0],
[14, -15],
[20, -40],
[35, -75],
];
for (let i = 0; i < anchors.length; i++) {
const points = leg(start, anchors[i][0], anchors[i][1], 6);
legs.push(points);
start = points[points.length - 1].observedAtMs + (gaps[i] ?? 0);
}
const waypoints = legs.flat();
const lastLeg = legs[legs.length - 1];
const liveStart = lastLeg[lastLeg.length - 1].observedAtMs + MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 35,
lon: -75 + 0.72 + i * 0.12,
}));
const nowMs = liveStart + 3 * MINUTE;
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
waypoints,
nowMs,
});
tracking._startTrail('abc123');
await settle();
assert.equal(flightState._trailFixes.length, waypoints.length + 3);
const segments = trail.last();
assert.equal(segments.length, 5);
const bodyCount = segments.reduce((sum, segment) => sum + segment.length, 0);
// The body draws every fix at or before the display time: all of them here.
assert.equal(bodyCount, waypoints.length + 3);
for (const [index, segment] of segments.entries()) {
const expected = legs[index].map((point) =>
Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, 10_000),
);
for (const position of segment.slice(0, expected.length)) {
assert.ok(
expected.some((e) => Cesium.Cartesian3.distance(e, position) < 1),
`segment ${index} holds a point from another leg`,
);
}
}
});
test('a null-altitude waypoint breaks the line, and nothing is drawn at 10 km', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const waypoints = leg(t0, 40, 10, 6);
waypoints[3] = waypoint(waypoints[3].observedAtMs, 40, 10.36, null);
const nowMs = t0 + 8 * MINUTE;
const liveFixes = [
{ timeMs: t0 + 6 * MINUTE, lat: 40, lon: 10.72 },
{ timeMs: t0 + 7 * MINUTE, lat: 40, lon: 10.84 },
];
// Unknown floor: the null point is skipped, and the line still breaks there.
const skipped = trackedHarness({ liveFixes, waypoints, nowMs });
skipped.tracking._startTrail('abc123');
await settle();
assert.equal(
skipped.flightState._trailFixes.length,
waypoints.length - 1 + 2,
);
const segments = skipped.trail.last();
assert.deepEqual(
segments.map((segment) => segment.length),
[3, 4],
);
for (const segment of segments) {
for (const position of segment) {
const height = Cesium.Cartographic.fromCartesian(position).height;
assert.ok(Math.abs(height - 10_000) < 1, 'only real altitudes drawn');
}
}
// Known floor: the point is kept on the ground, alone, joined to nothing.
const grounded = trackedHarness({
liveFixes,
waypoints,
nowMs,
floor: () => 120,
});
grounded.tracking._startTrail('abc123');
await settle();
assert.equal(grounded.flightState._trailFixes.length, waypoints.length + 2);
assert.deepEqual(
grounded.trail.last().map((segment) => segment.length),
[3, 4],
);
const kept = grounded.flightState._trailFixes[3];
assert.ok(
Math.abs(Cesium.Cartographic.fromCartesian(kept.position).height - 121) < 1,
);
});
test('a history tail far from the first live fix does not connect to it', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
// History ends 20 minutes before the first live fix, 1,400 km away.
const waypoints = leg(t0, 50, 0, 5);
const liveStart = t0 + 24 * MINUTE;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 50,
lon: 20 + i * 0.12,
}));
const { trail, tracking } = trackedHarness({
liveFixes,
waypoints,
nowMs: liveStart + 3 * MINUTE,
});
tracking._startTrail('abc123');
await settle();
assert.deepEqual(
trail.last().map((segment) => segment.length),
[5, 3],
);
});
test('the head is drawn to a nearby aircraft and not to one more than 150 km away', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const near = Cesium.Cartesian3.fromDegrees(1.3, 50, 10_000);
const joined = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: near,
});
joined.tracking._startTrail('abc123');
const drawn = headPositions(joined.viewer);
assert.equal(drawn.length, 2);
assert.ok(Cesium.Cartesian3.distance(drawn[1], near) < 1e-6);
const far = Cesium.Cartesian3.fromDegrees(4, 50, 10_000);
const detached = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: far,
});
detached.tracking._startTrail('abc123');
assert.deepEqual(headPositions(detached.viewer), []);
// An hour without a fix: the head no longer joins the last body point.
const stale = trackedHarness({ liveFixes, nowMs: t0 + HOUR, head: near });
stale.tracking._startTrail('abc123');
assert.deepEqual(headPositions(stale.viewer), []);
});
test('a live fix after a one-hour feed gap starts a new segment', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 50,
lon: 1 + i * 0.12,
}));
const { trail, tracking, flightState } = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
});
tracking._startTrail('abc123');
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3],
);
const later = t0 + 2 * MINUTE + HOUR;
// The display time moves past the new fixes.
flightState.now = () => later + 3 * MINUTE;
for (const [i, lon] of [3, 3.12, 3.24].entries()) {
tracking._appendTrailFix(
Cesium.Cartesian3.fromDegrees(lon, 50, 10_000),
later + i * MINUTE,
);
}
assert.equal(flightState._trailFixes.length, 6);
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3, 3],
);
});
test('every waypoint of a 24 h history survives, and the trail stays within TRAIL_MAX_POINTS', async () => {
// A full day at one fix per minute.
const day = 24 * 60;
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const full = leg(t0, 10, -100, day);
const liveStart = t0 + day * MINUTE;
const liveFixes = [{ timeMs: liveStart, lat: 10, lon: -100 + day * 0.12 }];
const kept = trackedHarness({
liveFixes,
waypoints: full,
nowMs: liveStart + MINUTE,
});
kept.tracking._startTrail('abc123');
await settle();
assert.ok(TRAIL_MAX_POINTS >= day + 500);
assert.equal(kept.flightState._trailFixes.length, full.length + 1);
assert.deepEqual(
kept.trail.last().map((segment) => segment.length),
[day + 1],
);
const count = TRAIL_MAX_POINTS + 120;
const large = leg(
liveStart - count * MINUTE,
10,
-100 + (day - count) * 0.12,
count,
);
const capped = trackedHarness({
liveFixes,
waypoints: large,
nowMs: liveStart + MINUTE,
});
capped.tracking._startTrail('abc123');
await settle();
const fixes = capped.flightState._trailFixes;
assert.equal(fixes.length, TRAIL_MAX_POINTS);
// The oldest go first: the newest live fix is still the last point.
assert.equal(fixes[fixes.length - 1].timeMs, liveStart);
});
+846
View File
@@ -0,0 +1,846 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { RENDER_DELAY_SEC, STALE_ALPHA } from './policy.js';
// Between reports, a tracked trail is the path the icon was actually drawn
// along: the real display position, recorded as the frames go by. These tests
// drive the real motion and display path frame by frame.
const SEC = 1000;
const ICAO = '00a001';
const T0 = Date.UTC(2026, 10, 9, 21, 0);
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
function services(trail, ground) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
aircraftPresentation: {
isTr3b: () => false,
tr3bTypeLabel: (id, label) => label,
},
focus: {
nearFarScalarValueAtDistance: () => 1,
clearFocusTarget() {},
publishFocusTargetFromCachedPosition() {},
},
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: ground,
stickyFloorCell: (lat, lon) => ({ lat, lon }),
neighborFloorM: () => null,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) && Number.isFinite(alt)
? Math.max(alt, g + 1.5)
: alt,
},
};
}
/**
* Real motion, real display position and real tracking over one contact.
* `frame()` runs one rendered frame: the layer's preRender work, then the
* head callback, as Cesium does.
*/
function harness({
fixes,
info,
nowMs,
ground = () => null,
envelopeM = null,
}) {
const segmentsCalls = [];
let inHead = false;
const trail = {
createTrail: () => ({
setSegments(segments) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail, ground);
const flightState = createFlightState({
services: svc,
source: { label: 'Fixture aircraft' },
});
flightState.records.data.set(ICAO, info);
let frameNumber = 0;
const added = [];
let entityChanges = 0;
flightState._viewer = {
isDestroyed: () => false,
scene: {
frameState: {
get frameNumber() {
return frameNumber;
},
},
},
entities: {
add(definition) {
entityChanges += 1;
added.push(definition);
return definition;
},
remove() {
entityChanges += 1;
},
},
};
flightState._trackedIcao = ICAO;
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set(ICAO, fixes);
const parts = {
rendering: { _modelOwnsVisual: () => false, _modelSpec: () => ({}) },
queries: { _contactLabel: (id, info) => info?.callsign || id },
};
parts.motion = createMotion({ flightState, services: svc, parts });
if (envelopeM != null) {
// A 3D model is the visual: its centre is the drawn position.
parts.motion._trackedModelCenterWorld = () =>
parts.motion._trackedDisplayCached();
parts.motion._trackedModelEnvelopeM = () => envelopeM;
}
const tracking = createTracking({
flightState,
services: svc,
parts,
layer: {},
});
parts.tracking = tracking;
const realDateNow = Date.now;
Date.now = () => clock;
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:fl-head-'),
);
const head = () => {
const before = entityChanges;
inHead = true;
try {
return headEntity().polyline.positions.getValue();
} finally {
inHead = false;
assert.equal(
entityChanges,
before,
'the head callback added or removed an entity',
);
}
};
/** Drawn icon positions, one per frame. */
const drawn = [];
const frame = ({ readHead = true } = {}) => {
frameNumber += 1;
tracking._trailTick();
const icon = parts.motion._trackedDisplayPosition(ICAO);
if (icon) drawn.push(Cesium.Cartesian3.clone(icon));
if (readHead) head();
};
const run = (toMs, { frameMs = 100, readHead = true } = {}) => {
while (clock < toMs) {
clock = Math.min(toMs, clock + frameMs);
frame({ readHead });
}
};
return {
flightState,
tracking,
motion: parts.motion,
head,
drawn,
run,
body: () => segmentsCalls.at(-1) ?? [],
bodyPoints: () => (segmentsCalls.at(-1) ?? []).flat(),
restore: () => {
Date.now = realDateNow;
},
};
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
if (path.length === 1) return Cesium.Cartesian3.distance(p, path[0]);
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
/** No vertex turns the line back sharper than 120° over a short step. */
function assertNoSpur(line, label) {
const u = new Cesium.Cartesian3();
const v = new Cesium.Cartesian3();
for (let i = 1; i < line.length - 1; i++) {
Cesium.Cartesian3.subtract(line[i], line[i - 1], u);
Cesium.Cartesian3.subtract(line[i + 1], line[i], v);
const lu = Cesium.Cartesian3.magnitude(u);
const lv = Cesium.Cartesian3.magnitude(v);
if (!(lu > 0 && lv > 0) || Math.min(lu, lv) > 500) continue;
const cos = Cesium.Cartesian3.dot(u, v) / (lu * lv);
assert.ok(
cos >= -0.5,
`${label}: spur at vertex ${i} of ${line.length} (turn ${(
(Math.acos(cos) * 180) /
Math.PI
).toFixed(0)}°)`,
);
}
}
const indexOfPoint = (line, p) =>
line.findIndex((q) => Cesium.Cartesian3.distance(p, q) < 0.01);
// A departing jet: four northbound climb reports, then a left turn through
// west to south with no report while the icon flies it.
function departure() {
const fixes = [
fix(T0, 37.6329, -122.3661, 213, 85, 8),
fix(T0 + 30 * SEC, 37.6542, -122.3616, 495, 95, 0),
fix(T0 + 60 * SEC, 37.6813, -122.3615, 808, 104, 350),
fix(T0 + 90 * SEC, 37.7066, -122.3755, 1257, 112, 340),
];
const info = {
altitude: 1257,
renderAltitudeM: 1257,
rawLat: 37.7066,
rawLon: -122.3755,
velocity: 112,
true_track: 340,
turnRateDps: -2,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 200 * SEC,
};
return { fixes, info };
}
test('a report newer than the display time arrives mid-turn: the trail between the two reports stays on the drawn arc', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// 40 s of turn with no new report: the icon flies the arc.
h.run(start + 40 * SEC);
const arc = h.drawn.slice();
// The next report is where the plane really was 60 s after the last one,
// still ahead of the display time (normal 30 s delay, slow feed). The icon
// now interpolates the straight line between the two reports and is eased
// onto it.
const at60 = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 150 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(at60);
const report = fix(
T0 + 150 * SEC,
Cesium.Math.toDegrees(c.latitude),
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
220,
);
fixes.push(report);
info.true_track = 220;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// The display passes the new report.
h.run(start + 75 * SEC);
const line = h.bodyPoints();
const from = indexOfPoint(line, fixes[3].position);
const to = indexOfPoint(line, report.position);
assert.ok(from >= 0 && to > from, 'both reports are on the trail');
const between = line.slice(from, to + 1);
// Every vertex between the reports is where the icon was drawn...
const drawnPath = [fixes[3].position, ...h.drawn, report.position];
for (const vertex of between) {
const off = distanceToPath(vertex, drawnPath);
assert.ok(
off < 50,
`trail vertex ${off.toFixed(0)} m off the drawn path`,
);
}
// ...and the arc the icon flew is still there: no straight chord. (The
// arc strays far from the chord, so a chord would fail this.)
const chord = [fixes[3].position, report.position];
assert.ok(Math.max(...arc.map((p) => distanceToPath(p, chord))) > 300);
// The last 10 s of the arc are exempt: the correction pulled the icon back
// over them, and keeping them would fold the line back on itself.
for (const point of arc.slice(0, -100)) {
const off = distanceToPath(point, between);
assert.ok(off < 50, `drawn arc point ${off.toFixed(0)} m from the trail`);
}
assertNoSpur(line, 'trail');
assertNoSpur(h.head(), 'head');
} finally {
h.restore();
}
});
test('a fresh report after a hold resumes with no backward spur', () => {
const fixes = [0, 1, 2, 3].map((i) =>
fix(T0 + i * 15 * SEC, 40, -100 + i * 0.04, 10_000, 230, 90),
);
const info = {
altitude: 10_000,
renderAltitudeM: 10_000,
rawLat: 40,
rawLon: -100 + 0.12,
velocity: 230,
true_track: 90,
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 45 * SEC,
};
const start = T0 + 45 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// The contact coasts five minutes, then holds; recording stops while it
// holds.
h.run(start + 320 * SEC);
const held = h.bodyPoints().length;
h.run(start + 360 * SEC);
assert.equal(h.bodyPoints().length, held, 'the trail grew during a hold');
// A fresh report, a little north of the track, ahead of the display.
const display = start + 360 * SEC - RENDER_DELAY_SEC * SEC;
// Just past where it held: five minutes and ten seconds at 230 m/s.
const aheadDeg = (230 * 310) / (111_320 * Math.cos((40 * Math.PI) / 180));
const fresh = fix(
display + 10 * SEC,
40.01,
-100 + 0.12 + aheadDeg,
10_000,
230,
80,
);
fixes.push(fresh);
info.lastContactEpochMs = fresh.epochMs;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(fresh.position),
fresh.epochMs,
);
h.run(start + 390 * SEC);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), fresh.position) >= 0);
} finally {
h.restore();
}
});
test('with the head hidden the body keeps advancing, and untracking clears the recording', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 5 * SEC);
const before = h.bodyPoints().at(-1);
// Cockpit view: the head is hidden and its callback never runs.
h.run(start + 30 * SEC, { readHead: false });
// The body's end keeps following the icon; straight stretches are thinned.
const end = h.bodyPoints().at(-1);
assert.ok(
Cesium.Cartesian3.distance(end, before) > 1_000,
'the body stopped advancing',
);
assert.ok(Cesium.Cartesian3.distance(end, h.drawn.at(-1)) < 300);
h.tracking._clearTrail();
assert.equal(h.flightState._trailFixes.length, 0);
// A new track of the same plane starts from reports only.
h.tracking._startTrail(ICAO);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
test('a live fix with no altitude breaks the line and puts no point at an invented height', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const invented = Cesium.Cartesian3.fromDegrees(-122.38, 37.71, 10_000);
h.tracking._appendTrailFix(invented, T0 + 95 * SEC, { nullAltitude: true });
const later = Cesium.Cartesian3.fromDegrees(-122.39, 37.715, 1400);
h.tracking._appendTrailFix(later, T0 + 100 * SEC);
h.run(T0 + 100 * SEC + RENDER_DELAY_SEC * SEC + SEC, { frameMs: 500 });
for (const point of h.bodyPoints())
assert.ok(Cesium.Cartographic.fromCartesian(point).height < 5_000);
// The reports on either side of it are not joined.
const segments = h.body();
const holding = (p) =>
segments.findIndex((segment) =>
segment.some((q) => Cesium.Cartesian3.distance(p, q) < 0.01),
);
assert.notEqual(holding(fixes[3].position), holding(later));
} finally {
h.restore();
}
});
test('with a 3D model as the visual, the trail stops outside the model and the head runs from there to the plane', () => {
const { fixes, info } = departure();
info.turnRateDps = 0;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const envelopeM = 300;
const h = harness({ fixes, info, nowMs: start, envelopeM });
try {
h.tracking._startTrail(ICAO);
h.run(start + 30 * SEC);
const icon = h.drawn.at(-1);
const body = h.bodyPoints();
const head = h.head();
assert.ok(head.length >= 2, 'no head is drawn');
assert.ok(Cesium.Cartesian3.distance(head.at(-1), icon) < 0.01);
// No trail point inside the model, and the head starts where the body ends.
for (const point of body)
assert.ok(Cesium.Cartesian3.distance(point, icon) >= envelopeM - 1);
assert.ok(Cesium.Cartesian3.distance(head[0], body.at(-1)) < 0.01);
// Body and head together follow where the icon was drawn.
const line = body.concat(head);
for (const point of h.drawn) {
const off = distanceToPath(point, line);
assert.ok(off < 35, `drawn icon ${off.toFixed(0)} m off the trail`);
}
} finally {
h.restore();
}
});
test('a report the display already passed replaces the guess drawn after it, with no spike', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 40 * SEC);
const displayMs = T0 + 130 * SEC;
// A report for 10 s before the display time, 400 m inside the drawn arc.
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 120 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
T0 + 120 * SEC,
Cesium.Math.toDegrees(c.latitude) - 400 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
250,
);
fixes.push(report);
info.true_track = 250;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
h.run(start + 55 * SEC);
const recorded = h.flightState._trailFixes.filter((entry) => entry.drawn);
// Nothing recorded between the report and the end of the ease.
assert.deepEqual(
recorded
.filter(
(entry) =>
entry.timeMs > report.epochMs && entry.timeMs < displayMs + 900,
)
.map((entry) => entry.timeMs - T0),
[],
);
const line = h.bodyPoints();
assert.ok(indexOfPoint(line, report.position) >= 0);
assertNoSpur(line, 'trail');
} finally {
h.restore();
}
});
test('a history report at an invented height is seeded with no position, and nothing is recorded next to it', () => {
const { fixes, info } = departure();
fixes[3].inventedHeight = true;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const seeded = h.flightState._trailFixes.find(
(entry) => entry.timeMs === fixes[3].epochMs,
);
assert.equal(seeded.position, null);
assert.equal(seeded.nullAltitude, true);
h.run(start + 10 * SEC);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
// A turboprop at FL210 flying east out of receiver coverage: its last
// position is at T0 + 30 s, and contact goes on for four more minutes.
function outOfCoverage() {
const fixes = [
fix(T0, 43, -108.1, 6_400, 140, 90),
fix(T0 + 30 * SEC, 43, -108.05, 6_400, 140, 90),
];
const info = {
altitude: 6_400,
renderAltitudeM: 6_400,
rawLat: 43,
rawLon: -108.05,
velocity: 140,
true_track: 90,
callsign: 'TEST7',
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 30 * SEC + 240 * SEC,
};
return { fixes, info };
}
/** Display time (ms) `afterSec` past the last report. */
const pastReport = (afterSec) =>
T0 + 30 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
test('a plane that stops reporting keeps moving dimmed as STALE from its first missed update, then stops as SIGNAL LOST', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(30));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
assert.equal(h.tracking._trackedIconColor().alpha, 1);
// The first missed update.
h.flightState.records.missingPolls.set(ICAO, 1);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.match(h.tracking._trackedLabelText(ICAO), /STALE/);
// Drawn with the fleet's stale fade.
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
// Still moving well past 90 s.
h.run(pastReport(200));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-11)) > 100,
);
// The coast ends 300 s past the report; the plane stops there.
h.run(pastReport(310));
const recorded = h.flightState._trailFixes.filter((e) => e.drawn).length;
h.run(pastReport(330));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-50)) < 0.01,
);
assert.equal(
h.flightState._trailFixes.filter((e) => e.drawn).length,
recorded,
'the trail kept recording a stopped plane',
);
// 330 s of display past the report is 330 s + the display delay of wall time.
const minutes = Math.floor((330 + RENDER_DELAY_SEC) / 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${minutes} min`,
dimmed: true,
});
assert.match(
h.tracking._trackedLabelText(ICAO),
new RegExp(`SIGNAL LOST · ${minutes} min`),
);
} finally {
h.restore();
}
});
test('a plane held at its last report shows SIGNAL LOST in seconds under a minute', () => {
// Its last report is 100 m above a known ground height: it holds there.
const { fixes, info } = outOfCoverage();
const h = harness({
fixes,
info,
nowMs: pastReport(-5),
ground: () => 6_300,
});
try {
h.tracking._startTrail(ICAO);
h.flightState.records.missingPolls.set(ICAO, 1);
h.run(pastReport(10));
assert.ok(10 + RENDER_DELAY_SEC < 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${10 + RENDER_DELAY_SEC} s`,
dimmed: true,
});
} finally {
h.restore();
}
});
test('a report arriving 200 s into a coast: the plane eases onto it and the trail drops what was drawn after it', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(200));
// Heard again: the report is for 10 s before the display time, 2 km north
// of where the coast put it.
const at = T0 + 30 * SEC + 190 * SEC;
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(at)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
at,
Cesium.Math.toDegrees(c.latitude) + 2_000 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
140,
90,
);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
assert.deepEqual(
h.flightState._trailFixes.filter(
(e) => e.drawn && e.timeMs > report.epochMs,
),
[],
);
const from = h.drawn.length;
h.run(pastReport(203));
const after = h.drawn.slice(from - 1);
for (let i = 1; i < after.length; i++)
assert.ok(
Cesium.Cartesian3.distance(after[i], after[i - 1]) < 400,
'the plane jumped instead of easing',
);
// Eased onto the path from the report: 13 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(after.at(-1), report.position);
assert.ok(
Math.abs(off - 140 * 13) < 150,
`${off.toFixed(0)} m from the report`,
);
} finally {
h.restore();
}
});
test('a tracked plane that stops reporting: STALE from the first missed poll, SIGNAL LOST after the five-minute coast, kept at 9 min, removed at 10 min', () => {
const { fixes, info } = outOfCoverage();
const fixMs = fixes.at(-1).epochMs;
// Contact stops with the position: the plane left coverage.
info.lastContactEpochMs = fixMs;
info.newestFixMs = fixMs;
const h = harness({ fixes, info, nowMs: fixMs + 5 * SEC });
try {
h.tracking._startTrail(ICAO);
const records = h.flightState.records;
const polls = new Map();
let removedAtSec = null;
// A poll every 10 s that lists the plane without a position.
for (let afterSec = 10; afterSec <= 660; afterSec += 10) {
h.run(fixMs + afterSec * SEC, { frameMs: 500, readHead: false });
const result = records.absence(ICAO, {
complete: true,
retainIds: new Set([ICAO]),
likelyLanded: false,
tracked: true,
});
if (result === 'remove') {
removedAtSec = afterSec;
break;
}
polls.set(afterSec, {
cue: h.tracking._trackedStaleCue(ICAO),
drawn: Cesium.Cartesian3.clone(h.drawn.at(-1)),
});
}
// Wall time when the display reaches the end of the coast.
const coastEndSec = 300 + RENDER_DELAY_SEC;
for (const [afterSec, { cue }] of polls) {
if (afterSec <= coastEndSec) {
assert.deepEqual(cue, { text: 'STALE', dimmed: true }, `${afterSec} s`);
} else {
assert.deepEqual(
cue,
{
text: `SIGNAL LOST · ${Math.floor(afterSec / 60)} min`,
dimmed: true,
},
`${afterSec} s`,
);
}
}
const moved = (a, b) =>
Cesium.Cartesian3.distance(polls.get(a).drawn, polls.get(b).drawn);
// Moving through the coast, still at its last speed at four minutes.
assert.ok(Math.abs(moved(230, 240) - 140 * 10) < 20);
// Stopped once the coast has ended.
const after = Math.ceil(coastEndSec / 10) * 10;
assert.ok(moved(after, 540) < 0.01);
assert.equal(polls.get(300).cue.text, 'STALE');
assert.deepEqual(polls.get(540).cue, {
text: 'SIGNAL LOST · 9 min',
dimmed: true,
});
assert.equal(removedAtSec, 600);
} finally {
h.restore();
}
});
test('a repeated old fix counts as a missed update for the tracked plane', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
// The feed re-sends the last position: its fix time did not advance.
h.flightState.records.fixRepeated.add(ICAO);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs;
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(360));
const stop = Cesium.Cartesian3.clone(h.drawn.at(-1));
assert.ok(Cesium.Cartesian3.distance(stop, h.drawn.at(-100)) < 0.01);
const kept = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => Cesium.Cartesian3.clone(e.position));
// The thinned straight coast, recorded up to where the plane stopped.
assert.ok(kept.length >= 2);
assert.ok(Cesium.Cartesian3.distance(kept.at(-1), stop) < 300);
// Heard again, for 20 s before the display time: the plane flew on, a
// little north of its old heading.
const at = T0 + 30 * SEC + 340 * SEC;
const lonDeg =
-108.05 + (140 * 340) / (111_320 * Math.cos((43 * Math.PI) / 180));
const report = fix(at, 43 + 1_000 / 111_320, lonDeg, 6_400, 140, 90);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// Nothing was recorded after the stop, so nothing is dropped.
const after = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => e.position);
assert.equal(after.length, kept.length);
kept.forEach((p, i) =>
assert.ok(Cesium.Cartesian3.distance(p, after[i]) < 0.01),
);
const from = h.drawn.length;
h.run(pastReport(365));
const eased = h.drawn.slice(from - 1);
const jump = Cesium.Cartesian3.distance(eased[0], eased.at(-1));
for (let i = 1; i < eased.length; i++)
assert.ok(
Cesium.Cartesian3.distance(eased[i], eased[i - 1]) < jump / 2,
'the plane jumped instead of easing',
);
// On the path from the report: 25 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(eased.at(-1), report.position);
assert.ok(Math.abs(off - 140 * 25) < 150, `${off.toFixed(0)} m`);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), report.position) >= 0);
} finally {
h.restore();
}
});
+83 -12
View File
@@ -5,10 +5,14 @@ import {
speedRamp,
courseBetweenCartesians,
COURSE_HOLD_SPEED_MPS,
floorCoastedPosition,
staleCoastLimitSeconds,
staleCoastSecondsFrom,
staleHoldsForFix,
arcOffsetEnu,
courseSlewCapDps,
limitCourseStep,
COAST_MAX_SPEED_MPS,
} from '../../data/motionModel.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
@@ -141,23 +145,86 @@ export function createMotion({
);
}
// Continue through fresh adsb.lol contacts even when the position epoch has
// not advanced, but keep an absolute stale-feed drift ceiling.
// Coast on the last speed and heading for five minutes past the newest
// fix, then stop, dimmed as SIGNAL LOST. A contact last heard on or near
// the ground holds there instead, and a coast never goes below the ground.
const { coastSec } = _coastPlan(newest, info, elapsedSec);
const cachedGroundFloor = services.groundFloor?.cachedGroundFloor;
_extrapolateFix(
newest,
info,
coastSec,
out,
(info && info.turnRateDps) || 0,
);
return coastSec > 0 ? floorCoastedPosition(out, cachedGroundFloor) : out;
}
/**
* How far a contact coasts past its newest fix at a display time
* `elapsedSec` after it: up to five minutes, or not at all for a contact
* that holds at a report on or near the ground. The hold decision is made once
* per report and kept on it.
* @param {{epochMs?: number, time: Cesium.JulianDate}} newest
* @param {object|undefined} info - The contact's record.
* @param {number} elapsedSec
* @returns {{coastSec: number, holds: boolean}}
*/
function _coastPlan(newest, info, elapsedSec) {
const coastLimitSec = staleCoastLimitSeconds({
// Captured at poll normalization so the fleet tick does not allocate a
// Date for every aircraft twelve times per second.
// `epochMs` is captured once when the poll is normalized. Avoid allocating
// a Date per aircraft on every fleet tick.
fixEpochMs: Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime(),
lastContactEpochMs: info?.lastContactEpochMs,
// Five minutes of flight after the last report, as flights.
minimumSec: 300,
maximumSec: 300,
});
return _extrapolateFix(
const holds = staleHoldsForFix(newest, {
onGround: info?.onGround,
cachedGroundFloor: services.groundFloor?.cachedGroundFloor,
});
return {
coastSec: staleCoastSecondsFrom({
newest,
elapsedSec,
coastLimitSec,
holds,
}),
holds,
};
}
/**
* Whether a contact has stopped where its coast ended (or held at its last
* report) with no newer report, and since when it has had none.
* @param {string} icao24
* @returns {{reportEpochMs: number, holds: boolean}|null} Null while a
* report is ahead of the display time or the contact is still coasting.
*/
function _signalLost(icao24) {
const newest = flightState._positionHistory.get(icao24)?.at(-1);
if (!newest) return null;
const newestMs = Number.isFinite(newest.epochMs)
? newest.epochMs
: Cesium.JulianDate.toDate(newest.time).getTime();
const elapsedSec =
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
if (!(elapsedSec > 0)) return null;
const { coastSec, holds } = _coastPlan(
newest,
info,
Math.min(elapsedSec, coastLimitSec),
out,
(info && info.turnRateDps) || 0,
flightState.records.data.get(icao24),
elapsedSec,
);
if (!(elapsedSec > coastSec)) return null;
return {
reportEpochMs: Number.isFinite(newest.reportEpochMs)
? newest.reportEpochMs
: newestMs,
holds,
};
}
/**
@@ -172,9 +239,12 @@ export function createMotion({
*/
function _extrapolateFix(fix, info, dt, out, turnRateDps = 0) {
const speed = Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.speedMps) || 0;
const speed = Math.min(
Number.isFinite(fix.velocity)
? fix.velocity
: (info && info.speedMps) || 0,
COAST_MAX_SPEED_MPS,
);
const heading = Number.isFinite(fix.track)
? fix.track
: (info && info.track) || 0;
@@ -514,6 +584,7 @@ export function createMotion({
}
return {
_deadReckon,
_signalLost,
_extrapolateFix,
_isTrackWarmingUp,
_trackedFocusSizePx,
+4 -1
View File
@@ -172,7 +172,7 @@ export const TRAIL_COLOR = '#FFB800';
/** @constant {number} Combined cap on trail vertices (backfill + live accumulation). */
export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MAX_POINTS = 2000;
// ---------------------------------------------------------------------------
// Render-behind smoothing (mirrors flights.js): the fleet renders at
@@ -271,3 +271,6 @@ export const TRACKED_MODEL_RETRY_BACKOFF_MS = 1500;
* synchronously (single primitive). */
export const IR_RELOAD_BATCH = 40;
/** Alpha of a contact that has missed an update or lost its signal: the stale fade, for the fleet and the tracked aircraft alike. */
export const STALE_ALPHA = 0.45;
+4
View File
@@ -6,4 +6,8 @@ export const LANDED_MISSING_POLL_LIMIT = 1;
export const MISSING_POLL_LIMIT = 3;
/** The tracked contact is kept this long after its newest fix: five minutes
* coasting, then five stopped as SIGNAL LOST. */
export const TRACKED_RETENTION_MS = 10 * 60_000;
export const ERROR_BACKOFF_INTERVAL = 20000;
+61 -3
View File
@@ -6,6 +6,7 @@ import {
GROUND_FLOOR_WARM_MAX_ALT_M,
LANDED_MISSING_POLL_LIMIT,
MISSING_POLL_LIMIT,
TRACKED_RETENTION_MS,
} from './recordPolicy.js';
/** Portable military aircraft metadata and bounded missing-poll retention. */
@@ -14,9 +15,44 @@ export class MilitaryFlightRecords {
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
this.data = new Map();
this.missingPolls = new Map();
// Contacts whose last poll re-sent a fix no newer than the one before.
this.fixRepeated = new Set();
this.lastSnapshotMs = null;
this.snapshotAdvanced = true;
this.geoidNCache = new Map();
this.geoidReady = false;
}
/**
* Start reading a snapshot taken at `observedAtMs`. A fix re-sent in a
* newer snapshot marks its contact as not updated; the same snapshot read
* again does not.
* @param {number} observedAtMs
*/
beginSnapshot(observedAtMs) {
this.snapshotAdvanced =
!Number.isFinite(observedAtMs) ||
!Number.isFinite(this.lastSnapshotMs) ||
observedAtMs > this.lastSnapshotMs;
if (Number.isFinite(observedAtMs))
this.lastSnapshotMs = Math.max(
this.lastSnapshotMs ?? -Infinity,
observedAtMs,
);
}
/** Note a received fix: the newest fix time, and whether it advanced. */
_noteFix(id, prevMeta, fixEpochMs) {
const prevFixMs = prevMeta?.newestFixMs;
// A fix counts as new only when it moves on by a second: feeds that
// derive fix time from receipt (readsb `now − seen_pos`, 0.1 s) jitter.
if (!Number.isFinite(prevFixMs) || fixEpochMs >= prevFixMs + 1000) {
this.fixRepeated.delete(id);
return fixEpochMs;
}
if (this.snapshotAdvanced) this.fixRepeated.add(id);
return prevFixMs;
}
receive(
aircraft,
{ observedAtMs, floorWarmPoints: _floorWarmPoints, modelOwnsVisual },
@@ -88,6 +124,10 @@ export class MilitaryFlightRecords {
// already uses (which also carries the on-ground 0 m / last-known-baro
// case), so the two never disagree on the "no data yet" case.
let renderAltitudeM = pickedAltM != null ? pickedAltM : altitudeM;
// No altitude for an airborne contact: the height is the airborne
// default, invented. The tracked trail keeps no point there.
const renderAltitudeInvented =
pickedAltM == null && !onGround && aircraft.baroAltitudeM == null;
// Field-test fix (RS46 heli-in-hillside, 2026-07-06): a baro-only
// AIRBORNE contact near steep terrain can compute a render height
// BELOW the local surface (no alt_geom; baro+N carries QNH error
@@ -127,9 +167,11 @@ export class MilitaryFlightRecords {
// Sticky merge — adsb.lol intermittently drops flight/t/r/ownOp; hold
// last-known-good (bounded by the layer's eviction, which deletes the entry).
const stickyType = stickyText(type, prevMeta?.type);
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
const meta = {
sourceReference: aircraft.reference,
observedReceiptMs: Date.now(),
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
callsign: stickyText(callsign, prevMeta?.callsign),
type: stickyType,
// Type outranks category automatically inside classifyAircraft.
@@ -146,6 +188,7 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
renderAltitudeInvented,
// A poll that omits track/speed holds the last values, like the civil
// records; 0 only until one is known. A grounded row without a speed
// reads 0, so a parked plane never coasts at its landing speed.
@@ -184,10 +227,25 @@ export class MilitaryFlightRecords {
prevMeta,
meta,
groundFlipped,
fixEpochMs: aircraft.positionTimeMs ?? observedAtMs,
fixEpochMs,
};
}
absence(id, { complete, retainIds, likelyLanded }) {
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
const misses = (this.missingPolls.get(id) || 0) + 1;
// The tracked contact has missed an update: it is stale from the first
// one, and kept (so the camera keeps its target) until ten minutes after
// its newest fix, whatever the snapshot says. With no fix time known it
// takes the ordinary limits below.
const meta = this.data.get(id);
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
if (tracked && Number.isFinite(fixMs)) {
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
this.missingPolls.set(id, misses);
return 'stale';
}
this.missingPolls.delete(id);
return 'remove';
}
// Partial admissions and position-less rows do not prove absence, but
// retention remains bounded; a likely-landed position-less contact
// still takes the landed limit.
@@ -196,7 +254,6 @@ export class MilitaryFlightRecords {
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
)
return 'retain';
const misses = (this.missingPolls.get(id) || 0) + 1;
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
this.missingPolls.set(id, misses);
@@ -208,6 +265,7 @@ export class MilitaryFlightRecords {
forget(id) {
this.data.delete(id);
this.missingPolls.delete(id);
this.fixRepeated.delete(id);
this.geoidNCache.delete(id);
}
}
+95
View File
@@ -174,3 +174,98 @@ test('military records hold the last track and speed when a poll omits them', ()
assert.equal(parked.meta.speedMps, 0);
assert.equal(parked.meta.track, 90);
});
test('an airborne military contact reported with no altitude is marked as drawn at an invented height', () => {
const records = store();
const none = records.receive(
observation({ baroAltitudeM: null, ellipsoidAltitudeM: null }),
context(),
);
assert.equal(none.meta.renderAltitudeInvented, true);
const known = records.receive(
observation({ positionTimeMs: 1700000005000 }),
context(),
);
assert.equal(known.meta.renderAltitudeInvented, false);
const parked = store().receive(
observation({ baroAltitudeM: null, onGround: true }),
context(),
);
assert.equal(parked.meta.renderAltitudeInvented, false);
});
test('the tracked military contact is stale from its first missed update and kept until ten minutes after its newest fix', () => {
const realNow = Date.now;
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
let clock = fixMs + 5_000;
Date.now = () => clock;
try {
for (const snapshot of [
{ complete: true },
{ complete: true, retainIds: new Set(['aaa001']) },
{ complete: false },
]) {
const records = store();
records.receive(
observation({ positionTimeMs: fixMs }),
context({ observedAtMs: fixMs + 1_000 }),
);
const absent = (tracked) =>
records.absence('aaa001', {
...snapshot,
likelyLanded: false,
tracked,
});
clock = fixMs + 15_000;
assert.equal(absent(true), 'stale');
assert.equal(records.missingPolls.get('aaa001'), 1);
clock = fixMs + 9 * 60_000;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000 - 1;
assert.equal(absent(true), 'stale');
clock = fixMs + 10 * 60_000;
assert.equal(absent(true), 'remove');
assert.equal(records.missingPolls.has('aaa001'), false);
}
const records = store();
records.receive(
observation({ positionTimeMs: fixMs }),
context({ observedAtMs: fixMs + 1_000 }),
);
clock = fixMs + 15_000;
const untracked = (snapshot) =>
records.absence('aaa001', { ...snapshot, likelyLanded: false });
assert.equal(untracked({ complete: false }), 'retain');
assert.deepEqual(
[1, 2, 3].map(() => untracked({ complete: true })),
['stale', 'stale', 'remove'],
);
} finally {
Date.now = realNow;
}
});
test('a repeated military fix time marks the contact as not updated; removal is unchanged', () => {
const records = store();
const fixMs = Date.UTC(2026, 10, 9, 20, 0);
const poll = (snapshotMs, positionTimeMs) => {
records.beginSnapshot(snapshotMs);
records.receive(
observation({ positionTimeMs }),
context({ observedAtMs: snapshotMs }),
);
};
poll(fixMs + 2_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), false);
// adsb.lol re-sends the last position for up to a minute.
poll(fixMs + 12_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), true);
assert.equal(records.missingPolls.has('aaa001'), false);
poll(fixMs + 12_000, fixMs);
assert.equal(records.fixRepeated.has('aaa001'), true);
poll(fixMs + 22_000, fixMs + 10_000);
assert.equal(records.fixRepeated.has('aaa001'), false);
poll(fixMs + 32_000, fixMs + 10_000);
records.forget('aaa001');
assert.equal(records.fixRepeated.has('aaa001'), false);
});
+25 -1
View File
@@ -52,6 +52,7 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
STALE_ALPHA,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -640,6 +641,19 @@ export function createRendering({
/** Per-frame driver for the standalone tracked model (see flights.js for the rationale). The
* tracked entity stays a pure billboard, so the follow-camera never stalls/freezes on 3D-toggle. */
/** Fade the tracked 3D model like the billboard while the plane is stale. */
function _applyTrackedModelFade(model) {
const alpha = parts.tracking._trackedStaleCue?.(flightState._trackedIcao)
?.dimmed
? STALE_ALPHA
: 1;
if (model.color?.alpha === alpha) return;
model.color = (
flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR
).withAlpha(alpha);
}
function _updateTrackedModel() {
const active =
flightState._trackedIcao &&
@@ -787,6 +801,7 @@ export function createRendering({
flightState._trackedModel.modelMatrix,
spec.headingOffsetDeg,
);
_applyTrackedModelFade(flightState._trackedModel);
if (!flightState._trackedModel.ready) return;
flightState._trackedModel.scale = trackedModelScaleForPixelCap({
baseScale: spec.scale,
@@ -814,6 +829,13 @@ export function createRendering({
*/
function _fleetTick() {
// Every frame, shown or not: the tracked trail records where its icon is
// drawn and moves its body on (the head callback only reads the result),
// and the tracked label's stale cue follows the clock.
if (flightState._viewer) {
parts.tracking._trailTick?.();
parts.tracking._staleCueTick?.();
}
if (
!flightState._viewer ||
!flightState._billboardCollection ||
@@ -989,7 +1011,9 @@ export function createRendering({
flightState._cockpitContactMode && !isCockpitNear
? 1
: _militaryBillboardScale(icao24),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseAlpha: flightState.records.missingPolls.get(icao24)
? STALE_ALPHA
: 1,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
+19 -3
View File
@@ -4,6 +4,7 @@ import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
staleHoldsForFix,
synthesizeForwardKinematicsFix,
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
@@ -29,6 +30,7 @@ export function createMilitarySnapshotRenderer({
return function applySnapshot(snapshot, viewer) {
const currentIcaos = new Set();
const receiptNowMs = snapshot.observedAtMs;
records.beginSnapshot(snapshot.observedAtMs);
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
// clamp — collected during the loop (low airborne contacts only),
// batch-resolved once after it. Never a fetch inside the loop.
@@ -83,6 +85,9 @@ export function createMilitarySnapshotRenderer({
position: position.clone(),
velocity: meta.speedMps,
track: meta.track,
// Drawn at an invented height: a trail seeded from history keeps no
// position for it.
inventedHeight: meta.renderAltitudeInvented === true,
});
if (history.length > POSITION_HISTORY_LIMIT) {
history.shift();
@@ -95,26 +100,36 @@ export function createMilitarySnapshotRenderer({
// (owner): ground traffic appends too — taxi history stays live
// after touchdown; _appendTrailFix floors grounded positions at
// the surface so the ground leg drapes instead of diving.
if (isTracked) tracking._appendTrailFix(position.clone());
if (isTracked)
tracking._appendTrailFix(position.clone(), fixEpochMs, {
nullAltitude: meta.renderAltitudeInvented === true,
});
} else {
const modelOwnsGroundVisual = rendering._modelOwnsVisual(icao24);
if (!modelOwnsGroundVisual) {
liftRepeatedGroundFix(newest, position, meta.onGround);
}
// A forward synthetic fix follows the stale rules (none while the
// contact holds at its report, a coast capped from the real report,
// never below the ground) and is a motion aid, never a trail report.
const kinematicsChanged =
newest.velocity !== meta.speedMps || newest.track !== meta.track;
if (kinematicsChanged) {
const holds = staleHoldsForFix(newest, {
onGround: meta.onGround,
cachedGroundFloor,
});
if (kinematicsChanged && !holds) {
const synthetic = synthesizeForwardKinematicsFix(newest, {
epochMs: Date.now(),
velocity: meta.speedMps,
track: meta.track,
turnRateDps: meta.turnRateDps,
cachedGroundFloor,
});
if (synthetic) {
history.push(synthetic);
if (history.length > POSITION_HISTORY_LIMIT) history.shift();
meta.turnRateDps = turnRateFromFixHistory(history);
if (isTracked) tracking._appendTrailFix(synthetic.position.clone());
}
}
}
@@ -261,6 +276,7 @@ export function createMilitarySnapshotRenderer({
complete: snapshot.complete,
retainIds: snapshot.retainIds,
likelyLanded: queries._likelyLanded(icao24),
tracked: icao24 === flightState._trackedIcao,
});
if (absence === 'retain') continue;
if (absence === 'stale') {
+152
View File
@@ -0,0 +1,152 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMilitarySnapshotRenderer } from './snapshotRenderer.js';
// A poll that repeats a contact's last position with new speed or course
// must not move it past where the stale rules allow, and never adds a
// report to the tracked trail.
const ICAO = '00a005';
const SEC = 1000;
/** Any property is a no-op function unless given. */
function stub(overrides = {}) {
return new Proxy(overrides, {
get: (target, key) => (key in target ? target[key] : () => undefined),
});
}
function harness({ ground = () => null }) {
const appended = [];
const history = [];
const flightState = {
_trackedIcao: ICAO,
_trackedEntity: null,
_positionHistory: new Map([[ICAO, history]]),
_billboards: new Map([[ICAO, { position: new Cesium.Cartesian3() }]]),
_billboardCollection: stub(),
_cullPositions: new Map(),
_displayCourse: new Map(),
_groundSnap: stub(),
_displayFloorState: new Map(),
_models: new Map(),
_viewer: null,
_cockpitContactMode: false,
records: { data: new Map() },
};
let meta = null;
let fixEpochMs = 0;
const records = stub({
receive: () => {
flightState.records.data.set(ICAO, meta);
return { prevMeta: meta, meta, groundFlipped: false, fixEpochMs };
},
absence: () => 'retain',
});
const apply = createMilitarySnapshotRenderer({
flightState,
records,
militaryRegistry: stub(),
groundFloor: stub({ cachedGroundFloor: ground }),
meshFloor: stub(),
rendering: stub({
_modelOwnsVisual: () => false,
_fleetBillboardColor: () => Cesium.Color.WHITE,
}),
tracking: stub({
_militaryLayerSuppresses: () => false,
_appendTrailFix: (position, epochMs) =>
appended.push({ position, epochMs }),
}),
motion: stub(),
enrichment: stub(),
queries: stub({ _likelyLanded: () => false }),
});
return {
history,
appended,
poll(nextMeta, epochMs) {
meta = nextMeta;
fixEpochMs = epochMs;
apply({ records: [{ id: ICAO }], complete: true }, null);
},
};
}
const lightAircraft = (velocity, track) => ({
rawLat: 38.1436,
rawLon: -122.5561,
renderAltitudeM: 10,
altitudeFt: 130,
speedMps: velocity,
track,
onGround: false,
wasAirborne: true,
klass: 'light',
});
test('military: a low contact whose position repeats while its speed changes is not moved and adds nothing to the trail', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({ ground: () => -30 });
h.poll(lightAircraft(36.4, 63), t0);
const first = h.history[0].position;
for (const [i, velocity] of [30, 22, 14, 9].entries()) {
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(velocity, 63 + i * 20), t0);
}
assert.equal(h.history.length, 1, 'a synthetic fix moved a holding contact');
assert.equal(h.history[0].position, first);
assert.equal(h.appended.length, 1, 'only the real report joins the trail');
});
test('military: a cruising contact whose position repeats coasts no further than the stale cap, and the synthetic fix is not a report', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
const cruise = (velocity, track) => ({
rawLat: 36,
rawLon: -115,
renderAltitudeM: 9_000,
altitudeFt: 29_500,
speedMps: velocity,
track,
onGround: false,
wasAirborne: true,
klass: 'jet',
});
h.poll(cruise(220, 90), t0);
const report = h.history[0].position;
t.mock.timers.tick(300 * SEC);
h.poll(cruise(200, 100), t0);
assert.equal(h.history.length, 2);
const moved = Cesium.Cartesian3.distance(report, h.history[1].position);
assert.ok(
moved <= 220 * 300 + 1,
`the synthetic fix coasted ${moved.toFixed(0)} m`,
);
assert.equal(h.appended.length, 1, 'the synthetic fix joined the trail');
});
test('military: a report drawn at an invented height says so on its history fix', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll({ ...lightAircraft(60, 90), renderAltitudeInvented: true }, t0);
h.poll({ ...lightAircraft(60, 90), rawLon: -122.55 }, t0 + 10 * SEC);
assert.equal(h.history[0].inventedHeight, true);
assert.equal(h.history[1].inventedHeight, false);
});
test('military: with the ground unknown, a low barometric altitude alone does not hold a contact', (t) => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
t.mock.timers.enable({ apis: ['Date'], now: t0 });
const h = harness({});
h.poll(lightAircraft(36.4, 63), t0);
t.mock.timers.tick(10 * SEC);
h.poll(lightAircraft(30, 80), t0);
assert.equal(h.history.length, 2);
assert.equal(h.history[0].staleHolds, false);
assert.equal(h.appended.length, 1);
});
+199
View File
@@ -0,0 +1,199 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_CONTINUITY_MAX_SPEED_KT } from '../../data/trailContinuity.js';
import { RENDER_DELAY_SEC } from './policy.js';
const SEC = 1000;
function motionFixture({ info, fixes, nowMs, ground = () => null }) {
const flightState = {
records: { data: new Map([['00a003', info]]) },
_positionHistory: new Map([['00a003', fixes]]),
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchEnu: new Cesium.Matrix4(),
_scratchRenderTime: new Cesium.JulianDate(),
now: () => nowMs,
};
const motion = createMotion({
flightState,
services: {
groundFloor: { cachedGroundFloor: ground },
camera: {},
focus: {},
},
parts: {},
});
return motion;
}
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
test('a military contact stale after a low approach holds at its last report', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const ground = 1_590; // An airbase, ellipsoidal.
const fixes = [
fix(t0, 35.03, -106.58, ground + 300, 75, 260),
fix(t0 + 10 * SEC, 35.03, -106.588, ground + 40, 70, 260),
];
const info = {
altitudeFt: 5_350,
renderAltitudeM: ground + 40,
rawLat: 35.03,
rawLon: -106.588,
speedMps: 70,
track: 260,
onGround: false,
};
for (const afterSec of [10, 60, 200]) {
const motion = motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
ground: () => ground,
});
const drawn = motion._deadReckon('00a003', new Cesium.Cartesian3());
assert.ok(Cesium.Cartesian3.distance(drawn, fixes[1].position) < 0.01);
}
});
test('a cruising military contact still heard after its last position coasts past 90 s, for up to 5 minutes, then stops', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 36, -115, 9_000, 220, 90),
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 220, 90),
];
const info = {
altitudeFt: 29_500,
renderAltitudeM: 9_000,
rawLat: 36,
rawLon: -114.975,
speedMps: 220,
track: 90,
onGround: false,
lastContactEpochMs: t0 + 10 * SEC + 300 * SEC,
};
const at = (afterSec) =>
motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC,
})._deadReckon('00a003', new Cesium.Cartesian3());
const d = (p) => Cesium.Cartesian3.distance(p, fixes[1].position);
const near = (got, want) => Math.abs(got - want) < want * 0.005 + 50;
assert.ok(near(d(at(45)), 220 * 45));
assert.ok(near(d(at(250)), 220 * 250), 'still moving at 250 s');
assert.ok(near(d(at(400)), 220 * 300), 'stopped at 300 s');
});
test('a bad military speed coasts no faster than the 1,500 kt continuity limit', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [
fix(t0, 36, -115, 9_000, 2_000, 90),
fix(t0 + 10 * SEC, 36, -114.975, 9_000, 2_000, 90),
];
const info = {
altitudeFt: 29_500,
renderAltitudeM: 9_000,
rawLat: 36,
rawLon: -114.975,
speedMps: 2_000,
track: 90,
onGround: false,
};
const drawn = motionFixture({
info,
fixes,
nowMs: t0 + 10 * SEC + RENDER_DELAY_SEC * SEC + 300 * SEC,
})._deadReckon('00a003', new Cesium.Cartesian3());
const limitMps = (TRAIL_CONTINUITY_MAX_SPEED_KT * 1852) / 3600;
const d = Cesium.Cartesian3.distance(drawn, fixes[1].position);
assert.ok(Math.abs(d - limitMps * 300) < 1_500, `${d.toFixed(0)} m`);
});
test('a holding military contact draws no head and its trail ends at the last report', () => {
const t0 = Date.UTC(2026, 10, 9, 20, 0);
const fixes = [0, 1, 2].map((i) =>
fix(t0 + i * 10 * SEC, 35.03, -106.57 - i * 0.008, 1_700, 70, 260),
);
const svc = {
...Object.fromEntries(
[
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
].map((name) => [name, {}]),
),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: () => null,
floorAltitudeM: (alt) => alt,
},
};
const calls = [];
svc.trails = {
createTrail: () => ({
setSegments: (segments) => calls.push(segments.flat()),
setPositions: (positions) => calls.push(positions.slice()),
setVisible() {},
clear() {},
destroy() {},
}),
};
const flightState = createFlightState({ services: svc, source: {} });
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add: (definition) => (added.push(definition), definition),
remove() {},
},
};
flightState._trackedIcao = '00a003';
flightState.now = () => t0 + 20 * SEC + RENDER_DELAY_SEC * SEC + 120 * SEC;
flightState._positionHistory.set('00a003', fixes);
const last = Cesium.Cartesian3.clone(fixes[2].position);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => last,
_trackedDisplayPosition: () => last,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
tracking._startTrail('00a003');
assert.equal(calls.at(-1).length, 3);
const head = added.find((d) =>
String(d.id).startsWith('gev-trail:mil-head-'),
);
assert.deepEqual(head.polyline.positions.getValue(), []);
});
+25 -2
View File
@@ -178,9 +178,32 @@ export function createFlightState({ source, services }) {
flightState._trailHeadSeq = 0;
/** @type {Cesium.Cartesian3[]} Chronological tracked-aircraft fixes (oldest first) */
/** @type {{position: Cesium.Cartesian3|null, timeMs: number, nullAltitude: boolean, breakBefore: boolean, drawn?: boolean}[]}
* Chronological tracked-aircraft trail (oldest first): reports, and
* (`drawn`) where the icon was drawn between them; `breakBefore` starts a
* new segment. */
flightState._trailPositions = [];
flightState._trailFixes = [];
/** @type {number} Body fixes drawn: those at or before the display time. */
flightState._trailBodyCount = 0;
/** @type {Cesium.Cartesian3|null} Where the tracked icon was last recorded into the trail. */
flightState._trailRecordPosition = null;
/** @type {number} Display time (epoch ms) of that recording. */
flightState._trailRecordMs = -Infinity;
/** @type {string|undefined} The stale cue last written into the tracked label (see `_staleCueTick`). */
flightState._trackedStaleCueText = undefined;
/** @type {{key: string, cue: {text: string, dimmed: boolean}}|null} This frame's tracked stale cue (see `_trackedStaleCue`). */
flightState._trackedStaleCueCache = null;
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
+286 -104
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import { trackedModelZoomActive } from '../../data/trackedModelRegime.js';
@@ -19,14 +19,33 @@ import {
TRACKED_MODEL_MAX_LOAD_FAILS,
TRACKED_MODEL_RETRY_BACKOFF_MS,
TRAIL_MAX_POINTS,
DR_CORRECTION_MS,
RENDER_DELAY_SEC,
TRAIL_COLOR,
BILLBOARD_SCALE,
GROUND_SCALE,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
STALE_ALPHA,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import {
drawTrailSegments,
historyFixEpochMs,
insertTrailFix,
joinTrailBackfill,
recordTrailPoint,
trailBackfillFixes,
trailBodyCount,
trailBodyCountOutsideModel,
trailBodySegments,
trailHeadStartFix,
trailRecordingBlocked,
TRAIL_RECORD_STEP_M,
TRAIL_RECORD_STEP_MS,
} from '../../data/trailContinuity.js';
const STALE_TRACKED_COLOR = TRACKED_ICON_COLOR.withAlpha(STALE_ALPHA);
export function createTracking({
flightState,
@@ -206,24 +225,98 @@ export function createTracking({
_setCockpitContactMode(active);
}
/**
* The tracked plane's stale cue and whether it is drawn with the stale fade.
* - SIGNAL LOST · <time since its last report>, dimmed: its coast has ended
* (or it holds at its last report after a missed update) and no newer
* report has come.
* - STALE, dimmed: it has missed an update and still coasts.
* - STALE, not dimmed: the whole feed is backing off.
* @param {string} icao24
* @returns {{text: string, dimmed: boolean}}
*/
function _trackedStaleCue(icao24) {
const records = flightState.records;
const misses = records.missingPolls.get(icao24) || 0;
const repeated = records.fixRepeated?.has(icao24) === true;
const backoff = flightState.feed._backoff === true;
// Computed once per frame for the label, the icon colour and the model
// fade; a poll between frames changes the key.
const frame = flightState._viewer?.scene?.frameState?.frameNumber;
const key = Number.isFinite(frame)
? `${frame}|${icao24}|${misses}|${repeated}|${backoff}`
: null;
const cached = flightState._trackedStaleCueCache;
if (key !== null && cached?.key === key) return cached.cue;
const cue = _computeTrackedStaleCue(
icao24,
misses > 0 || repeated,
backoff,
);
flightState._trackedStaleCueCache = key === null ? null : { key, cue };
return cue;
}
/** `_trackedStaleCue` without the per-frame cache. A repeated fix counts as missed. */
function _computeTrackedStaleCue(icao24, missed, backoff) {
const lost = parts.motion._signalLost(icao24);
if (lost && (missed || !lost.holds)) {
const sec = Math.max(
0,
Math.floor((flightState.now() - lost.reportEpochMs) / 1000),
);
const time =
sec < 60
? `${formatNumber(sec, { useGrouping: false })} ${unitLabel('s')}`
: `${formatNumber(Math.floor(sec / 60), { useGrouping: false })} ${unitLabel('min')}`;
return { text: t('SIGNAL LOST · {time}', { time }), dimmed: true };
}
if (missed) return { text: t('STALE'), dimmed: true };
if (backoff) return { text: t('STALE'), dimmed: false };
return { text: '', dimmed: false };
}
/** The tracked billboard's colour: amber, faded while stale, clear while the 3D model is the visual. */
function _trackedIconColor() {
if (parts.rendering._modelOwnsVisual(flightState._trackedIcao))
return AMBER_TRANSPARENT;
return _trackedStaleCue(flightState._trackedIcao).dimmed
? STALE_TRACKED_COLOR
: TRACKED_ICON_COLOR;
}
/**
* Re-word the tracked label when its stale cue changes (STALE, SIGNAL LOST,
* each new second or minute of it). Run from the layer's per-frame pass.
*/
function _staleCueTick() {
const icao24 = flightState._trackedIcao;
if (!icao24 || !flightState._trackedEntity) return;
const text = _trackedStaleCue(icao24).text;
if (text === flightState._trackedStaleCueText) return;
flightState._trackedStaleCueText = text;
_updateTrackedLabelModel(icao24);
}
/**
* Build the multi-line presentation text for the protected tracked host card.
* Lines: callsign, type/registration, operator/altitude.
* While the plane is in its missed-poll grace (coasting on dead reckoning
* with sticky metadata) the first line carries a "· STALE" cue — the fleet's
* 45%-alpha billboard fade doesn't apply to the tracked plane (its entity
* owns the visual), so without this the readout would present last-known
* altitude/speed as live.
* The first line carries the stale cue (`_trackedStaleCue`): STALE while
* the plane coasts on dead reckoning after a missed update, SIGNAL LOST
* once the coast has ended, so last-known altitude/speed are never
* presented as live.
* @param {Object|null} info - Flight metadata from _flightData
* @param {string} icao24 - ICAO hex identifier (fallback display name)
* @returns {string} Newline-separated label text
*/
function _buildTrackedLabel(info, icao24) {
const stale =
flightState.records.missingPolls.get(icao24) || flightState.feed._backoff
? ` · ${t('STALE')}`
: '';
const cue = _trackedStaleCue(icao24).text;
const stale = cue ? ` · ${cue}` : '';
const callsign =
(parts.queries._toCleanText(info?.callsign) ||
parts.queries._toCleanText(info?.registration) ||
@@ -456,33 +549,112 @@ export function createTracking({
}
/**
* Append one fix to the tracked aircraft's trail accumulation and refresh
* the rendered trail. Caller passes an owned (cloned) Cartesian3.
* @param {Cesium.Cartesian3} position - New fix position, appended at the head.
* Put one report into the tracked aircraft's trail, in time order, and
* refresh the rendered trail. Caller passes an owned (cloned) Cartesian3. A
* report that breaks from its neighbour starts a new segment (see
* trailContinuity).
* @param {Cesium.Cartesian3} position - The report's position.
* @param {number} [epochMs] - The report's time; defaults to the tracked
* aircraft's newest position-history fix, which every caller has just pushed.
* @param {{nullAltitude?: boolean}} [options] - `nullAltitude`: the report
* had no altitude, so its position is not kept and the line breaks there.
* A report for a time the display has already passed replaces what was
* drawn after it (see `insertTrailFix`).
*/
function _appendTrailFix(position) {
flightState._trailPositions.push(_trailFloorPosition(position));
if (flightState._trailPositions.length > TRAIL_MAX_POINTS)
flightState._trailPositions.shift();
function _appendTrailFix(position, epochMs, { nullAltitude = false } = {}) {
const history = flightState._positionHistory.get(flightState._trackedIcao);
const timeMs = Number.isFinite(epochMs)
? epochMs
: historyFixEpochMs(history?.[history.length - 1]);
insertTrailFix(
flightState._trailFixes,
_trailFloorPosition(position),
timeMs,
TRAIL_MAX_POINTS,
{
nullAltitude,
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
settleMs: DR_CORRECTION_MS,
},
);
_refreshTrailDisplay();
}
/**
* Renders the trail BODY — the accumulated fixes EXCLUDING the newest raw one. That
* newest fix is at ~now, ~one poll interval AHEAD of the delayed icon (rendered at
* now − RENDER_DELAY_SEC), so drawing it would push the trail in FRONT of the plane.
* The cheap per-frame _trailHeadEntity segment bridges the last body point to the
* delayed dead-reckoned head, so this primitive only rebuilds on a real fix (poll
* cadence), never at motion cadence.
* Per-frame trail work, run from the layer's preRender pass whether or not
* the head is visible: record where the tracked icon is drawn (at most once
* a second or every 150 m, floored like the head) and move the body on as
* the display time passes reports. The head callback only reads the result.
*/
function _trailTick() {
if (!flightState._trackedIcao || !flightState._trail) return;
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
let changed = false;
if (
flightState._trailFixes.length &&
!parts.motion._isTrackWarmingUp() &&
!trailRecordingBlocked(flightState._trailFixes, displayMs)
) {
const drawn = parts.motion._trackedDisplayPosition(
flightState._trackedIcao,
);
const last = flightState._trailRecordPosition;
if (
drawn &&
(!last ||
displayMs - flightState._trailRecordMs >= TRAIL_RECORD_STEP_MS ||
Cesium.Cartesian3.distance(last, drawn) >= TRAIL_RECORD_STEP_M)
) {
flightState._trailRecordMs = displayMs;
flightState._trailRecordPosition = Cesium.Cartesian3.clone(
drawn,
flightState._trailRecordPosition ?? undefined,
);
changed = recordTrailPoint(
flightState._trailFixes,
_trailFloorPosition(Cesium.Cartesian3.clone(drawn)),
displayMs,
TRAIL_MAX_POINTS,
);
}
}
if (changed || _trailBodyCountNow() !== flightState._trailBodyCount)
_refreshTrailDisplay();
}
/**
* Renders the trail BODY — the accumulated fixes at or before the delayed display
* time (now − RENDER_DELAY_SEC). Newer fixes are AHEAD of the icon, so drawing them
* would push the trail in FRONT of the plane. The cheap per-frame _trailHeadEntity
* segment bridges the last body point to the delayed dead-reckoned head, so the
* body rebuilds only on a real fix or when the display time passes one, never at
* motion cadence. A break lifts the pen: each segment is
* its own line, nothing between them.
*/
function _refreshTrailDisplay() {
if (!flightState._trail) return;
flightState._trail.setPositions(
flightState._trailPositions.length > 1
? flightState._trailPositions.slice(0, -1)
: flightState._trailPositions,
flightState._trailBodyCount = _trailBodyCountNow();
drawTrailSegments(
flightState._trail,
trailBodySegments(flightState._trailFixes, flightState._trailBodyCount),
);
}
/** Fixes at or before the time the aircraft is drawn (now − RENDER_DELAY_SEC),
* less the newest recorded points inside its 3D model. */
function _trailBodyCountNow() {
return trailBodyCountOutsideModel(
flightState._trailFixes,
trailBodyCount(
flightState._trailFixes,
flightState.now() - RENDER_DELAY_SEC * 1000,
),
parts.motion._trackedModelCenterWorld(),
parts.motion._trackedModelEnvelopeM(),
);
}
@@ -495,25 +667,27 @@ export function createTracking({
function _startTrail(icao24) {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
const history = flightState._positionHistory.get(icao24) || [];
// Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds(
julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
// Seed every report. The body only draws those at or before the delayed
// display time (now − RENDER_DELAY_SEC); newer ones join it as the display
// passes them. A synthetic forward fix (it carries `reportEpochMs`) is a
// motion aid, never a report. A report drawn at an invented height keeps
// no position and breaks the line.
for (const fix of history) {
if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) {
// Floor grounded-history seeds (round 2): a grounded contact's stored
// fixes carry the pre-datum default height — the trail copy sits on
// the surface instead (pure lift; airborne fixes pass through).
flightState._trailPositions.push(
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
);
}
if (Number.isFinite(fix.reportEpochMs)) continue;
// Floor grounded-history seeds (round 2): a grounded contact's stored
// fixes carry the pre-datum default height — the trail copy sits on
// the surface instead (pure lift; airborne fixes pass through).
insertTrailFix(
flightState._trailFixes,
_trailFloorPosition(Cesium.Cartesian3.clone(fix.position)),
historyFixEpochMs(fix),
TRAIL_MAX_POINTS,
{ nullAltitude: fix.inventedHeight === true },
);
}
if (!flightState._trail && flightState._viewer) {
flightState._trail = createTrail(flightState._viewer, {
@@ -534,14 +708,13 @@ export function createTracking({
show: !flightState._cockpitContactMode,
polyline: {
positions: new Cesium.CallbackProperty(() => {
// Need ≥2 accumulated points: the body draws all-but-newest, so the head must
// start at the last DISPLAYED body point (index n−2). With a single fix that
// point would be the sole raw fix — ~now, AHEAD of the delayed icon — so the
// segment would draw IN FRONT of the plane. Likewise during warm-up the icon
// predates all real history, so there is no valid body point behind it.
// The head starts at the last DISPLAYED body point, the newest fix at or
// before the delayed display time; a fix ahead of the icon would draw the
// segment IN FRONT of the plane. During warm-up the icon predates all real
// history, so there is no valid body point behind it.
if (
!flightState._trackedIcao ||
flightState._trailPositions.length < 2 ||
!flightState._trailFixes.length ||
parts.motion._isTrackWarmingUp()
)
return [];
@@ -549,12 +722,20 @@ export function createTracking({
parts.motion._trackedTrailCached() ||
parts.motion._trackedDisplayPosition(flightState._trackedIcao);
if (!head) return [];
// body[n−2] (last displayed body point) → delayed head: runs FORWARD, never a
// backward/reversing segment.
const start =
flightState._trailPositions[
flightState._trailPositions.length - 2
];
// The last body point (a report, or where the icon was drawn a
// moment ago) → the icon. The body is moved on by _trailTick, never
// here: a callback must not add or remove entities.
// No head across a break: a body point more than 150 km away, or
// too long ago or too far to be one path, stays detached; none while
// the aircraft holds at its last point.
const last = trailHeadStartFix(
flightState._trailFixes,
head,
flightState.now(),
flightState._trailBodyCount,
);
if (!last) return [];
const start = last.position;
// Floor the head too (round 2): a grounded tracked contact's DR
// display height is the pre-datum default — without this the last
// segment dives underground while taxiing.
@@ -595,10 +776,10 @@ export function createTracking({
}
/**
* Fire-and-forget optional source history backfill (PRD F2). Trace points are
* stride-thinned to the remaining vertex budget, then spliced strictly older
* than the oldest seeded fix AHEAD of the locally accumulated fine segment,
* capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
* Fire-and-forget optional source history backfill (PRD F2). Every trace
* point strictly older than the oldest seeded fix is spliced AHEAD of the
* locally accumulated fine segment, each leg its own segment, capped at
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/
* malformed) silently keeps the local-only trail.
* @param {string} icao24 - ICAO hex identifier being tracked.
* @param {number} token - Backfill token captured at request time.
@@ -644,7 +825,13 @@ export function createTracking({
lon = point.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM });
parsed.push({
lat,
lon,
baroAltitudeM: point.baroAltitudeM,
onGround: point.onGround === true,
timeMs: point.observedAtMs,
});
}
if (!parsed.length) return;
@@ -667,39 +854,34 @@ export function createTracking({
)
return;
let older = [];
let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet
for (const { lat, lon, baroAltitudeM } of parsed) {
const baroM =
baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon);
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// Ground/no-alt point with an unresolved floor: hold the previous
// waypoint's altitude (continuity — never a dive to a made-up depth).
// Leading points with nothing to carry keep the old low breadcrumb
// sentinel (an arbitrary placeholder, never a reported altitude).
if (altM == null) altM = lastAltM != null ? lastAltM : 50;
lastAltM = altM;
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, altM));
}
// Stride-thin the backfill so it plus the live fine segment fit the cap.
const budget = Math.max(
1,
TRAIL_MAX_POINTS - flightState._trailPositions.length,
// Every trace point is kept, each leg its own segment. A 'ground' point
// is at altitude 0, floored at the ground. A point with no altitude is a
// break on both sides: it sits on the ground where the floor is known and
// is skipped otherwise; a skipped point still breaks the line.
const backfill = trailBackfillFixes(
parsed.map(({ lat, lon, baroAltitudeM, onGround, timeMs }) => {
const known = Number.isFinite(baroAltitudeM)
? baroAltitudeM
: onGround
? 0
: null;
const altM = floorAltitudeM(
known == null ? null : known + geoidHeight(lat, lon),
cachedGroundFloor(lat, lon),
);
return {
position:
altM == null ? null : Cesium.Cartesian3.fromDegrees(lon, lat, altM),
timeMs,
nullAltitude: known == null,
};
}),
);
flightState._trailFixes = joinTrailBackfill(
backfill,
flightState._trailFixes,
TRAIL_MAX_POINTS,
);
if (older.length > budget) {
const stride = Math.ceil(older.length / budget);
const thinned = [];
for (let i = 0; i < older.length; i += stride) thinned.push(older[i]);
older = thinned;
}
flightState._trailPositions = older.concat(flightState._trailPositions);
if (flightState._trailPositions.length > TRAIL_MAX_POINTS) {
flightState._trailPositions = flightState._trailPositions.slice(
flightState._trailPositions.length - TRAIL_MAX_POINTS,
);
}
_refreshTrailDisplay();
}
@@ -709,7 +891,9 @@ export function createTracking({
function _clearTrail() {
flightState._trailBackfillToken += 1;
flightState._trailPositions = [];
flightState._trailFixes = [];
flightState._trailRecordPosition = null;
flightState._trailRecordMs = -Infinity;
if (flightState._trail) flightState._trail.clear();
if (
flightState._trailHeadEntity &&
@@ -926,15 +1110,9 @@ export function createTracking({
(CLASS_SCALE_2D[
flightState.records.data.get(flightState._trackedIcao)?.klass
] || 1),
// Solid amber when the billboard is the visual (zoomed out, 3D off, or model still loading);
// transparent once the STANDALONE tracked model is actually up (ready + shown).
color: new Cesium.CallbackProperty(
() =>
parts.rendering._modelOwnsVisual(flightState._trackedIcao)
? AMBER_TRANSPARENT
: TRACKED_ICON_COLOR,
false,
),
// Solid amber (faded while stale) when the billboard is the visual (zoomed out, 3D off,
// or model still loading); transparent once the STANDALONE tracked model is actually up.
color: new Cesium.CallbackProperty(_trackedIconColor, false),
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8000000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
@@ -1157,6 +1335,9 @@ export function createTracking({
_setCockpitContactMode,
_applyCockpitState,
_buildTrackedLabel,
_trackedStaleCue,
_trackedIconColor,
_staleCueTick,
_updateTrackedLabelModel,
_syncTrackedBillboardImage,
_refreshTr3bContact,
@@ -1168,6 +1349,7 @@ export function createTracking({
_syncTracked2dRotation,
_trailFloorPosition,
_appendTrailFix,
_trailTick,
_refreshTrailDisplay,
_startTrail,
_backfillTrail,
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { TRAIL_MAX_POINTS } from './policy.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
function services({ trails, floor = () => null } = {}) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: floor,
floorAltitudeM: (alt, ground) =>
Number.isFinite(alt) && Number.isFinite(ground)
? Math.max(alt, ground + 1)
: Number.isFinite(alt)
? alt
: Number.isFinite(ground)
? ground + 1
: null,
},
};
}
/** A fake trail that records what draws: segments of two or more points. */
function recordingTrail() {
const calls = [];
return {
last: () =>
(calls[calls.length - 1] ?? []).filter((segment) => segment.length >= 2),
createTrail: () => ({
setSegments(segments) {
calls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
calls.push([positions.slice()]);
},
setVisible() {},
clear() {
calls.push([]);
},
destroy() {},
}),
};
}
function trackedHarness({ liveFixes = [], trace = null, nowMs, floor, head }) {
const trail = recordingTrail();
const svc = services({ trails: trail, floor });
const flightState = createFlightState({
services: svc,
source: {
label: 'Fixture military aircraft',
async getTrack() {
if (!trace) throw new Error('404');
return { records: trace };
},
},
});
const added = [];
flightState._viewer = {
isDestroyed: () => false,
entities: {
add(definition) {
added.push(definition);
return definition;
},
remove() {},
},
};
flightState._trackedIcao = 'ae0001';
flightState.now = () => nowMs;
flightState._positionHistory.set(
'ae0001',
liveFixes.map((fix) => ({
time: Cesium.JulianDate.fromDate(new Date(fix.timeMs)),
epochMs: fix.timeMs,
position: Cesium.Cartesian3.fromDegrees(fix.lon, fix.lat, 9_000),
})),
);
const tracking = createTracking({
flightState,
services: svc,
parts: {
motion: {
_isTrackWarmingUp: () => false,
_trackedTrailCached: () => head ?? null,
_trackedDisplayPosition: () => head ?? null,
_trackedModelCenterWorld: () => null,
_trackedModelEnvelopeM: () => null,
},
},
layer: {},
});
const headPositions = () =>
added
.find((definition) =>
String(definition.id).startsWith('gev-trail:mil-head-'),
)
.polyline.positions.getValue();
return { trail, tracking, flightState, headPositions };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** readsb-shaped trace points as the military source parses them. */
function point(timeMs, lat, lon, baroAltitudeM = 9_000, onGround = false) {
return {
observedAtMs: timeMs,
latitude: lat,
longitude: lon,
baroAltitudeM,
onGround,
};
}
function leg(startMs, lat, lon, count) {
return Array.from({ length: count }, (_, i) =>
point(startMs + i * MINUTE, lat, lon + i * 0.12),
);
}
test('a multi-leg military trace becomes separate segments with no line across a gap', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const first = leg(t0, 36, -115, 5);
const second = leg(t0 + 5 * MINUTE + 3 * HOUR, 35, -106, 5);
const third = leg(t0 + 10 * MINUTE + 5 * HOUR, 38, -77, 5);
const trace = [...first, ...second, ...third];
const liveStart = t0 + 15 * MINUTE + 5 * HOUR;
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: liveStart + i * MINUTE,
lat: 38,
lon: -77 + 0.6 + i * 0.12,
}));
const { trail, flightState, tracking } = trackedHarness({
liveFixes,
trace,
nowMs: liveStart + 3 * MINUTE,
});
tracking._startTrail('ae0001');
await settle();
assert.equal(flightState._trailFixes.length, trace.length + 3);
assert.deepEqual(
trail.last().map((segment) => segment.length),
[5, 5, 8],
);
});
test('a military trace point with no altitude breaks the line and is never given a made-up height', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const trace = leg(t0, 35, -106, 7);
trace[3] = point(trace[3].observedAtMs, 35, -105.64, null);
const liveFixes = [{ timeMs: t0 + 7 * MINUTE, lat: 35, lon: -105.16 }];
const { trail, flightState, tracking } = trackedHarness({
liveFixes,
trace,
nowMs: t0 + 9 * MINUTE,
});
tracking._startTrail('ae0001');
await settle();
// Skipped (no floor known): 3 + 3 + the live fix drawn.
assert.equal(flightState._trailFixes.length, trace.length - 1 + 1);
assert.deepEqual(
trail.last().map((segment) => segment.length),
[3, 4],
);
for (const fix of flightState._trailFixes) {
const height = Cesium.Cartographic.fromCartesian(fix.position).height;
assert.ok(Math.abs(height - 9_000) < 1, `no invented height: ${height}`);
}
});
test('a military ground point is drawn on the ground and stays joined', async () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
// Taxiing: 'ground' points a few hundred metres apart, one minute apart.
const trace = Array.from({ length: 4 }, (_, i) =>
point(t0 + i * MINUTE, 35, -106.6 + i * 0.004, null, true),
);
const liveFixes = [{ timeMs: t0 + 4 * MINUTE, lat: 35, lon: -106.584 }];
const { trail, flightState, tracking } = trackedHarness({
liveFixes,
trace,
nowMs: t0 + 6 * MINUTE,
floor: () => 1_590,
});
tracking._startTrail('ae0001');
await settle();
assert.deepEqual(
trail.last().map((segment) => segment.length),
[5],
);
const height = Cesium.Cartographic.fromCartesian(
flightState._trailFixes[0].position,
).height;
assert.ok(Math.abs(height - 1_591) < 1);
});
test('the military head is drawn to a nearby aircraft and not to one more than 150 km away', () => {
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const liveFixes = [0, 1, 2].map((i) => ({
timeMs: t0 + i * MINUTE,
lat: 35,
lon: -106 + i * 0.12,
}));
const near = Cesium.Cartesian3.fromDegrees(-105.7, 35, 9_000);
const joined = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: near,
});
joined.tracking._startTrail('ae0001');
assert.equal(joined.headPositions().length, 2);
const far = Cesium.Cartesian3.fromDegrees(-103, 35, 9_000);
const detached = trackedHarness({
liveFixes,
nowMs: t0 + 3 * MINUTE,
head: far,
});
detached.tracking._startTrail('ae0001');
assert.deepEqual(detached.headPositions(), []);
});
test('a full 24 h military trace fits within TRAIL_MAX_POINTS without thinning', async () => {
const day = 24 * 60;
const t0 = Date.UTC(2026, 10, 8, 0, 0);
const trace = leg(t0, 20, -150, day);
const liveStart = t0 + day * MINUTE;
const liveFixes = [{ timeMs: liveStart, lat: 20, lon: -150 + day * 0.12 }];
const { flightState, tracking } = trackedHarness({
liveFixes,
trace,
nowMs: liveStart + MINUTE,
});
tracking._startTrail('ae0001');
await settle();
assert.ok(TRAIL_MAX_POINTS >= day + 500);
assert.equal(flightState._trailFixes.length, day + 1);
});
+697
View File
@@ -0,0 +1,697 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createMotion } from './motion.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
import { RENDER_DELAY_SEC, STALE_ALPHA } from './policy.js';
// A tracked military contact's trail between reports is the path its icon
// was drawn along, recorded from the real display path frame by frame.
const SEC = 1000;
const ICAO = '00a004';
const T0 = Date.UTC(2026, 10, 9, 21, 0);
function fix(timeMs, lat, lon, heightM, velocity, track) {
return {
time: Cesium.JulianDate.fromDate(new Date(timeMs)),
epochMs: timeMs,
position: Cesium.Cartesian3.fromDegrees(lon, lat, heightM),
velocity,
track,
};
}
function services(trail, ground) {
const names = [
'picking',
'sprites',
'aircraftPresentation',
'camera',
'militaryRegistry',
'labels',
'meshFloor',
'focus',
'readout',
'context',
'render',
'recession',
];
return {
...Object.fromEntries(names.map((name) => [name, {}])),
aircraftPresentation: {
isTr3b: () => false,
tr3bTypeLabel: (id, label) => label,
},
focus: {
nearFarScalarValueAtDistance: () => 1,
clearFocusTarget() {},
publishFocusTargetFromCachedPosition() {},
},
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
trails: trail,
geoid: { ensureGeoidReady: async () => {}, geoidHeight: () => 0 },
groundFloor: {
resolveGroundFloorCellsBounded: async () => {},
cachedGroundFloor: ground,
floorAltitudeM: (alt, g) =>
Number.isFinite(g) && Number.isFinite(alt)
? Math.max(alt, g + 1.5)
: alt,
},
};
}
/**
* Real motion, real display position and real tracking over one contact.
* `frame()` runs one rendered frame: the layer's preRender work, then the
* head callback, as Cesium does.
*/
function harness({ fixes, info, nowMs, ground = () => null }) {
const segmentsCalls = [];
let inHead = false;
const trail = {
createTrail: () => ({
setSegments(segments) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push(segments.map((segment) => segment.slice()));
},
setPositions(positions) {
assert.ok(
!inHead,
'the trail body was rebuilt inside the head callback',
);
segmentsCalls.push([positions.slice()]);
},
setVisible() {},
clear() {},
destroy() {},
}),
};
const svc = services(trail, ground);
const flightState = createFlightState({ services: svc, source: {} });
flightState.records.data.set(ICAO, info);
let frameNumber = 0;
const added = [];
let entityChanges = 0;
flightState._viewer = {
isDestroyed: () => false,
scene: {
frameState: {
get frameNumber() {
return frameNumber;
},
},
},
entities: {
add(definition) {
entityChanges += 1;
added.push(definition);
return definition;
},
remove() {
entityChanges += 1;
},
},
};
flightState._trackedIcao = ICAO;
let clock = nowMs;
flightState.now = () => clock;
flightState._positionHistory.set(ICAO, fixes);
const parts = {
rendering: { _modelOwnsVisual: () => false, _modelSpec: () => ({}) },
queries: {
_toCleanText: (value) => (typeof value === 'string' ? value.trim() : ''),
_formatAltitude: (ft) => `${ft} ft`,
},
};
parts.motion = createMotion({ flightState, services: svc, parts });
const tracking = createTracking({
flightState,
services: svc,
parts,
layer: {},
});
parts.tracking = tracking;
const realDateNow = Date.now;
Date.now = () => clock;
const headEntity = () =>
added.find((definition) =>
String(definition.id).startsWith('gev-trail:mil-head-'),
);
const head = () => {
const before = entityChanges;
inHead = true;
try {
return headEntity().polyline.positions.getValue();
} finally {
inHead = false;
assert.equal(
entityChanges,
before,
'the head callback added or removed an entity',
);
}
};
/** Drawn icon positions, one per frame. */
const drawn = [];
const frame = ({ readHead = true } = {}) => {
frameNumber += 1;
tracking._trailTick();
const icon = parts.motion._trackedDisplayPosition(ICAO);
if (icon) drawn.push(Cesium.Cartesian3.clone(icon));
if (readHead) head();
};
const run = (toMs, { frameMs = 100, readHead = true } = {}) => {
while (clock < toMs) {
clock = Math.min(toMs, clock + frameMs);
frame({ readHead });
}
};
return {
flightState,
tracking,
motion: parts.motion,
head,
drawn,
run,
body: () => segmentsCalls.at(-1) ?? [],
bodyPoints: () => (segmentsCalls.at(-1) ?? []).flat(),
restore: () => {
Date.now = realDateNow;
},
};
}
/** Distance from p to the nearest point of a polyline. */
function distanceToPath(p, path) {
let best = Infinity;
const ab = new Cesium.Cartesian3();
const ap = new Cesium.Cartesian3();
const closest = new Cesium.Cartesian3();
if (path.length === 1) return Cesium.Cartesian3.distance(p, path[0]);
for (let i = 1; i < path.length; i++) {
Cesium.Cartesian3.subtract(path[i], path[i - 1], ab);
Cesium.Cartesian3.subtract(p, path[i - 1], ap);
const len2 = Cesium.Cartesian3.magnitudeSquared(ab);
const t =
len2 > 0
? Math.max(0, Math.min(1, Cesium.Cartesian3.dot(ap, ab) / len2))
: 0;
Cesium.Cartesian3.add(
path[i - 1],
Cesium.Cartesian3.multiplyByScalar(ab, t, closest),
closest,
);
best = Math.min(best, Cesium.Cartesian3.distance(p, closest));
}
return best;
}
/** No vertex turns the line back sharper than 120° over a short step. */
function assertNoSpur(line, label) {
const u = new Cesium.Cartesian3();
const v = new Cesium.Cartesian3();
for (let i = 1; i < line.length - 1; i++) {
Cesium.Cartesian3.subtract(line[i], line[i - 1], u);
Cesium.Cartesian3.subtract(line[i + 1], line[i], v);
const lu = Cesium.Cartesian3.magnitude(u);
const lv = Cesium.Cartesian3.magnitude(v);
if (!(lu > 0 && lv > 0) || Math.min(lu, lv) > 500) continue;
const cos = Cesium.Cartesian3.dot(u, v) / (lu * lv);
assert.ok(
cos >= -0.5,
`${label}: spur at vertex ${i} of ${line.length} (turn ${(
(Math.acos(cos) * 180) /
Math.PI
).toFixed(0)}°)`,
);
}
}
const indexOfPoint = (line, p) =>
line.findIndex((q) => Cesium.Cartesian3.distance(p, q) < 0.01);
// A departing jet: four northbound climb reports, then a left turn through
// west to south with no report while the icon flies it.
function departure() {
const fixes = [
fix(T0, 37.6329, -122.3661, 213, 85, 8),
fix(T0 + 30 * SEC, 37.6542, -122.3616, 495, 95, 0),
fix(T0 + 60 * SEC, 37.6813, -122.3615, 808, 104, 350),
fix(T0 + 90 * SEC, 37.7066, -122.3755, 1257, 112, 340),
];
const info = {
altitudeFt: 4_124,
renderAltitudeM: 1257,
rawLat: 37.7066,
rawLon: -122.3755,
speedMps: 112,
track: 340,
turnRateDps: -2,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 200 * SEC,
};
return { fixes, info };
}
test('military: a report newer than the display time arrives mid-turn, and the trail between the reports stays on the drawn arc', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
// 40 s of turn with no new report: the icon flies the arc.
h.run(start + 40 * SEC);
const arc = h.drawn.slice();
// The next report is where the plane really was 60 s after the last one,
// still ahead of the display time (normal 30 s delay, slow feed). The icon
// now interpolates the straight line between the two reports and is eased
// onto it.
const at60 = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 150 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(at60);
const report = fix(
T0 + 150 * SEC,
Cesium.Math.toDegrees(c.latitude),
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
220,
);
fixes.push(report);
info.track = 220;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// The display passes the new report.
h.run(start + 75 * SEC);
const line = h.bodyPoints();
const from = indexOfPoint(line, fixes[3].position);
const to = indexOfPoint(line, report.position);
assert.ok(from >= 0 && to > from, 'both reports are on the trail');
const between = line.slice(from, to + 1);
// Every vertex between the reports is where the icon was drawn...
const drawnPath = [fixes[3].position, ...h.drawn, report.position];
for (const vertex of between) {
const off = distanceToPath(vertex, drawnPath);
assert.ok(
off < 50,
`trail vertex ${off.toFixed(0)} m off the drawn path`,
);
}
// ...and the arc the icon flew is still there: no straight chord. (The
// arc strays far from the chord, so a chord would fail this.)
const chord = [fixes[3].position, report.position];
assert.ok(Math.max(...arc.map((p) => distanceToPath(p, chord))) > 300);
// The last 10 s of the arc are exempt: the correction pulled the icon back
// over them, and keeping them would fold the line back on itself.
for (const point of arc.slice(0, -100)) {
const off = distanceToPath(point, between);
assert.ok(off < 50, `drawn arc point ${off.toFixed(0)} m from the trail`);
}
assertNoSpur(line, 'trail');
assertNoSpur(h.head(), 'head');
} finally {
h.restore();
}
});
test('military: a report the display already passed replaces the guess drawn after it, with no spike', () => {
const { fixes, info } = departure();
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
h.run(start + 40 * SEC);
const displayMs = T0 + 130 * SEC;
// A report for 10 s before the display time, 400 m inside the drawn arc.
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(T0 + 120 * SEC)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
T0 + 120 * SEC,
Cesium.Math.toDegrees(c.latitude) - 400 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
112,
250,
);
fixes.push(report);
info.track = 250;
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
h.run(start + 55 * SEC);
const recorded = h.flightState._trailFixes.filter((entry) => entry.drawn);
// Nothing recorded between the report and the end of the ease.
assert.deepEqual(
recorded
.filter(
(entry) =>
entry.timeMs > report.epochMs && entry.timeMs < displayMs + 900,
)
.map((entry) => entry.timeMs - T0),
[],
);
const line = h.bodyPoints();
assert.ok(indexOfPoint(line, report.position) >= 0);
assertNoSpur(line, 'trail');
} finally {
h.restore();
}
});
test('military: a history report at an invented height is seeded with no position, and nothing is recorded next to it', () => {
const { fixes, info } = departure();
fixes[3].inventedHeight = true;
const start = T0 + 90 * SEC + RENDER_DELAY_SEC * SEC;
const h = harness({ fixes, info, nowMs: start });
try {
h.tracking._startTrail(ICAO);
const seeded = h.flightState._trailFixes.find(
(entry) => entry.timeMs === fixes[3].epochMs,
);
assert.equal(seeded.position, null);
assert.equal(seeded.nullAltitude, true);
h.run(start + 10 * SEC);
assert.equal(
h.flightState._trailFixes.filter((entry) => entry.drawn).length,
0,
);
} finally {
h.restore();
}
});
// A turboprop at FL210 flying east out of receiver coverage: its last
// position is at T0 + 30 s, and contact goes on for four more minutes.
function outOfCoverage() {
const fixes = [
fix(T0, 43, -108.1, 6_400, 140, 90),
fix(T0 + 30 * SEC, 43, -108.05, 6_400, 140, 90),
];
const info = {
altitudeFt: 21_000,
renderAltitudeM: 6_400,
rawLat: 43,
rawLon: -108.05,
speedMps: 140,
track: 90,
callsign: 'TEST7',
turnRateDps: 0,
onGround: false,
wasAirborne: true,
lastContactEpochMs: T0 + 30 * SEC + 240 * SEC,
};
return { fixes, info };
}
/** Display time (ms) `afterSec` past the last report. */
const pastReport = (afterSec) =>
T0 + 30 * SEC + RENDER_DELAY_SEC * SEC + afterSec * SEC;
test('military: a plane that stops reporting keeps moving dimmed as STALE from its first missed update, then stops as SIGNAL LOST', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(30));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
assert.equal(h.tracking._trackedIconColor().alpha, 1);
// The first missed update.
h.flightState.records.missingPolls.set(ICAO, 1);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.match(
h.tracking._buildTrackedLabel(h.flightState.records.data.get(ICAO), ICAO),
/STALE/,
);
// Drawn with the fleet's stale fade.
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
// Still moving well past 90 s.
h.run(pastReport(200));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-11)) > 100,
);
// The coast ends 300 s past the report; the plane stops there.
h.run(pastReport(310));
const recorded = h.flightState._trailFixes.filter((e) => e.drawn).length;
h.run(pastReport(330));
assert.ok(
Cesium.Cartesian3.distance(h.drawn.at(-1), h.drawn.at(-50)) < 0.01,
);
assert.equal(
h.flightState._trailFixes.filter((e) => e.drawn).length,
recorded,
'the trail kept recording a stopped plane',
);
// 330 s of display past the report is 330 s + the display delay of wall time.
const minutes = Math.floor((330 + RENDER_DELAY_SEC) / 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${minutes} min`,
dimmed: true,
});
assert.match(
h.tracking._buildTrackedLabel(h.flightState.records.data.get(ICAO), ICAO),
new RegExp(`SIGNAL LOST · ${minutes} min`),
);
} finally {
h.restore();
}
});
test('military: a plane held at its last report shows SIGNAL LOST in seconds under a minute', () => {
// Its last report is 100 m above a known ground height: it holds there.
const { fixes, info } = outOfCoverage();
const h = harness({
fixes,
info,
nowMs: pastReport(-5),
ground: () => 6_300,
});
try {
h.tracking._startTrail(ICAO);
h.flightState.records.missingPolls.set(ICAO, 1);
h.run(pastReport(10));
assert.ok(10 + RENDER_DELAY_SEC < 60);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: `SIGNAL LOST · ${10 + RENDER_DELAY_SEC} s`,
dimmed: true,
});
} finally {
h.restore();
}
});
test('military: a report arriving 200 s into a coast: the plane eases onto it and the trail drops what was drawn after it', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(200));
// Heard again: the report is for 10 s before the display time, 2 km north
// of where the coast put it.
const at = T0 + 30 * SEC + 190 * SEC;
const guess = h.motion._deadReckon(
ICAO,
new Cesium.Cartesian3(),
Cesium.JulianDate.fromDate(new Date(at)),
);
const c = Cesium.Cartographic.fromCartesian(guess);
const report = fix(
at,
Cesium.Math.toDegrees(c.latitude) + 2_000 / 111_320,
Cesium.Math.toDegrees(c.longitude),
c.height,
140,
90,
);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
assert.deepEqual(
h.flightState._trailFixes.filter(
(e) => e.drawn && e.timeMs > report.epochMs,
),
[],
);
const from = h.drawn.length;
h.run(pastReport(203));
const after = h.drawn.slice(from - 1);
for (let i = 1; i < after.length; i++)
assert.ok(
Cesium.Cartesian3.distance(after[i], after[i - 1]) < 400,
'the plane jumped instead of easing',
);
// Eased onto the path from the report: 13 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(after.at(-1), report.position);
assert.ok(
Math.abs(off - 140 * 13) < 150,
`${off.toFixed(0)} m from the report`,
);
} finally {
h.restore();
}
});
test('military: a tracked plane that stops reporting: STALE from the first missed poll, SIGNAL LOST after the five-minute coast, kept at 9 min, removed at 10 min', () => {
const { fixes, info } = outOfCoverage();
const fixMs = fixes.at(-1).epochMs;
// Contact stops with the position: the plane left coverage.
info.lastContactEpochMs = fixMs;
info.newestFixMs = fixMs;
const h = harness({ fixes, info, nowMs: fixMs + 5 * SEC });
try {
h.tracking._startTrail(ICAO);
const records = h.flightState.records;
const polls = new Map();
let removedAtSec = null;
// A poll every 10 s that lists the plane without a position.
for (let afterSec = 10; afterSec <= 660; afterSec += 10) {
h.run(fixMs + afterSec * SEC, { frameMs: 500, readHead: false });
const result = records.absence(ICAO, {
complete: true,
retainIds: new Set([ICAO]),
likelyLanded: false,
tracked: true,
});
if (result === 'remove') {
removedAtSec = afterSec;
break;
}
polls.set(afterSec, {
cue: h.tracking._trackedStaleCue(ICAO),
drawn: Cesium.Cartesian3.clone(h.drawn.at(-1)),
});
}
// Wall time when the display reaches the end of the coast.
const coastEndSec = 300 + RENDER_DELAY_SEC;
for (const [afterSec, { cue }] of polls) {
if (afterSec <= coastEndSec) {
assert.deepEqual(cue, { text: 'STALE', dimmed: true }, `${afterSec} s`);
} else {
assert.deepEqual(
cue,
{
text: `SIGNAL LOST · ${Math.floor(afterSec / 60)} min`,
dimmed: true,
},
`${afterSec} s`,
);
}
}
const moved = (a, b) =>
Cesium.Cartesian3.distance(polls.get(a).drawn, polls.get(b).drawn);
// Moving through the coast, still at its last speed at four minutes.
assert.ok(Math.abs(moved(230, 240) - 140 * 10) < 20);
// Stopped once the coast has ended.
const after = Math.ceil(coastEndSec / 10) * 10;
assert.ok(moved(after, 540) < 0.01);
assert.equal(polls.get(300).cue.text, 'STALE');
assert.deepEqual(polls.get(540).cue, {
text: 'SIGNAL LOST · 9 min',
dimmed: true,
});
assert.equal(removedAtSec, 600);
} finally {
h.restore();
}
});
test('military: a repeated old fix counts as a missed update for the tracked plane', () => {
const { fixes, info } = outOfCoverage();
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(-2));
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: '',
dimmed: false,
});
// The feed re-sends the last position: its fix time did not advance.
h.flightState.records.fixRepeated.add(ICAO);
assert.deepEqual(h.tracking._trackedStaleCue(ICAO), {
text: 'STALE',
dimmed: true,
});
assert.equal(h.tracking._trackedIconColor().alpha, STALE_ALPHA);
assert.equal(h.flightState.records.missingPolls.has(ICAO), false);
} finally {
h.restore();
}
});
test('military: a report arriving after the plane has stopped: it eases in with no spur, and the trail keeps everything up to the stop', () => {
const { fixes, info } = outOfCoverage();
info.lastContactEpochMs = fixes.at(-1).epochMs;
const h = harness({ fixes, info, nowMs: pastReport(-5) });
try {
h.tracking._startTrail(ICAO);
h.run(pastReport(360));
const stop = Cesium.Cartesian3.clone(h.drawn.at(-1));
assert.ok(Cesium.Cartesian3.distance(stop, h.drawn.at(-100)) < 0.01);
const kept = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => Cesium.Cartesian3.clone(e.position));
// The thinned straight coast, recorded up to where the plane stopped.
assert.ok(kept.length >= 2);
assert.ok(Cesium.Cartesian3.distance(kept.at(-1), stop) < 300);
// Heard again, for 20 s before the display time: the plane flew on, a
// little north of its old heading.
const at = T0 + 30 * SEC + 340 * SEC;
const lonDeg =
-108.05 + (140 * 340) / (111_320 * Math.cos((43 * Math.PI) / 180));
const report = fix(at, 43 + 1_000 / 111_320, lonDeg, 6_400, 140, 90);
fixes.push(report);
h.tracking._appendTrailFix(
Cesium.Cartesian3.clone(report.position),
report.epochMs,
);
// Nothing was recorded after the stop, so nothing is dropped.
const after = h.flightState._trailFixes
.filter((e) => e.drawn)
.map((e) => e.position);
assert.equal(after.length, kept.length);
kept.forEach((p, i) =>
assert.ok(Cesium.Cartesian3.distance(p, after[i]) < 0.01),
);
const from = h.drawn.length;
h.run(pastReport(365));
const eased = h.drawn.slice(from - 1);
const jump = Cesium.Cartesian3.distance(eased[0], eased.at(-1));
for (let i = 1; i < eased.length; i++)
assert.ok(
Cesium.Cartesian3.distance(eased[i], eased[i - 1]) < jump / 2,
'the plane jumped instead of easing',
);
// On the path from the report: 25 s on from it at 140 m/s.
const off = Cesium.Cartesian3.distance(eased.at(-1), report.position);
assert.ok(Math.abs(off - 140 * 25) < 150, `${off.toFixed(0)} m`);
assertNoSpur(h.bodyPoints(), 'trail');
assertNoSpur(h.head(), 'head');
assert.ok(indexOfPoint(h.bodyPoints(), report.position) >= 0);
} finally {
h.restore();
}
});