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

@@ -0,0 +1,7 @@
- A tracked flight's trail splits at feed gaps, impossible jumps and reports
without altitude instead of joining a day of history into one line, keeps
every history point, and follows the path the aircraft was drawn along so it
no longer cuts across turns. A tracked aircraft that stops reporting shows
STALE, keeps flying on its last heading for five minutes, then stops dimmed
as SIGNAL LOST and is kept ten minutes after its last report; aircraft on or
near the ground hold instead (Sameh Khamis, @samehkhamis, #1654).
+77 -30
View File
@@ -1444,6 +1444,52 @@ use the supplied asset resolver, including the preload path. Enrichment receives
an abort signal and cannot update a later lifecycle after destruction. Existing
camera, terrain floor, trail, selection and measured model-size policies remain.
A tracked civil or military aircraft's trail (`src/data/trailContinuity.js`) lifts
the pen, drawing nothing, between two points more than 48 minutes or over 1,500 kt
apart (a parked aircraft within 0.1 nm keeps one line for up to three hours) and at a
waypoint with no altitude; a ground waypoint is drawn on the ground. The head joins the
aircraft only within 150 km. Each trail keeps up to 2,000 points, a full day of history
at one fix per minute plus the live session; past that the oldest recorded points go
first, and the oldest reports only once none are left. The renderer
(`src/data/trailRenderer.js`) freezes every chunk of 128 points except the newest into
static geometry, so a long trail costs about as much per frame as a short one;
`scripts/qa-trail-freeze.mjs` checks that a freeze never shows a blank frame.
While an aircraft is tracked, its trail between reports is the path its icon was
drawn along: the displayed position is recorded about once a second or every 150 m
(`recordTrailPoint`). Recorded points are thinned by shape: one stays only where
dropping it would move the line more than 30 m across the track or in height from any
position it stood for, or leave a straight piece longer than 10 km, so straight flight
adds a vertex every 10 km at most and a turn keeps its curve. A recorded point that
would turn the line back sharper than 120° over a short step is dropped. Reports stay
in the trail as anchors, in time order. Recorded points never join two reports that are
not one flight under the 48-minute and 1,500 kt rules; the points between them go and
nothing is recorded across such a pair. A report for a time the display has already
passed replaces what was drawn after it, and nothing is recorded until the icon has
eased onto it, so the line reads arc, report, corrected path. History from before tracking started, and the backfill, run report to report.
The trail never runs past the icon or through its 3D model, and the head runs from the
newest kept point outside the model to the icon (to the model's tail when the model is
the visual). A contact with no new report coasts along its reported track for 5 minutes
past its last real report, at no more than 1,500 kt and never below known ground, then stops;
one that
went quiet on the ground, or within 150 m of a known ground floor, holds at its last
report, records nothing and draws no head (with the ground unknown, a low barometric
altitude alone does not hold it). The hold-or-coast choice is made once per report (`staleHoldsForFix`
in `src/data/motionModel.js`), and the forward fix built when a repeated position carries
new speed or course follows the same rules and is never a trail report. A live report
with no altitude breaks the line and leaves no point at an invented height, and so does
such a report seeded from position history when tracking starts.
A contact that misses an update is stale from the first miss: it is drawn with the
45%-alpha stale fade (`STALE_ALPHA`), the tracked billboard and 3D model included, and the
tracked label reads STALE. For the tracked aircraft a poll that re-sends a fix no newer
than the last one is a miss too. Once its coast has ended, or it holds at its last report
after a miss, it stands still, dimmed, and the label reads SIGNAL LOST · <time since its
last report>, in seconds under a minute and minutes after (`_trackedStaleCue` in each
flight layer's `tracking.js`). The tracked aircraft is kept, so the follow camera keeps its
target, until 10 minutes after its newest fix (`TRACKED_RETENTION_MS`): 5 minutes coasting,
then 5 stopped as SIGNAL LOST. Untracked aircraft keep their missed-poll limits. A report
that arrives later eases the aircraft onto it.
The tracked-aircraft descriptor both flight layers hand to the cockpit carries
`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
@@ -1762,7 +1808,7 @@ time. TRACK ACQUIRED stays first. Then:
left out (the satellites layer's bounded `getNearestPositions`, measured
from the satellite itself), with their bearing from the top of the frame
and distance. A row (`Ride …`) rides that satellite. While there is a
satellite ridden before this one, a RETURN · *name* row goes back to it; if
satellite ridden before this one, a RETURN · _name_ row goes back to it; if
that satellite has left the catalogue it is dropped and RETURN offers the
one before. Riding stays in orbit: the satellites layer tracks the new
satellite (`trackById`), the Cockpit takes the camera back at once and
@@ -1974,6 +2020,7 @@ video; camera poses use the existing low-confidence heading prior.
The CCTV catalog includes Taiwan national freeway cameras from the Freeway Bureau, MOTC. The app first reads the daily MOTC 2.0 CCTV.xml table with a keyless HTTPS request, a 15-second deadline, a 6 MiB response cap, and the shared 15-minute cache. If that catalog is unreachable and no last-good source list exists, the loader uses the Highway Bureau’s public provincial-road CCTV XML feed at https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml. The fallback is capped by the same Taiwan source limit and hourly retry backoff; its MJPEG feeds are restricted to the eight cctv-ss01 through cctv-ss08.thb.gov.tw HTTPS servers. The frame proxy returns one bounded JPEG from each stream, not an unbounded video response. The Highway Bureau open-data page states XML downloads are free and asks users to attribute the source; the app credits Highway Bureau, MOTC. These are provincial-road cameras, not a replacement for the national freeway cameras. CCTV_TAIWAN_ENABLED=0 disables both feeds; CCTV_TAIWAN_MAX_SOURCES sets the shared cap (default 600, bounded to 1–2000), and CCTV_TAIWAN_URL overrides the Freeway Bureau table URL.
Gate: src/data/cctvTaiwan.test.mjs and src/data/cctvProxy.test.mjs.
## DriveBC CCTV source pack
The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the
@@ -4729,35 +4776,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| Layer | Source | File | Proxy | Update Interval |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×2 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×2 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -5743,7 +5790,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal, except the tracked aircraft, which is kept until 10 minutes after its newest fix. When a position epoch pauses, both layers coast for five minutes on the last speed and heading (at most 1,500 kt), then stop dimmed as SIGNAL LOST; contacts on or near known ground hold instead. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
+5
View File
@@ -1459,6 +1459,7 @@
"src/layers/flights/state.js",
"src/layers/flights/testing.js",
"src/layers/flights/tracking.js",
"src/data/trailContinuity.js",
"src/layers/flights/records.js",
"src/layers/flights/recordPolicy.js",
"src/layers/flights/snapshotRenderer.js",
@@ -1523,6 +1524,7 @@
"src/layers/military/state.js",
"src/layers/military/testing.js",
"src/layers/military/tracking.js",
"src/data/trailContinuity.js",
"src/layers/military/records.js",
"src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js",
@@ -1542,6 +1544,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js",
"src/time/worldClock.js",
"src/time/julian.js"
],
@@ -2632,6 +2635,7 @@
"src/data/trafficPresetStyle.js",
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/trailContinuity.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js",
@@ -3631,6 +3635,7 @@
"src/data/trafficPresetStyle.js",
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/trailContinuity.js",
"src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/keylessGeocoder.js",
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* Trail freeze gate: freezing a trail chunk never shows a blank frame.
*
* A tracked aircraft's trail is cut into chunks; every chunk but the newest
* is frozen into static geometry, which Cesium builds asynchronously. Until
* that geometry is in the scene, the chunk keeps being drawn dynamically.
*
* This gate drives the real app and a real Cesium scene: it draws a trail of
* CHUNK_POINTS points (all dynamic), then adds one point so the first chunk
* freezes, and on every rendered frame after that it checks two things:
* 1. every frozen chunk is either still drawn by a dynamic polyline or its
* static geometry is in the scene (DataSourceDisplay reports DONE);
* 2. the number of trail-coloured pixels never drops below 90% of the
* count measured just before the freeze.
* It also checks that clearing the trail releases every chunk entity.
*
* The camera is held still while the gate runs.
*
* Needs a running dev server:
* node scripts/qa-trail-freeze.mjs [--url http://localhost:4173]
*/
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const urlFlag = process.argv.indexOf('--url');
const APP_URL =
urlFlag > 0 ? process.argv[urlFlag + 1] : 'http://localhost:4173';
const browser = await puppeteer.launch({
headless: 'new',
protocolTimeout: 300_000,
executablePath: chromeExecutablePath(),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=swiftshader',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=960,640',
],
});
let failed = false;
const report = (name, ok, detail) => {
if (!ok) failed = true;
console.log(
` [${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
};
try {
const page = await browser.newPage();
await page.setViewport({ width: 960, height: 640 });
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForFunction(
() =>
window.__godsEyeView?.viewer &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 180_000 },
);
const result = await page.evaluate(async () => {
// The exact Cesium module the renderer uses, so classes are shared.
const source = await (await fetch('/src/data/trailRenderer.js')).text();
const cesiumUrl = source.match(/from\s+["']([^"']*cesium[^"']*)["']/)[1];
const Cesium = await import(cesiumUrl);
const { createTrail, CHUNK_POINTS } =
await import('/src/data/trailRenderer.js');
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
const nextFrame = () =>
new Promise((resolve) => {
const remove = scene.postRender.addEventListener(() => {
remove();
resolve();
});
scene.requestRender();
});
// A line across the middle of the current view, in mid-air in front of
// the camera, so terrain and buildings never hide it.
viewer.camera.cancelFlight?.();
const camera = viewer.camera;
const ahead = Cesium.Cartesian3.add(
camera.positionWC,
Cesium.Cartesian3.multiplyByScalar(
camera.directionWC,
3_000,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const points = Array.from({ length: CHUNK_POINTS + 1 }, (_, i) =>
Cesium.Cartesian3.add(
ahead,
Cesium.Cartesian3.multiplyByScalar(
camera.rightWC,
-1_200 + (2_400 * i) / CHUNK_POINTS,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
),
);
const added = [];
const removed = [];
const add = viewer.entities.add.bind(viewer.entities);
const remove = viewer.entities.remove.bind(viewer.entities);
viewer.entities.add = (definition) => {
const entity = add(definition);
if (String(entity.id).startsWith('gev-trail:')) added.push(entity);
return entity;
};
viewer.entities.remove = (entity) => {
if (String(entity?.id).startsWith('gev-trail:')) removed.push(entity);
return remove(entity);
};
const gl = scene.context._gl;
const width = gl.drawingBufferWidth;
const height = gl.drawingBufferHeight;
const pixels = new Uint8Array(width * height * 4);
// Magenta trail: count pixels that are clearly magenta.
const countTrailPixels = () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let count = 0;
for (let p = 0; p < pixels.length; p += 4) {
const r = pixels[p];
const g = pixels[p + 1];
const b = pixels[p + 2];
if (r > 150 && b > 150 && g < 110) count++;
}
return count;
};
const trail = createTrail(viewer, { color: '#ff00ff', width: 8 });
trail.setPositions(points.slice(0, CHUNK_POINTS));
for (let i = 0; i < 20; i++) await nextFrame();
const before = trail._chunksForTest();
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let lit = 0;
for (let p = 0; p < pixels.length; p += 4)
if (pixels[p] || pixels[p + 1] || pixels[p + 2]) lit++;
const cam = viewer.camera.positionCartographic;
const diagnostics = {
lit,
size: [width, height],
camera: [
Cesium.Math.toDegrees(cam.longitude),
Cesium.Math.toDegrees(cam.latitude),
cam.height,
],
preserveDrawingBuffer: gl.getContextAttributes().preserveDrawingBuffer,
};
let baseline = 0;
for (let i = 0; i < 5; i++) {
await nextFrame();
baseline = Math.max(baseline, countTrailPixels());
}
trail.setPositions(points);
const frames = [];
const sphere = new Cesium.BoundingSphere();
let resolvedAt = null;
for (let i = 0; i < 400; i++) {
await nextFrame();
const state = trail._chunksForTest();
const covered = state.frozen.every(
(chunk) =>
chunk.standIn ||
viewer.dataSourceDisplay.getBoundingSphere(
chunk.entity,
false,
sphere,
) === Cesium.BoundingSphereState.DONE,
);
frames.push({ covered, pixels: countTrailPixels() });
if (resolvedAt == null && state.frozen.every((c) => !c.standIn))
resolvedAt = i;
if (resolvedAt != null && i > resolvedAt + 30) break;
}
trail.clear();
for (let i = 0; i < 3; i++) await nextFrame();
const frozenAfterClear = trail._chunksForTest().frozen.length;
trail.destroy();
const leaked = added.filter((entity) => !removed.includes(entity)).length;
viewer.entities.add = add;
viewer.entities.remove = remove;
return {
chunkPoints: CHUNK_POINTS,
diagnostics,
frozenBefore: before.frozen.length,
baseline,
frames,
resolvedAt,
frozenAfterClear,
entities: added.length,
leaked,
};
});
if (process.env.GEV_TEST_VERBOSE)
console.log(JSON.stringify(result.diagnostics));
const minPixels = Math.min(...result.frames.map((frame) => frame.pixels));
report(
'the trail draws before the freeze',
result.baseline > 200 && result.frozenBefore === 0,
`baseline=${result.baseline} trail pixels, frozen chunks=${result.frozenBefore}`,
);
report(
'the frozen chunk reaches the scene as static geometry',
result.resolvedAt != null,
`stand-in dropped after ${result.resolvedAt} frames`,
);
report(
'every frame after the freeze draws the frozen chunk (dynamic copy or static geometry)',
result.frames.every((frame) => frame.covered),
`${result.frames.length} frames checked`,
);
report(
'no blank frame: trail pixels never drop below 90% of the pre-freeze count',
minPixels >= 0.9 * result.baseline,
`min=${minPixels} baseline=${result.baseline}`,
);
report(
'clearing and destroying release every trail entity',
result.frozenAfterClear === 0 && result.leaked === 0,
`entities created=${result.entities} leaked=${result.leaked}`,
);
} catch (error) {
report('gate ran', false, error.message);
} finally {
await browser.close();
}
console.log(failed ? 'RESULT: FAIL' : 'RESULT: PASS');
process.exit(failed ? 1 : 0);
+153 -21
View File
@@ -42,8 +42,9 @@
* and a real mouse click on the tracked plane is a
* NO-OP (no deselect, no camera spike).
* M3. AGE-OUT RELEASE — when the tracked plane's fixes stop arriving
* (3 missed polls via the shim), tracking clears and
* the camera is RELEASED IN PLACE: viewer.trackedEntity
* it stays tracked (STALE, then SIGNAL LOST) until
* 10 min after its newest fix; then tracking clears
* and the camera is RELEASED IN PLACE: viewer.trackedEntity
* undefined, NO jump (owner decision 2026-07-02 —
* the old ~80 km overview flyTo is gone).
*
@@ -362,7 +363,12 @@ async function main() {
'Synthetica', // 2 origin_country
Math.floor(Date.now() / 1000), // 3 time_position
Math.floor(Date.now() / 1000), // 4 last_contact
f.lon, // 5 longitude
// A plane given `moveFromMs` flies east at its reported speed
// (the trail-head check needs a moving icon); others hold still.
Number.isFinite(f.moveFromMs)
? f.lon + (f.vel * (Date.now() - f.moveFromMs)) / 1000
/ (111320 * Math.cos((f.lat * Math.PI) / 180))
: f.lon, // 5 longitude
f.lat, // 6 latitude
f.alt, // 7 baro_altitude (m)
f.onGround === true, // 8 on_ground (ground-traffic phase flips this live)
@@ -1498,6 +1504,110 @@ async function main() {
`max=${maxDelta.toFixed(3)} m over ${samples.length} frames (tol ${JITTER_TOL_M} m, mode=${modelUp ? 'model-matrix' : 'display-contract'})`);
}
// ============================================================
// TRAIL HEAD ENDS AT THE DRAWN ICON, every rendered frame.
// The head's last vertex is where the tracked plane is drawn (the
// display position, or the model's tail anchor while the model is the
// visual). getTrackedInfo reports the dead-reckoned position without
// the brief post-report easing; its gap is logged, not asserted.
// ============================================================
console.log('\nTrail head — ends at the drawn icon on every frame');
// The fixture planes repeat one position, so the icon holds and draws no
// head. Fly the tracked one east for this check; the display runs 30 s
// behind, so give it time to reach the moving reports.
await evalPage((icao) => {
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
f.moveFromMs = Date.now();
}, trackedIcao);
await sleep(55_000);
const headSamples = await sampleFrames(page, 40, (icao) => {
const viewer = window.__godsEyeView.viewer;
const fl = window.__godsEyeView.dataManager.layers.get('flights').module;
const ent = viewer.trackedEntity;
const time = viewer.clock.currentTime;
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
const line = head?.polyline?.positions?.getValue(time);
const drawn = ent?.position?.getValue(time);
if (!line?.length || !drawn) return null;
const end = line[line.length - 1];
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
const ti = fl.getTrackedInfo();
let infoGap = null;
if (ti && ti.icao24 === icao) {
// WGS84 lat/lon → ECEF on the ellipsoid; the angle at the centre to
// the drawn position, times the mean radius, is the ground gap.
const a = 6378137, e2 = 6.69437999014e-3;
const lat = (ti.latitude * Math.PI) / 180, lon = (ti.longitude * Math.PI) / 180;
const n = a / Math.sqrt(1 - e2 * Math.sin(lat) ** 2);
const p = {
x: n * Math.cos(lat) * Math.cos(lon),
y: n * Math.cos(lat) * Math.sin(lon),
z: n * (1 - e2) * Math.sin(lat),
};
const dot = p.x * drawn.x + p.y * drawn.y + p.z * drawn.z;
const cos = dot / (Math.hypot(p.x, p.y, p.z) * Math.hypot(drawn.x, drawn.y, drawn.z));
infoGap = 6371008.8 * Math.acos(Math.min(1, cos));
}
// While the 3D model is the visual the head ends at the model's tail
// anchor, which sits on the drawn (pixel-size-enlarged) model.
const model = window.__findTrackedModel ? window.__findTrackedModel() : null;
const sphere = model && model.ready && model.show ? model.boundingSphere : null;
// boundingSphere leaves out the minimum-pixel-size enlargement that
// computedScale carries; the drawn model is that much larger.
const enlarge = sphere && Number.isFinite(model.computedScale) && model.scale > 0
? Math.max(1, model.computedScale / model.scale)
: 1;
const radius = sphere ? sphere.radius * enlarge : null;
return {
end: dist(end, drawn),
onModel: sphere ? dist(end, sphere.center) <= radius + 50 : false,
modelRadius: radius,
infoGap,
vertices: line.length,
};
}, trackedIcao);
// The same reading between frames, the way a page script polls it: the
// head holds the last rendered frame while getTrackedInfo dead-reckons
// the current instant. Logged only, to tell the two apart.
const betweenFrames = [];
for (let i = 0; i < 8; i++) {
await sleep(600);
betweenFrames.push(await evalPage((icao) => {
const viewer = window.__godsEyeView.viewer;
const head = viewer.entities.values.find((e) => String(e.id).startsWith('gev-trail:fl-head-'));
const line = head?.polyline?.positions?.getValue(viewer.clock.currentTime);
const drawn = viewer.trackedEntity?.position?.getValue(viewer.clock.currentTime);
if (!line?.length || !drawn) return null;
const end = line[line.length - 1];
return Math.hypot(end.x - drawn.x, end.y - drawn.y, end.z - drawn.z);
}, trackedIcao));
}
console.log(` (between frames: head end vs entity position, m: ${
betweenFrames.map((v) => (v == null ? '-' : v.toFixed(1))).join(' ')})`);
// Freeze the plane where it got to, so later checks see a still contact.
await evalPage((icao) => {
const f = window.__SYNTH.flights.find((p) => p.icao === icao);
f.lon += (f.vel * (Date.now() - f.moveFromMs)) / 1000
/ (111320 * Math.cos((f.lat * Math.PI) / 180));
delete f.moveFromMs;
}, trackedIcao);
const headUsable = headSamples.values.filter((v) => v != null);
if (headUsable.length < 10) {
skip('trail head: ends at the drawn icon on every frame',
`only ${headUsable.length} frames with a drawn head (timedOut=${headSamples.timedOut})`);
} else {
const worst = Math.max(...headUsable.map((v) => v.end));
const off = headUsable.filter((v) => !(v.end <= 50 || v.onModel));
const onModel = headUsable.filter((v) => v.end > 50 && v.onModel).length;
const radii = headUsable.map((v) => v.modelRadius).filter((v) => v != null);
const gaps = headUsable.map((v) => v.infoGap).filter((v) => v != null);
record('trail head: last vertex on the drawn icon (within 50 m, or on the drawn 3D model) on every frame',
off.length === 0,
`${off.length} of ${headUsable.length} frames off; max end-to-centre=${worst.toFixed(1)} m, ${onModel} frames ending on the model's tail (model radius ${
radii.length ? Math.max(...radii).toFixed(0) : 'n/a'} m); getTrackedInfo vs drawn icon max=${
gaps.length ? Math.max(...gaps).toFixed(1) : 'n/a'} m (logged only)`);
}
// ============================================================
// INVARIANT 2: NO PULL-OUT on a track switch
// tracking B while tracking A must not fly to ~80 km overview.
@@ -1744,7 +1854,8 @@ async function main() {
// ============================================================
// REGRESSION M3 (rewritten 2026-07-02 — deliberate behavior change):
// when the TRACKED plane ages out (3 missed polls), tracking must clear
// when the TRACKED plane ages out (10 min after its newest fix, past the
// 3 missed polls that remove an untracked plane), tracking must clear
// and the camera must be RELEASED IN PLACE — viewer.trackedEntity
// undefined and NO jump (the old contract flew an ~80 km overview; the
// owner field-ruled that wrong: "it randomly zooms way up and loses my
@@ -1765,27 +1876,48 @@ async function main() {
const beforeTracking = !!fl.getTrackedInfo();
// Simulate the age-out via the shim: the plane stops arriving.
window.__SYNTH.flights = window.__SYNTH.flights.filter((f) => f.icao !== icao);
// MISSING_POLL_LIMIT = 3 consecutive absent polls → removal + clear.
// Capture the camera pose right BEFORE the removing poll — the follow
// camera drifts with the coasting plane until the instant of release,
// so this is the "where the user was looking" reference.
// The tracked plane outlives MISSING_POLL_LIMIT (3 absent polls): it is
// kept until 10 min after its newest fix, so the camera keeps its target.
await fl.update(v);
await fl.update(v);
const c0 = v.camera.positionCartographic;
const atAgeOut = {
lonDeg: (c0.longitude * 180) / Math.PI,
latDeg: (c0.latitude * 180) / Math.PI,
height: c0.height,
};
await fl.update(v); // third miss → removal + clear + in-place release
return {
beforeTracking,
atAgeOut,
stillTrackedAfter: !!fl.getTrackedInfo(),
viewerStillTracking: !!v.trackedEntity,
};
await fl.update(v);
const trackedThroughMisses = !!fl.getTrackedInfo();
// Jump the page clock past the 10 min retention instead of waiting.
const realNow = Date.now;
Date.now = () => realNow() + 11 * 60 * 1000;
try {
// Let the follow camera settle on the stopped plane, then capture the
// pose right BEFORE the removing poll: the "where the user was
// looking" reference.
await new Promise((res) => {
const stop = v.scene.postRender.addEventListener(() => {
stop();
res();
});
v.scene.requestRender();
});
const c0 = v.camera.positionCartographic;
const atAgeOut = {
lonDeg: (c0.longitude * 180) / Math.PI,
latDeg: (c0.latitude * 180) / Math.PI,
height: c0.height,
};
await fl.update(v); // past retention → removal + clear + in-place release
return {
beforeTracking,
trackedThroughMisses,
atAgeOut,
stillTrackedAfter: !!fl.getTrackedInfo(),
viewerStillTracking: !!v.trackedEntity,
};
} finally {
Date.now = realNow;
}
}, m3Icao);
record('M3: the tracked plane stays tracked through 3 missed polls (kept 10 min after its newest fix)',
m3.trackedThroughMisses,
`trackedThroughMisses=${m3.trackedThroughMisses}`);
record('M3: tracking cleared after the tracked plane ages out (getTrackedInfo null)',
m3.beforeTracking && !m3.stillTrackedAfter,
`before=${m3.beforeTracking} trackedAfter=${m3.stillTrackedAfter}`);
+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();
}
});