mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
Merge branch 'main' into sameh/route-corridor
This commit is contained in:
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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -615,6 +615,7 @@
|
||||
"LOADING": "جارٍ التحميل",
|
||||
"DEGRADED": "متدهور",
|
||||
"STALE": "متقادم",
|
||||
"SIGNAL LOST · {time}": "انقطعت الإشارة · {time}",
|
||||
"FALLBACK": "بديل",
|
||||
"PARTIAL": "جزئي",
|
||||
"UNCERTAIN": "غير مؤكد",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"LOADING": "LÄDT",
|
||||
"DEGRADED": "EINGESCHRÄNKT",
|
||||
"STALE": "VERALTET",
|
||||
"SIGNAL LOST · {time}": "SIGNAL VERLOREN · {time}",
|
||||
"PARTIAL": "TEILWEISE",
|
||||
"FALLBACK": "FALLBACK",
|
||||
"SUMMARY": "ÜBERSICHT",
|
||||
|
||||
@@ -500,6 +500,7 @@
|
||||
"LOADING": "CARGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESACTUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SEÑAL PERDIDA · {time}",
|
||||
"FALLBACK": "RESPALDO",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"UNCERTAIN": "INCIERTO",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"LOADING": "טוען",
|
||||
"DEGRADED": "מופחת",
|
||||
"STALE": "מיושן",
|
||||
"SIGNAL LOST · {time}": "האות אבד · {time}",
|
||||
"PARTIAL": "חלקי",
|
||||
"FALLBACK": "חלופה",
|
||||
"view band|STREET": "רחוב",
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
"LOADING": "CARICAMENTO",
|
||||
"DEGRADED": "DEGRADATO",
|
||||
"STALE": "OBSOLETO",
|
||||
"SIGNAL LOST · {time}": "SEGNALE PERSO · {time}",
|
||||
"PARTIAL": "PARZIALE",
|
||||
"FALLBACK": "RISERVA",
|
||||
"view band|STREET": "STRADA",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "読込中",
|
||||
"DEGRADED": "性能低下",
|
||||
"STALE": "更新遅延",
|
||||
"SIGNAL LOST · {time}": "信号途絶 · {time}",
|
||||
"PARTIAL": "部分的",
|
||||
"FALLBACK": "フォールバック",
|
||||
"view band|STREET": "街路",
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
"LOADING": "CARREGANDO",
|
||||
"DEGRADED": "DEGRADADO",
|
||||
"STALE": "DESATUALIZADO",
|
||||
"SIGNAL LOST · {time}": "SINAL PERDIDO · {time}",
|
||||
"PARTIAL": "PARCIAL",
|
||||
"FALLBACK": "ALTERNATIVO",
|
||||
"view band|STREET": "RUA",
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"{count}h ago": "{count} ч назад",
|
||||
"STALE · cached {age}": "УСТАРЕЛ · кэш {age}",
|
||||
"STALE": "УСТАРЕЛ",
|
||||
"SIGNAL LOST · {time}": "СИГНАЛ ПОТЕРЯН · {time}",
|
||||
"refreshing...": "обновление...",
|
||||
"loading...": "загрузка...",
|
||||
"KEY REQUIRED": "ТРЕБУЕТСЯ КЛЮЧ",
|
||||
|
||||
@@ -332,6 +332,7 @@
|
||||
"LOADING": "ЗАВАНТАЖЕННЯ",
|
||||
"DEGRADED": "ПЕРЕБОЇ",
|
||||
"STALE": "ЗАСТАРІЛО",
|
||||
"SIGNAL LOST · {time}": "СИГНАЛ ВТРАЧЕНО · {time}",
|
||||
"PARTIAL": "ЧАСТКОВО",
|
||||
"FALLBACK": "РЕЗЕРВ",
|
||||
"view band|STREET": "ВУЛИЦЯ",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "加载中",
|
||||
"DEGRADED": "降级",
|
||||
"STALE": "过期",
|
||||
"SIGNAL LOST · {time}": "信号丢失 · {time}",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "备用",
|
||||
"view band|STREET": "街道",
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"LOADING": "加載中",
|
||||
"DEGRADED": "降級",
|
||||
"STALE": "過期",
|
||||
"SIGNAL LOST · {time}": "訊號遺失 · {time}",
|
||||
"PARTIAL": "部分",
|
||||
"FALLBACK": "備用",
|
||||
"view band|STREET": "街道",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user