mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge rebased aircraft emergency contribution for HAL-522 validation
# Conflicts: # DATA_SOURCES.md
This commit is contained in:
commit
69e8b18e02
31 files changed
+1448
-1207
No files matched your search
@@ -95,6 +95,20 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Flights and Military Flights highlight aircraft broadcasting a transponder
|
||||||
|
emergency. Squawk 7500 (unlawful interference), 7600 (radio failure) and
|
||||||
|
7700 (general emergency) — or adsb.lol's ADS-B `emergency` status, which
|
||||||
|
also reports downed aircraft — tint the aircraft red on the globe, in
|
||||||
|
Cockpit contacts and in snapshots, and name the code on the Cockpit HUD.
|
||||||
|
Minimum-fuel and lifeguard statuses are reported but not tinted. The status
|
||||||
|
follows the latest poll instead of sticking, so a cleared code stops
|
||||||
|
highlighting on the next refresh. The analyst and voice query engine can
|
||||||
|
filter by `squawk`, `emergency`, `emergencyKind` and `emergencySource`
|
||||||
|
(whether the squawk or the ADS-B field named the status), and each result
|
||||||
|
says when the transponder was last heard, so answers read "broadcasting
|
||||||
|
squawk 7700" or "ADS-B reports general emergency". No new source or
|
||||||
|
request: OpenSky already carries the squawk and adsb.lol carries both.
|
||||||
|
|
||||||
- Street Level: Google Street View is the second provider. On the Google 3D
|
- Street Level: Google Street View is the second provider. On the Google 3D
|
||||||
map (Google's terms keep it off other maps), light its chip (off by
|
map (Google's terms keep it off other maps), light its chip (off by
|
||||||
default: every panorama is billed to the Google Maps key, which needs the
|
default: every panorama is billed to the Google Maps key, which needs the
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
- **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default.
|
||||||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||||
- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29.
|
- **GNSS integrity (adsb.lol integrity fields).** The GNSS Integrity layer (id `gnss-interference`) reads the same keyless 250 nm point snapshot around the camera (anchor rounded to a whole degree, 60 s server cache, cooldown on upstream 429). The server returns only hex, position, NIC, NACp and readsb's GPS-loss flag for airborne ADS-B v1/v2 transponder positions seen in the last minute; callsigns and registrations are dropped. The method has three separately sourced parts, exposed as machine-readable `provenance` on the layer and its stats: (1) a GEV-defined per-aircraft classifier (`gev-nic-nacp-v1`): an aircraft has low navigation accuracy when it reports NIC < 7 or NACp < 8 or readsb flags GPS loss. The numbers are borrowed from the 14 CFR 91.227(c) ADS-B Out performance minima, a US regulation, not a published interference classifier; gpsjam.org does not publish its per-aircraft threshold ([FAQ](https://gpsjam.org/faq)), and this one is not yet validated against known events. (2) gpsjam.org's published cell formula and bands (`100 × (bad − 1) / total`; low < 2 %, medium 2–10 %, high > 10 %). (3) A GEV-defined window: at least 3 aircraft per 0.5° cell over a rolling 30 minutes around the views the user visits. Medium and high cells are navigation-integrity anomalies. Suspected jamming or spoofing is a secondary interpretation, not a confirmed source or location: avionics faults also report low values. Verified 2026-09-29.
|
||||||
|
- **Transponder emergencies.** Emergency highlighting reads fields the flight feeds already return: OpenSky state vectors' `squawk` and adsb.lol/readsb's `squawk` and `emergency`. It makes no extra request. A broadcast code states what the transponder sends, not a verified incident; OpenSky has no ADS-B emergency field, so civil highlighting there relies on squawk 7500/7600/7700 alone.
|
||||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||||
|
|||||||
@@ -814,6 +814,31 @@ use the supplied asset resolver, including the preload path. Enrichment receives
|
|||||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||||
|
|
||||||
|
## Transponder emergency highlighting
|
||||||
|
|
||||||
|
`src/data/aircraftEmergency.js` derives one status per poll from the Mode A
|
||||||
|
squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency;
|
||||||
|
ICAO-standard worldwide) and, where the feed carries it, the readsb ADS-B
|
||||||
|
`emergency` field (adsb.lol; OpenSky state vectors carry the squawk only). An
|
||||||
|
explicit ADS-B status wins over the squawk. `general`, `unlawful`, `nordo` and
|
||||||
|
`downed` are emergencies; `minfuel` and `lifeguard` are priorities.
|
||||||
|
|
||||||
|
Both aircraft layers keep `squawk` and `emergency` on each record and replace
|
||||||
|
them on every poll, so the status is never sticky. Civil and military fleet
|
||||||
|
billboards and Cockpit far-contact dots are tinted `EMERGENCY_TINT_CSS` red
|
||||||
|
only for emergencies; priorities keep the normal tint. The military layer uses
|
||||||
|
the same tint when a billboard is first created and when a tracked contact is
|
||||||
|
released. While a broadcasting aircraft is tracked, the Cockpit aircraft-meta
|
||||||
|
line reads `SQUAWK 7700 · GENERAL EMERGENCY` when the code names the status,
|
||||||
|
else `ADS-B EMERGENCY · …` or `ADS-B PRIORITY · …`, in red. Analyst records and
|
||||||
|
the voice query fields expose `squawk`, `emergency` (flag), `emergencyKind` and
|
||||||
|
`emergencySource` (`squawk` when the Mode A code names the status, `ads-b` for
|
||||||
|
the readsb field), plus `emergencyBroadcast` (`broadcasting squawk 7700 (general
|
||||||
|
emergency)` vs `ADS-B reports general emergency`) and `observedAtMs`, the epoch
|
||||||
|
ms of the last transponder message. Voice results carry `observedAtMs` only for
|
||||||
|
aircraft with a status.
|
||||||
|
`scripts/qa-emergency-squawk.mjs` proves the tint, HUD line and clearing.
|
||||||
|
|
||||||
## Browser live-source observations
|
## Browser live-source observations
|
||||||
|
|
||||||
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
|
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"src/tooling/panelStorageDocs.test.mjs",
|
"src/tooling/panelStorageDocs.test.mjs",
|
||||||
"src/tooling/packageBoundaries.test.mjs",
|
"src/tooling/packageBoundaries.test.mjs",
|
||||||
"src/data/infrastructure.js",
|
"src/data/infrastructure.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
|
"src/data/aircraftEmergency.test.mjs",
|
||||||
|
"src/data/aircraftEmergencyAnalyst.test.mjs",
|
||||||
|
"scripts/qa-emergency-squawk.mjs",
|
||||||
"src/data/infrastructure.test.mjs",
|
"src/data/infrastructure.test.mjs",
|
||||||
"src/data/localGeojsonCore.js",
|
"src/data/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js",
|
"src/data/localGeojsonLod.js",
|
||||||
|
|||||||
@@ -794,6 +794,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/cyberSonar.js",
|
"src/cyberSonar.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/aircraftNearbyPolicy.js",
|
"src/data/aircraftNearbyPolicy.js",
|
||||||
@@ -837,6 +838,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/cyberSonar.js",
|
"src/cyberSonar.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/aircraftNearbyPolicy.js",
|
"src/data/aircraftNearbyPolicy.js",
|
||||||
@@ -1494,6 +1496,7 @@
|
|||||||
"src/contextModePolicy.js",
|
"src/contextModePolicy.js",
|
||||||
"src/creditKeyboard.js",
|
"src/creditKeyboard.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/aircraftNearbyPolicy.js",
|
"src/data/aircraftNearbyPolicy.js",
|
||||||
@@ -2280,6 +2283,7 @@
|
|||||||
"src/celestialRing.js",
|
"src/celestialRing.js",
|
||||||
"src/cockpitMath.js",
|
"src/cockpitMath.js",
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/aircraftNearbyPolicy.js",
|
"src/data/aircraftNearbyPolicy.js",
|
||||||
@@ -2905,7 +2909,8 @@
|
|||||||
"src/layers/flights/recordPolicy.js",
|
"src/layers/flights/recordPolicy.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/aircraftClass.js"
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -2924,7 +2929,8 @@
|
|||||||
"src/layers/military/recordPolicy.js",
|
"src/layers/military/recordPolicy.js",
|
||||||
"src/data/aircraftMeta.js",
|
"src/data/aircraftMeta.js",
|
||||||
"src/data/renderAltitude.js",
|
"src/data/renderAltitude.js",
|
||||||
"src/data/aircraftClass.js"
|
"src/data/aircraftClass.js",
|
||||||
|
"src/data/aircraftEmergency.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
|
||||||
|
*
|
||||||
|
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||||
|
* `/api/opensky` and `/api/adsblol/mil` are stubbed in the page so the codes
|
||||||
|
* are deterministic; everything downstream (normalizers, records, rendering,
|
||||||
|
* Cockpit HUD) is production code.
|
||||||
|
*
|
||||||
|
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
|
||||||
|
* drawn red; a normal civil flight stays white, a normal military
|
||||||
|
* flight stays amber, and a military `minfuel` priority stays amber.
|
||||||
|
* (ii) HUD — tracking the military emergency and entering Cockpit names
|
||||||
|
* the broadcast code on the aircraft-meta line, in red.
|
||||||
|
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
|
||||||
|
* normal wording (the status is not sticky).
|
||||||
|
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
|
||||||
|
* billboard is red, not the plain military amber.
|
||||||
|
*
|
||||||
|
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
|
||||||
|
*
|
||||||
|
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
|
||||||
|
* Exits non-zero on any FAIL. Does not commit anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const getOpt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||||
|
const HEADFUL = argv.includes('--headful');
|
||||||
|
const CENTER = { lon: 4.0, lat: 53.5 };
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** OpenSky state vector: squawk sits at index 14. */
|
||||||
|
const civilRow = (icao24, callsign, lon, lat, squawk) => [
|
||||||
|
icao24,
|
||||||
|
callsign,
|
||||||
|
'Netherlands',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
10000,
|
||||||
|
false,
|
||||||
|
230,
|
||||||
|
90,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
10050,
|
||||||
|
squawk,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** readsb row as adsb.lol /v2/mil returns it. */
|
||||||
|
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
|
||||||
|
hex,
|
||||||
|
flight,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
alt_baro: 24000,
|
||||||
|
alt_geom: 24200,
|
||||||
|
gs: 380,
|
||||||
|
track: 270,
|
||||||
|
t: 'C17',
|
||||||
|
r: `QA-${hex}`,
|
||||||
|
squawk,
|
||||||
|
emergency,
|
||||||
|
seen: 1,
|
||||||
|
seen_pos: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
|
||||||
|
function installStubs() {
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
const json = (body) =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input?.url || '';
|
||||||
|
const now = Date.now();
|
||||||
|
const { civil, mil } = window.__squawkQaFixtures;
|
||||||
|
if (url.includes('/api/opensky-track')) return json({ path: [] });
|
||||||
|
if (url.includes('/api/opensky')) {
|
||||||
|
const t = Math.floor(now / 1000);
|
||||||
|
return json({
|
||||||
|
time: t,
|
||||||
|
states: civil.map((row) => {
|
||||||
|
const copy = [...row];
|
||||||
|
copy[3] = t - 1;
|
||||||
|
copy[4] = t - 1;
|
||||||
|
return copy;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/api/adsblol/mil')) {
|
||||||
|
const cleared = window.__squawkQaPhase === 'cleared';
|
||||||
|
return json({
|
||||||
|
msg: 'No error',
|
||||||
|
now,
|
||||||
|
ac: mil.map((row) =>
|
||||||
|
cleared && row.hex === 'ae7700'
|
||||||
|
? { ...row, squawk: '4521', emergency: 'none' }
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour of every shown fleet billboard whose id is one of `ids`. */
|
||||||
|
function billboardColors(ids) {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const found = {};
|
||||||
|
const seen = new Set();
|
||||||
|
const visit = (primitive) => {
|
||||||
|
if (!primitive || seen.has(primitive)) return;
|
||||||
|
seen.add(primitive);
|
||||||
|
if (
|
||||||
|
typeof primitive.length !== 'number' ||
|
||||||
|
typeof primitive.get !== 'function'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
for (let i = 0; i < primitive.length; i++) {
|
||||||
|
const item = primitive.get(i);
|
||||||
|
if (item?.color && ids.includes(item.id) && item.show !== false) {
|
||||||
|
found[item.id] = {
|
||||||
|
r: Math.round(item.color.red * 255),
|
||||||
|
g: Math.round(item.color.green * 255),
|
||||||
|
b: Math.round(item.color.blue * 255),
|
||||||
|
};
|
||||||
|
} else visit(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(viewer.scene.primitives);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
|
||||||
|
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
|
||||||
|
const isAmber = (c) =>
|
||||||
|
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
|
||||||
|
|
||||||
|
async function shoot(page, name) {
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await sleep(600);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
|
||||||
|
console.log(` App URL : ${APP_URL}\n`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(APP_URL);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(await puppeteer.executablePath().catch(() => null)) ||
|
||||||
|
undefined;
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: HEADFUL ? false : 'new',
|
||||||
|
protocolTimeout: 180000,
|
||||||
|
...(executablePath && fs.existsSync(executablePath)
|
||||||
|
? { executablePath }
|
||||||
|
: {}),
|
||||||
|
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=1440,900',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
const errors = [];
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
const { lon, lat } = CENTER;
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
(fixtures) => {
|
||||||
|
window.__squawkQaFixtures = fixtures;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
civil: [
|
||||||
|
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
|
||||||
|
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
|
||||||
|
],
|
||||||
|
mil: [
|
||||||
|
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '7700', 'general'),
|
||||||
|
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
|
||||||
|
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Stub in-page rather than with request interception: an intercepted page
|
||||||
|
// stalls Cesium's workers (see qa-gnss.mjs).
|
||||||
|
await page.evaluateOnNewDocument(installStubs);
|
||||||
|
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await sleep(5000);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.evaluate(
|
||||||
|
(lo, la) => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const d2r = Math.PI / 180;
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight active */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
|
||||||
|
{
|
||||||
|
longitude: lo * d2r,
|
||||||
|
latitude: la * d2r,
|
||||||
|
height: 260000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
},
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── (i) TINT ──────────────────────────────────────────────────────────
|
||||||
|
console.log('(i) TINT — emergency codes through the real layers...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
await dm.setEnabled('military', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
|
||||||
|
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
|
||||||
|
await page
|
||||||
|
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
|
||||||
|
timeout: 60000,
|
||||||
|
polling: 500,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await sleep(1500);
|
||||||
|
const colors = await page.evaluate(probe);
|
||||||
|
const show = (id) => JSON.stringify(colors[id] ?? null);
|
||||||
|
record(
|
||||||
|
'TINT: civil squawk 7600 is red',
|
||||||
|
isRed(colors['4847a1']),
|
||||||
|
show('4847a1'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: normal civil flight stays white',
|
||||||
|
isWhite(colors['4847a2']),
|
||||||
|
show('4847a2'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: military ADS-B general emergency is red',
|
||||||
|
isRed(colors.ae7700),
|
||||||
|
show('ae7700'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: military minimum-fuel priority stays amber',
|
||||||
|
isAmber(colors.ae0f01),
|
||||||
|
show('ae0f01'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: normal military flight stays amber',
|
||||||
|
isAmber(colors.ae0001),
|
||||||
|
show('ae0001'),
|
||||||
|
);
|
||||||
|
await shoot(page, 'globe-tint');
|
||||||
|
|
||||||
|
// ── (ii) HUD ──────────────────────────────────────────────────────────
|
||||||
|
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
|
||||||
|
const tracked = await page.evaluate(() =>
|
||||||
|
Boolean(
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('military')
|
||||||
|
.module.trackById('ae7700', { origin: 'user' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'HUD: emergency aircraft can be tracked',
|
||||||
|
tracked,
|
||||||
|
`tracked=${tracked}`,
|
||||||
|
);
|
||||||
|
// Cockpit entry is gated on the Contacts (flights) context mode.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager._contextControls._selectContextMode(
|
||||||
|
'flights',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const entry = document.getElementById('cockpit-entry');
|
||||||
|
return entry && !entry.hidden && !entry.disabled;
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await page
|
||||||
|
.$eval('#cockpit-entry', (entry) => entry.click())
|
||||||
|
.catch(() => {});
|
||||||
|
const hudReady = await page
|
||||||
|
.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.body.classList.contains('cockpit-mode') &&
|
||||||
|
/SQUAWK 7700/.test(
|
||||||
|
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||||
|
),
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const hud = await page.evaluate(() => {
|
||||||
|
const meta = document.getElementById('cockpit-aircraft-meta');
|
||||||
|
return {
|
||||||
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||||
|
text: meta?.textContent || '',
|
||||||
|
emergencyClass: meta?.classList.contains('emergency') || false,
|
||||||
|
color: meta ? getComputedStyle(meta).color : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'HUD: meta line names the broadcast code',
|
||||||
|
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
|
||||||
|
JSON.stringify(hud),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'HUD: meta line is styled as an emergency',
|
||||||
|
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
|
||||||
|
`class=${hud.emergencyClass} color=${hud.color}`,
|
||||||
|
);
|
||||||
|
await shoot(page, 'cockpit-hud');
|
||||||
|
|
||||||
|
// ── (iii) CLEAR ───────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iii) CLEAR — the next poll drops the code...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'cleared';
|
||||||
|
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||||
|
});
|
||||||
|
const cleared = await page
|
||||||
|
.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!document
|
||||||
|
.getElementById('cockpit-aircraft-meta')
|
||||||
|
?.classList.contains('emergency'),
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const after = await page.evaluate(
|
||||||
|
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'CLEAR: HUD returns to its normal wording',
|
||||||
|
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
|
||||||
|
JSON.stringify(after),
|
||||||
|
);
|
||||||
|
await shoot(page, 'cockpit-cleared');
|
||||||
|
|
||||||
|
// ── (iv) RELEASE ──────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.refreshLayer('military');
|
||||||
|
dm.layers.get('military').module.stopTracking({ origin: 'user' });
|
||||||
|
});
|
||||||
|
const releaseProbe = `(${billboardColors})(["ae7700"])`;
|
||||||
|
const released = await page
|
||||||
|
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
|
||||||
|
timeout: 20000,
|
||||||
|
polling: 250,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
|
||||||
|
record(
|
||||||
|
'RELEASE: untracked emergency billboard is red',
|
||||||
|
released && isRed(releasedColor),
|
||||||
|
JSON.stringify(releasedColor ?? null),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'no uncaught browser errors',
|
||||||
|
errors.length === 0,
|
||||||
|
errors.slice(0, 3).join(' | '),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||||
|
exitCode = 3;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = results.filter((r) => r.ok).length;
|
||||||
|
const fail = results.length - pass;
|
||||||
|
console.log('\n' + '─'.repeat(60));
|
||||||
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||||
|
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
|
||||||
|
console.log('─'.repeat(60) + '\n');
|
||||||
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
@@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
|
|||||||
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HUD wording for a tracked aircraft's transponder emergency, or null.
|
||||||
|
* States what was broadcast, never a conclusion about the flight: the Mode A
|
||||||
|
* code when it names the status, else the ADS-B field it came from (an
|
||||||
|
* unrelated squawk is dropped).
|
||||||
|
* @param {{label?: string, squawk?: string|null, source?: string,
|
||||||
|
* severity?: string}|null|undefined} emergency
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function formatCockpitEmergency(emergency) {
|
||||||
|
const label = String(emergency?.label || '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
if (!label) return null;
|
||||||
|
if (emergency.source === 'squawk' && emergency.squawk)
|
||||||
|
return `SQUAWK ${emergency.squawk} · ${label}`;
|
||||||
|
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
|
||||||
|
return `ADS-B ${field} · ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||||
export function formatCockpitContextScope(
|
export function formatCockpitContextScope(
|
||||||
subjectLabel,
|
subjectLabel,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
compassDivisions,
|
compassDivisions,
|
||||||
formatAltitudeRulerTick,
|
formatAltitudeRulerTick,
|
||||||
formatCockpitContextScope,
|
formatCockpitContextScope,
|
||||||
|
formatCockpitEmergency,
|
||||||
formatCompassDivision,
|
formatCompassDivision,
|
||||||
formatSpeedRulerTick,
|
formatSpeedRulerTick,
|
||||||
normalizeHeading,
|
normalizeHeading,
|
||||||
@@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
|||||||
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
|
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
|
||||||
assert.equal(resolveTrackedAircraftInfo(), null);
|
assert.equal(resolveTrackedAircraftInfo(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
|
||||||
|
'SQUAWK 7700 · GENERAL EMERGENCY',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
|
||||||
|
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
|
||||||
|
'an unrelated squawk is not quoted beside an ADS-B status',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
|
||||||
|
'ADS-B PRIORITY · MINIMUM FUEL',
|
||||||
|
);
|
||||||
|
assert.equal(formatCockpitEmergency(null), null);
|
||||||
|
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// src/data/aircraftEmergency.js
|
||||||
|
/**
|
||||||
|
* Transponder emergency status for one aircraft, from the Mode A squawk and
|
||||||
|
* the ADS-B emergency/priority field.
|
||||||
|
*
|
||||||
|
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
|
||||||
|
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
|
||||||
|
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
|
||||||
|
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
|
||||||
|
* squawk only.
|
||||||
|
*
|
||||||
|
* The status is what the transponder broadcast in THIS poll, never a finding:
|
||||||
|
* crews pass through 7x00 codes while dialling, and a real emergency clears
|
||||||
|
* when the code does. Callers keep it per poll (not sticky).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
|
||||||
|
export const EMERGENCY_TINT_CSS = '#FF4444';
|
||||||
|
|
||||||
|
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
|
||||||
|
|
||||||
|
const KINDS = {
|
||||||
|
general: { label: 'General emergency', severity: 'emergency' },
|
||||||
|
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
|
||||||
|
nordo: { label: 'Radio failure', severity: 'emergency' },
|
||||||
|
downed: { label: 'Downed aircraft', severity: 'emergency' },
|
||||||
|
minfuel: { label: 'Minimum fuel', severity: 'priority' },
|
||||||
|
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Mode A code as its four octal digits, else null.
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function normalizeSquawk(value) {
|
||||||
|
const code = String(value ?? '').trim();
|
||||||
|
return /^[0-7]{4}$/.test(code) ? code : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency status from one observation. An explicit ADS-B status wins; a
|
||||||
|
* 7x00 squawk counts even when the ADS-B field says `none`.
|
||||||
|
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
|
||||||
|
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
|
||||||
|
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
|
||||||
|
* 'squawk' when the Mode A code itself names the status (so it can be
|
||||||
|
* quoted), else 'ads-b'.
|
||||||
|
*/
|
||||||
|
export function aircraftEmergency({ squawk, emergency } = {}) {
|
||||||
|
const code = normalizeSquawk(squawk);
|
||||||
|
const reported = String(emergency ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const fromAdsb = Object.hasOwn(KINDS, reported);
|
||||||
|
const kind = fromAdsb ? reported : (SQUAWK_KINDS[code] ?? null);
|
||||||
|
if (!kind) return null;
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
...KINDS[kind],
|
||||||
|
squawk: code,
|
||||||
|
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a summary may phrase the status without flattening it into an incident:
|
||||||
|
* quote the code when the Mode A squawk itself names the status, otherwise
|
||||||
|
* attribute it to the ADS-B emergency field.
|
||||||
|
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
|
||||||
|
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
|
||||||
|
* or 'ADS-B reports general emergency'; null when there is no status.
|
||||||
|
*/
|
||||||
|
export function emergencyBroadcastPhrase(status) {
|
||||||
|
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
|
||||||
|
const label = KINDS[status.kind].label.toLowerCase();
|
||||||
|
return status.source === 'squawk' && status.squawk
|
||||||
|
? `broadcasting squawk ${status.squawk} (${label})`
|
||||||
|
: `ADS-B reports ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the aircraft is broadcasting an emergency (not a priority status).
|
||||||
|
* @param {{severity?: string}|null|undefined} status
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isEmergency(status) {
|
||||||
|
return status?.severity === 'emergency';
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// src/data/aircraftEmergency.test.mjs
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
aircraftEmergency,
|
||||||
|
emergencyBroadcastPhrase,
|
||||||
|
isEmergency,
|
||||||
|
normalizeSquawk,
|
||||||
|
} from './aircraftEmergency.js';
|
||||||
|
|
||||||
|
test('the three ICAO emergency squawks map to their kinds', () => {
|
||||||
|
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
|
||||||
|
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
|
||||||
|
const general = aircraftEmergency({ squawk: ' 7700 ' });
|
||||||
|
assert.deepEqual(general, {
|
||||||
|
kind: 'general',
|
||||||
|
label: 'General emergency',
|
||||||
|
severity: 'emergency',
|
||||||
|
squawk: '7700',
|
||||||
|
source: 'squawk',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
|
||||||
|
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
|
||||||
|
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
|
||||||
|
assert.equal(aircraftEmergency(), null);
|
||||||
|
assert.equal(normalizeSquawk('7800'), null);
|
||||||
|
assert.equal(normalizeSquawk(7700), '7700');
|
||||||
|
assert.equal(normalizeSquawk('770'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
|
||||||
|
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
|
||||||
|
assert.equal(minfuel.kind, 'minfuel');
|
||||||
|
assert.equal(minfuel.severity, 'priority');
|
||||||
|
assert.equal(minfuel.source, 'ads-b');
|
||||||
|
assert.equal(minfuel.squawk, '4521');
|
||||||
|
assert.equal(
|
||||||
|
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
|
||||||
|
'squawk',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
|
||||||
|
'squawk',
|
||||||
|
'a code that names the status stays quotable',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
|
||||||
|
'ads-b',
|
||||||
|
);
|
||||||
|
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
|
||||||
|
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
|
||||||
|
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('priority statuses are reported but are not emergencies', () => {
|
||||||
|
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
|
||||||
|
assert.equal(
|
||||||
|
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
|
||||||
|
assert.equal(isEmergency(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
|
||||||
|
assert.equal(
|
||||||
|
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
|
||||||
|
'broadcasting squawk 7700 (general emergency)',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
emergencyBroadcastPhrase(
|
||||||
|
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
|
||||||
|
),
|
||||||
|
'ADS-B reports general emergency',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
|
||||||
|
'ADS-B reports lifeguard (medical priority)',
|
||||||
|
);
|
||||||
|
assert.equal(emergencyBroadcastPhrase(null), null);
|
||||||
|
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// src/data/aircraftEmergencyAnalyst.test.mjs
|
||||||
|
// Source-to-analyst fixture: an OpenSky state vector squawking 7700 and an
|
||||||
|
// adsb.lol (readsb) aircraft whose ADS-B `emergency` field says `general`
|
||||||
|
// on an ordinary code must reach the analyst with the same kind but distinct
|
||||||
|
// provenance, so a summary never flattens one into the other.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
|
||||||
|
import { FlightRecords } from '../layers/flights/records.js';
|
||||||
|
import { MilitaryFlightRecords } from '../layers/military/records.js';
|
||||||
|
import { mapAnalystRecord as mapFlight } from './flights.js';
|
||||||
|
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
|
||||||
|
import { createAnalystEngine } from './analystEngine.js';
|
||||||
|
|
||||||
|
const NOW_MS = 1800000000000;
|
||||||
|
|
||||||
|
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
|
||||||
|
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
|
||||||
|
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
|
||||||
|
const OPENSKY = {
|
||||||
|
time: NOW_MS / 1000,
|
||||||
|
states: [
|
||||||
|
[
|
||||||
|
'a1b2c3',
|
||||||
|
'SWA696 ',
|
||||||
|
'United States',
|
||||||
|
NOW_MS / 1000 - 6,
|
||||||
|
NOW_MS / 1000 - 3,
|
||||||
|
-97.67,
|
||||||
|
30.19,
|
||||||
|
3000,
|
||||||
|
false,
|
||||||
|
150,
|
||||||
|
180,
|
||||||
|
-5,
|
||||||
|
null,
|
||||||
|
3040,
|
||||||
|
'7700',
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
|
||||||
|
const ADSB_LOL = {
|
||||||
|
ac: [
|
||||||
|
{
|
||||||
|
hex: 'ae01ce',
|
||||||
|
flight: 'RCH123 ',
|
||||||
|
t: 'C17',
|
||||||
|
lat: 31.1,
|
||||||
|
lon: -97.4,
|
||||||
|
alt_baro: 18000,
|
||||||
|
gs: 300,
|
||||||
|
track: 90,
|
||||||
|
squawk: '1200',
|
||||||
|
emergency: 'general',
|
||||||
|
seen: 1.5,
|
||||||
|
seen_pos: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function civilAnalystRecord() {
|
||||||
|
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
|
||||||
|
const store = new FlightRecords({
|
||||||
|
geoidHeight: () => 0,
|
||||||
|
cachedGroundFloor: () => null,
|
||||||
|
floorAltitudeM: (altitude) => altitude,
|
||||||
|
approxDistanceKm: () => 0,
|
||||||
|
});
|
||||||
|
const { meta } = store.receive(row, {
|
||||||
|
viewerLatDeg: null,
|
||||||
|
viewerLonDeg: null,
|
||||||
|
trackedId: null,
|
||||||
|
floorWarmPoints: [],
|
||||||
|
});
|
||||||
|
return mapFlight(row.id, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function militaryAnalystRecord() {
|
||||||
|
const [row] = readsbSnapshot(ADSB_LOL, {
|
||||||
|
observedAtMs: NOW_MS,
|
||||||
|
now: NOW_MS,
|
||||||
|
}).records;
|
||||||
|
const store = new MilitaryFlightRecords({
|
||||||
|
geoidHeight: () => 0,
|
||||||
|
cachedGroundFloor: () => null,
|
||||||
|
floorAltitudeM: (altitude) => altitude,
|
||||||
|
});
|
||||||
|
const { meta } = store.receive(row, {
|
||||||
|
observedAtMs: NOW_MS,
|
||||||
|
floorWarmPoints: [],
|
||||||
|
modelOwnsVisual: false,
|
||||||
|
});
|
||||||
|
return mapMilitary(row.id, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
|
||||||
|
const civil = civilAnalystRecord();
|
||||||
|
const military = militaryAnalystRecord();
|
||||||
|
|
||||||
|
assert.equal(civil.emergency, true);
|
||||||
|
assert.equal(civil.emergencyKind, 'general');
|
||||||
|
assert.equal(civil.squawk, '7700');
|
||||||
|
assert.equal(civil.emergencySource, 'squawk');
|
||||||
|
assert.equal(
|
||||||
|
civil.emergencyBroadcast,
|
||||||
|
'broadcasting squawk 7700 (general emergency)',
|
||||||
|
);
|
||||||
|
assert.equal(civil.observedAtMs, NOW_MS - 3000, 'OpenSky last_contact');
|
||||||
|
|
||||||
|
assert.equal(military.emergency, true);
|
||||||
|
assert.equal(military.emergencyKind, 'general');
|
||||||
|
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
|
||||||
|
assert.equal(military.emergencySource, 'ads-b');
|
||||||
|
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
|
||||||
|
assert.equal(military.observedAtMs, NOW_MS - 1500, 'snapshot time - seen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the analyst can filter emergencies by source', async () => {
|
||||||
|
const records = {
|
||||||
|
flights: [civilAnalystRecord()],
|
||||||
|
military: [militaryAnalystRecord()],
|
||||||
|
};
|
||||||
|
const engine = createAnalystEngine({
|
||||||
|
getRecords: (key) => records[key] || [],
|
||||||
|
resolveRegionRing: async () => null,
|
||||||
|
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
|
||||||
|
});
|
||||||
|
const query = (value) =>
|
||||||
|
engine.query({
|
||||||
|
layers: ['flights', 'military'],
|
||||||
|
scope: { kind: 'anywhere' },
|
||||||
|
filters: [
|
||||||
|
{ field: 'emergency', op: 'eq', value: true },
|
||||||
|
{ field: 'emergencySource', op: 'eq', value },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const bySquawk = await query('squawk');
|
||||||
|
assert.deepEqual(
|
||||||
|
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||||
|
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
|
||||||
|
);
|
||||||
|
const byAdsb = await query('ads-b');
|
||||||
|
assert.deepEqual(
|
||||||
|
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
|
||||||
|
[['ae01ce', 'ADS-B reports general emergency']],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-10
|
|||||||
const FLIGHTS = [
|
const FLIGHTS = [
|
||||||
{ id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' },
|
{ id: 'SWA1', lat: 30.2, lon: -97.7, altitudeM: 11000, speedMps: 240, military: false, onGround: false, routeOrigin: 'AUS', routeDestination: 'LAX' },
|
||||||
{ id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null },
|
{ id: 'RCH01', lat: 31.0, lon: -97.0, altitudeM: 13500, speedMps: 250, military: true, onGround: false, routeOrigin: null, routeDestination: null },
|
||||||
{ id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, routeOrigin: null, routeDestination: null },
|
{ id: 'N123', lat: 45.0, lon: -122.0, altitudeM: 2000, speedMps: 80, military: false, onGround: false, squawk: '7700', emergency: true, emergencyKind: 'general', routeOrigin: null, routeDestination: null },
|
||||||
{ id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null },
|
{ id: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null },
|
||||||
];
|
];
|
||||||
const SHIPS = [
|
const SHIPS = [
|
||||||
@@ -325,6 +325,19 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
|
|||||||
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
|
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('analyst: aircraft broadcasting an emergency are filterable by flag and code', async () => {
|
||||||
|
const flagged = await makeEngine().query({
|
||||||
|
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||||
|
filters: [{ field: 'emergency', op: 'eq', value: true }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(flagged.items.map((i) => i.id), ['N123']);
|
||||||
|
const coded = await makeEngine().query({
|
||||||
|
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||||
|
filters: [{ field: 'squawk', op: 'eq', value: '7700' }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(coded.items.map((i) => i.id), ['N123']);
|
||||||
|
});
|
||||||
|
|
||||||
test('analyst: satellites and local infrastructure are queryable layers', async () => {
|
test('analyst: satellites and local infrastructure are queryable layers', async () => {
|
||||||
const SATS = [
|
const SATS = [
|
||||||
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
|
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
|
||||||
@@ -506,6 +519,7 @@ test('time fields filter by ISO and derive ageHours; the feed window is exposed'
|
|||||||
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
assert.equal(age.coverage.layersQueried[0].window, 'last 24 h');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
|
||||||
let state = 'stale';
|
let state = 'stale';
|
||||||
const engine = createAnalystEngine({
|
const engine = createAnalystEngine({
|
||||||
|
|||||||
+242
-879
File diff suppressed because it is too large.
Load diff
+116
-298
@@ -24,12 +24,8 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
|
|||||||
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
|
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
|
||||||
'source-unavailable',
|
'source-unavailable',
|
||||||
);
|
);
|
||||||
_setMilitaryTrackingRefreshOutcomeForTest({
|
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
|
||||||
status: 'accepted',
|
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||||
ids: ['different'],
|
|
||||||
});
|
|
||||||
const missing =
|
|
||||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
|
||||||
assert.equal(missing.status, 'missing');
|
assert.equal(missing.status, 'missing');
|
||||||
assert.equal(missing.reason, 'target-absent-from-snapshot');
|
assert.equal(missing.reason, 'target-absent-from-snapshot');
|
||||||
});
|
});
|
||||||
@@ -71,11 +67,34 @@ test('military analyst record: full record maps every contract field', () => {
|
|||||||
aircraftClass: 'widebody',
|
aircraftClass: 'widebody',
|
||||||
originCountry: null,
|
originCountry: null,
|
||||||
operator: 'United States Air Force',
|
operator: 'United States Air Force',
|
||||||
|
squawk: null,
|
||||||
|
emergency: false,
|
||||||
|
emergencyKind: null,
|
||||||
|
emergencySource: null,
|
||||||
|
emergencyBroadcast: null,
|
||||||
|
observedAtMs: null,
|
||||||
routeOrigin: null,
|
routeOrigin: null,
|
||||||
routeDestination: null,
|
routeDestination: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('military analyst record: emergency flag is only raised for emergency severity', () => {
|
||||||
|
const hijack = mapAnalystRecord('ae01ce', {
|
||||||
|
...FULL_INFO,
|
||||||
|
squawk: '7500',
|
||||||
|
emergency: { kind: 'unlawful', severity: 'emergency', squawk: '7500' },
|
||||||
|
});
|
||||||
|
assert.equal(hijack.squawk, '7500');
|
||||||
|
assert.equal(hijack.emergency, true);
|
||||||
|
assert.equal(hijack.emergencyKind, 'unlawful');
|
||||||
|
const lifeguard = mapAnalystRecord('ae01ce', {
|
||||||
|
...FULL_INFO,
|
||||||
|
emergency: { kind: 'lifeguard', severity: 'priority', squawk: null },
|
||||||
|
});
|
||||||
|
assert.equal(lifeguard.emergency, false);
|
||||||
|
assert.equal(lifeguard.emergencyKind, 'lifeguard');
|
||||||
|
});
|
||||||
|
|
||||||
test('military analyst record: military is ALWAYS true, routes/country always null', () => {
|
test('military analyst record: military is ALWAYS true, routes/country always null', () => {
|
||||||
const r = mapAnalystRecord('ae01ce', undefined);
|
const r = mapAnalystRecord('ae01ce', undefined);
|
||||||
assert.equal(r.military, true);
|
assert.equal(r.military, true);
|
||||||
@@ -85,14 +104,8 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('military analyst record: no callsign falls back to registration, then icao24', () => {
|
test('military analyst record: no callsign falls back to registration, then icao24', () => {
|
||||||
assert.equal(
|
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
|
||||||
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
|
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
|
||||||
'05-8152',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
|
|
||||||
'ae01ce',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
|
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
|
||||||
@@ -101,8 +114,7 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
|
|||||||
assert.equal(r.onGround, false);
|
assert.equal(r.onGround, false);
|
||||||
for (const [key, value] of Object.entries(r)) {
|
for (const [key, value] of Object.entries(r)) {
|
||||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||||
if (typeof value === 'number')
|
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||||
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,17 +126,12 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
|
|||||||
test('military first update forwards caller cancellation into the feed request', async () => {
|
test('military first update forwards caller cancellation into the feed request', async () => {
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
let observedSignal = null;
|
let observedSignal = null;
|
||||||
globalThis.fetch = (_url, options = {}) =>
|
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||||
new Promise((_resolve, reject) => {
|
observedSignal = options.signal;
|
||||||
observedSignal = options.signal;
|
options.signal?.addEventListener('abort', () => {
|
||||||
options.signal?.addEventListener(
|
reject(new DOMException('aborted', 'AbortError'));
|
||||||
'abort',
|
}, { once: true });
|
||||||
() => {
|
});
|
||||||
reject(new DOMException('aborted', 'AbortError'));
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
|
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
|
||||||
@@ -154,22 +161,11 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
|
|||||||
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
|
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await militaryFlightsLayer.update({
|
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||||
camera: { positionCartographic: null },
|
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||||
scene: {},
|
|
||||||
});
|
|
||||||
const resolution =
|
|
||||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
|
||||||
assert.equal(resolution.status, 'source-unavailable');
|
assert.equal(resolution.status, 'source-unavailable');
|
||||||
assert.match(
|
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
|
||||||
militaryFlightsLayer.getStats().error,
|
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
|
||||||
/Malformed adsb\.lol aircraft rows/,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
militaryFlightsLayer.getAnalystRecords().length,
|
|
||||||
1,
|
|
||||||
'warm aircraft data is preserved',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
}
|
}
|
||||||
@@ -215,26 +211,21 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
|||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
ac:
|
ac: poll++ === 0 ? [{
|
||||||
poll++ === 0
|
hex: icao24,
|
||||||
? [
|
lon: -96.9,
|
||||||
{
|
lat: 31.1,
|
||||||
hex: icao24,
|
alt_baro: 28_000,
|
||||||
lon: -96.9,
|
alt_geom: 28_100,
|
||||||
lat: 31.1,
|
track: 95,
|
||||||
alt_baro: 28_000,
|
gs: 400,
|
||||||
alt_geom: 28_100,
|
seen: 0,
|
||||||
track: 95,
|
seen_pos: 0,
|
||||||
gs: 400,
|
flight: 'RCH451 ',
|
||||||
seen: 0,
|
t: 'C17',
|
||||||
seen_pos: 0,
|
r: '05-8152',
|
||||||
flight: 'RCH451 ',
|
ownOp: 'United States Air Force',
|
||||||
t: 'C17',
|
}] : [],
|
||||||
r: '05-8152',
|
|
||||||
ownOp: 'United States Air Force',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,10 +250,7 @@ test('cached military snapshots retain their observation time and expose stale f
|
|||||||
_setTrackedMilitaryRefreshStateForTest({
|
_setTrackedMilitaryRefreshStateForTest({
|
||||||
icao24: 'ae01ce',
|
icao24: 'ae01ce',
|
||||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||||
billboard: {
|
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
|
||||||
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
billboardCollection: { show: false, remove() {} },
|
billboardCollection: { show: false, remove() {} },
|
||||||
viewer,
|
viewer,
|
||||||
history,
|
history,
|
||||||
@@ -300,18 +288,11 @@ test('cached military snapshots retain their observation time and expose stale f
|
|||||||
await militaryFlightsLayer.update(viewer);
|
await militaryFlightsLayer.update(viewer);
|
||||||
assert.equal(history.length, 1);
|
assert.equal(history.length, 1);
|
||||||
assert.equal(history[0].epochMs, receivedAt - 2000);
|
assert.equal(history[0].epochMs, receivedAt - 2000);
|
||||||
for (const [status, age] of [
|
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
|
||||||
['HIT', 5000],
|
|
||||||
['STALE', 45000],
|
|
||||||
]) {
|
|
||||||
cache = status;
|
cache = status;
|
||||||
now = receivedAt + age;
|
now = receivedAt + age;
|
||||||
await militaryFlightsLayer.update(viewer);
|
await militaryFlightsLayer.update(viewer);
|
||||||
assert.equal(
|
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
|
||||||
history.length,
|
|
||||||
1,
|
|
||||||
'replayed positions cannot create a new fix',
|
|
||||||
);
|
|
||||||
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
|
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
|
||||||
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
|
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
|
||||||
assert.equal(militaryFlightsLayer.getStats().error, null);
|
assert.equal(militaryFlightsLayer.getStats().error, null);
|
||||||
@@ -338,18 +319,14 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
trackedEntityChanged,
|
trackedEntityChanged,
|
||||||
clock: { currentTime: now },
|
clock: { currentTime: now },
|
||||||
camera: {
|
camera: {
|
||||||
cancelFlight() {
|
cancelFlight() { cancelledFlights += 1; },
|
||||||
cancelledFlights += 1;
|
|
||||||
},
|
|
||||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||||
direction: Cesium.Cartesian3.UNIT_Y,
|
direction: Cesium.Cartesian3.UNIT_Y,
|
||||||
transform: Cesium.Matrix4.IDENTITY,
|
transform: Cesium.Matrix4.IDENTITY,
|
||||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||||
lookAtTransform() {
|
lookAtTransform() { appliedFrames += 1; },
|
||||||
appliedFrames += 1;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
scene: {
|
scene: {
|
||||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||||
@@ -388,12 +365,9 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
globalThis.window = new EventTarget();
|
globalThis.window = new EventTarget();
|
||||||
const selectionEvents = [];
|
const selectionEvents = [];
|
||||||
globalThis.window.addEventListener(
|
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
|
||||||
'gev:awareness-subject-selected',
|
selectionEvents.push(event.detail);
|
||||||
(event) => {
|
});
|
||||||
selectionEvents.push(event.detail);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
globalThis.fetch = async () => ({ ok: false });
|
globalThis.fetch = async () => ({ ok: false });
|
||||||
_setTrackedOverlayHostForTest(host);
|
_setTrackedOverlayHostForTest(host);
|
||||||
try {
|
try {
|
||||||
@@ -405,15 +379,13 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
billboardCollection: { show: true, remove() {} },
|
billboardCollection: { show: true, remove() {} },
|
||||||
viewer,
|
viewer,
|
||||||
tracked: false,
|
tracked: false,
|
||||||
history: [
|
history: [{
|
||||||
{
|
time: now,
|
||||||
time: now,
|
epochMs: Date.now(),
|
||||||
epochMs: Date.now(),
|
position,
|
||||||
position,
|
velocity: 231.5,
|
||||||
velocity: 231.5,
|
track: 92.1,
|
||||||
track: 92.1,
|
}],
|
||||||
},
|
|
||||||
],
|
|
||||||
meta: {
|
meta: {
|
||||||
...FULL_INFO,
|
...FULL_INFO,
|
||||||
type: 'C17',
|
type: 'C17',
|
||||||
@@ -423,72 +395,34 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const entity = viewer.trackedEntity;
|
const entity = viewer.trackedEntity;
|
||||||
assert.ok(
|
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
|
||||||
entity instanceof Cesium.Entity,
|
|
||||||
'trackById must create the real Cesium entity',
|
|
||||||
);
|
|
||||||
assert.equal(entity.label, undefined);
|
assert.equal(entity.label, undefined);
|
||||||
assert.ok(
|
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
|
||||||
entities.values.every((candidate) => candidate.label === undefined),
|
|
||||||
);
|
|
||||||
assert.deepEqual(entity.gevLabelModel, {
|
assert.deepEqual(entity.gevLabelModel, {
|
||||||
title: 'RCH451',
|
title: 'RCH451',
|
||||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
details: [
|
||||||
|
'C17 · 05-8152',
|
||||||
|
'United States Air Force · 28000 ft · 450 kt',
|
||||||
|
],
|
||||||
accent: '#ffd166',
|
accent: '#ffd166',
|
||||||
});
|
});
|
||||||
viewer.scene.preUpdate.raiseEvent();
|
viewer.scene.preUpdate.raiseEvent();
|
||||||
const initialAppliedFrames = appliedFrames;
|
const initialAppliedFrames = appliedFrames;
|
||||||
const initialCancelledFlights = cancelledFlights;
|
const initialCancelledFlights = cancelledFlights;
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
|
||||||
true,
|
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
cancelledFlights,
|
|
||||||
initialCancelledFlights,
|
|
||||||
'ordinary repeated tracking stays camera-idempotent',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
selectionEvents.at(-1)?.origin,
|
|
||||||
'user',
|
|
||||||
'same-target selection upgrades durable authority',
|
|
||||||
);
|
|
||||||
assert.equal(entity.gevSelectionOrigin, 'user');
|
assert.equal(entity.gevSelectionOrigin, 'user');
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
|
||||||
militaryFlightsLayer.refocusTrackedById('different-flight'),
|
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
|
||||||
false,
|
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
selectionEvents.at(-1)?.origin,
|
|
||||||
'voice',
|
|
||||||
'same-target refocus forwards explicit authority',
|
|
||||||
);
|
|
||||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||||
viewer.scene.preUpdate.raiseEvent();
|
viewer.scene.preUpdate.raiseEvent();
|
||||||
assert.equal(
|
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
|
||||||
viewer.trackedEntity,
|
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
|
||||||
entity,
|
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
|
||||||
'refocus must retain the exact tracked entity',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
appliedFrames,
|
|
||||||
initialAppliedFrames + 1,
|
|
||||||
'repeated refocus keeps one camera-frame owner',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
cancelledFlights,
|
|
||||||
initialCancelledFlights + 2,
|
|
||||||
'only explicit refocus requests cancel camera flights',
|
|
||||||
);
|
|
||||||
const publication = publications.at(-1);
|
const publication = publications.at(-1);
|
||||||
assert.equal(publication.sourceId, 'tracked');
|
assert.equal(publication.sourceId, 'tracked');
|
||||||
const entry = publication.entries[0];
|
const entry = publication.entries[0];
|
||||||
@@ -499,40 +433,23 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
|
|
||||||
const display = entity.position.getValue(now);
|
const display = entity.position.getValue(now);
|
||||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||||
assert.equal(
|
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
|
||||||
entry.position(),
|
|
||||||
display,
|
|
||||||
'host must reuse the exact cached display Cartesian',
|
|
||||||
);
|
|
||||||
|
|
||||||
militaryFlightsLayer.setParams(
|
militaryFlightsLayer.setParams(
|
||||||
{ selectedMilitaryTrackingId: 'late001' },
|
{ selectedMilitaryTrackingId: 'late001' },
|
||||||
{ origin: 'share-restore' },
|
{ origin: 'share-restore' },
|
||||||
);
|
);
|
||||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
|
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
_pendingMilitaryTrackingRestoreForTest(),
|
|
||||||
null,
|
|
||||||
'new user selection cancels stale restore',
|
|
||||||
);
|
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'late001',
|
icao24: 'late001',
|
||||||
billboard: {
|
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
|
||||||
...billboard,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
|
|
||||||
},
|
|
||||||
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
|
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
_applyPendingMilitaryTrackingRestoreForTest();
|
_applyPendingMilitaryTrackingRestoreForTest();
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
|
||||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
|
||||||
icao24,
|
|
||||||
);
|
|
||||||
|
|
||||||
militaryFlightsLayer.stopTracking();
|
militaryFlightsLayer.stopTracking();
|
||||||
militaryFlightsLayer.setParams(
|
militaryFlightsLayer.setParams(
|
||||||
@@ -541,25 +458,15 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
);
|
);
|
||||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
|
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
|
||||||
militaryFlightsLayer.stopTracking({ origin: 'user' });
|
militaryFlightsLayer.stopTracking({ origin: 'user' });
|
||||||
assert.equal(
|
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||||
_pendingMilitaryTrackingRestoreForTest(),
|
|
||||||
null,
|
|
||||||
'explicit clear cancels stale restore',
|
|
||||||
);
|
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'late002',
|
icao24: 'late002',
|
||||||
billboard: {
|
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
|
||||||
...billboard,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
|
|
||||||
},
|
|
||||||
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
|
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
_applyPendingMilitaryTrackingRestoreForTest();
|
_applyPendingMilitaryTrackingRestoreForTest();
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
militaryFlightsLayer.setParams(
|
militaryFlightsLayer.setParams(
|
||||||
{ selectedMilitaryTrackingId: 'late003' },
|
{ selectedMilitaryTrackingId: 'late003' },
|
||||||
@@ -574,18 +481,12 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
);
|
);
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'late003',
|
icao24: 'late003',
|
||||||
billboard: {
|
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
|
||||||
...billboard,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
|
|
||||||
},
|
|
||||||
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
|
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
_applyPendingMilitaryTrackingRestoreForTest();
|
_applyPendingMilitaryTrackingRestoreForTest();
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
militaryFlightsLayer.setParams(
|
militaryFlightsLayer.setParams(
|
||||||
{ selectedMilitaryTrackingId: 'late004' },
|
{ selectedMilitaryTrackingId: 'late004' },
|
||||||
@@ -594,27 +495,18 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
|
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'late004',
|
icao24: 'late004',
|
||||||
billboard: {
|
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
|
||||||
...billboard,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
|
|
||||||
},
|
|
||||||
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
|
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
|
||||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
|
||||||
'late004',
|
|
||||||
);
|
|
||||||
|
|
||||||
militaryFlightsLayer.setParams(
|
militaryFlightsLayer.setParams(
|
||||||
{ selectedMilitaryTrackingId: 'late005' },
|
{ selectedMilitaryTrackingId: 'late005' },
|
||||||
{ origin: 'local-restore' },
|
{ origin: 'local-restore' },
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
_pendingMilitaryTrackingRestoreForTest(),
|
_pendingMilitaryTrackingRestoreForTest(),
|
||||||
'late005',
|
'late005',
|
||||||
@@ -622,18 +514,12 @@ test('real military track path creates no native label and publishes every cache
|
|||||||
);
|
);
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'late005',
|
icao24: 'late005',
|
||||||
billboard: {
|
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
|
||||||
...billboard,
|
|
||||||
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
|
|
||||||
},
|
|
||||||
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
|
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
|
||||||
history: [],
|
history: [],
|
||||||
});
|
});
|
||||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||||
assert.equal(
|
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
|
||||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
|
||||||
'late005',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
militaryFlightsLayer.stopTracking();
|
militaryFlightsLayer.stopTracking();
|
||||||
viewer.scene.preUpdate.raiseEvent();
|
viewer.scene.preUpdate.raiseEvent();
|
||||||
@@ -678,47 +564,21 @@ test('a callsign-less contact is findable by the tail number the app displays',
|
|||||||
|
|
||||||
// The identity analyst_query hands back for this contact.
|
// The identity analyst_query hands back for this contact.
|
||||||
const analystId = mapAnalystRecord(icao24, {
|
const analystId = mapAnalystRecord(icao24, {
|
||||||
callsign: null,
|
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||||
registration: '6606',
|
|
||||||
rawLat: 40.71,
|
|
||||||
rawLon: -74.01,
|
|
||||||
}).id;
|
}).id;
|
||||||
assert.equal(
|
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
|
||||||
analystId,
|
|
||||||
'6606',
|
|
||||||
'the analyst names a callsign-less contact by its tail',
|
|
||||||
);
|
|
||||||
|
|
||||||
const found = militaryFlightsLayer.findByQuery(analystId);
|
const found = militaryFlightsLayer.findByQuery(analystId);
|
||||||
assert.equal(
|
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
|
||||||
found?.icao24,
|
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
|
||||||
icao24,
|
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
|
||||||
'and the tracker must resolve that same identity',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
militaryFlightsLayer.findByQuery('660')?.icao24,
|
|
||||||
icao24,
|
|
||||||
'prefix match too',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
militaryFlightsLayer.findByQuery(icao24)?.icao24,
|
|
||||||
icao24,
|
|
||||||
'hex still wins outright',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the analyst record carries the hex key the tracker keys on', () => {
|
test('the analyst record carries the hex key the tracker keys on', () => {
|
||||||
const record = mapAnalystRecord('ae7f01', {
|
const record = mapAnalystRecord('ae7f01', {
|
||||||
callsign: null,
|
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||||
registration: '6606',
|
|
||||||
rawLat: 40.71,
|
|
||||||
rawLon: -74.01,
|
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
|
||||||
record.icao24,
|
|
||||||
'ae7f01',
|
|
||||||
'a resolvable key must ride along with the display label',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a registration never outranks another contact’s exact callsign', () => {
|
test('a registration never outranks another contact’s exact callsign', () => {
|
||||||
@@ -731,35 +591,18 @@ test('a registration never outranks another contact’s exact callsign', () => {
|
|||||||
show: true,
|
show: true,
|
||||||
});
|
});
|
||||||
const base = {
|
const base = {
|
||||||
type: 'C17',
|
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
|
||||||
operator: 'USAF',
|
track: 90, klass: 'widebody', onGround: false,
|
||||||
altitudeFt: 25000,
|
|
||||||
speedMps: 180,
|
|
||||||
track: 90,
|
|
||||||
klass: 'widebody',
|
|
||||||
onGround: false,
|
|
||||||
};
|
};
|
||||||
// Registered FIRST, so insertion order favours it.
|
// Registered FIRST, so insertion order favours it.
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'ae0aa1',
|
icao24: 'ae0aa1',
|
||||||
meta: {
|
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
|
||||||
...base,
|
|
||||||
callsign: null,
|
|
||||||
registration: 'ZULU777',
|
|
||||||
rawLat: 31.0,
|
|
||||||
rawLon: -97.0,
|
|
||||||
},
|
|
||||||
billboard: billboardAt(-97.0, 31.0),
|
billboard: billboardAt(-97.0, 31.0),
|
||||||
});
|
});
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'ae0bb2',
|
icao24: 'ae0bb2',
|
||||||
meta: {
|
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
|
||||||
...base,
|
|
||||||
callsign: 'ZULU777',
|
|
||||||
registration: '05-9999',
|
|
||||||
rawLat: 31.2,
|
|
||||||
rawLon: -97.2,
|
|
||||||
},
|
|
||||||
billboard: billboardAt(-97.2, 31.2),
|
billboard: billboardAt(-97.2, 31.2),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -782,49 +625,24 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
|||||||
show: true,
|
show: true,
|
||||||
});
|
});
|
||||||
const base = {
|
const base = {
|
||||||
type: 'C130',
|
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
|
||||||
operator: 'USAF',
|
track: 45, klass: 'turboprop', onGround: false,
|
||||||
altitudeFt: 21000,
|
|
||||||
speedMps: 150,
|
|
||||||
track: 45,
|
|
||||||
klass: 'turboprop',
|
|
||||||
onGround: false,
|
|
||||||
};
|
};
|
||||||
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
|
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
|
||||||
// highest-hex first so insertion order would pick the wrong one.
|
// highest-hex first so insertion order would pick the wrong one.
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'aef002',
|
icao24: 'aef002',
|
||||||
meta: {
|
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
|
||||||
...base,
|
|
||||||
callsign: null,
|
|
||||||
registration: 'QQ7-222',
|
|
||||||
rawLat: 32.0,
|
|
||||||
rawLon: -98.0,
|
|
||||||
},
|
|
||||||
billboard: billboardAt(-98.0, 32.0),
|
billboard: billboardAt(-98.0, 32.0),
|
||||||
});
|
});
|
||||||
_addMilitaryTrackingCandidateForTest({
|
_addMilitaryTrackingCandidateForTest({
|
||||||
icao24: 'aef001',
|
icao24: 'aef001',
|
||||||
meta: {
|
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
|
||||||
...base,
|
|
||||||
callsign: null,
|
|
||||||
registration: 'QQ7-111',
|
|
||||||
rawLat: 32.1,
|
|
||||||
rawLon: -98.1,
|
|
||||||
},
|
|
||||||
billboard: billboardAt(-98.1, 32.1),
|
billboard: billboardAt(-98.1, 32.1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
|
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
|
||||||
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
|
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
|
||||||
assert.equal(
|
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
|
||||||
first,
|
assert.equal(second, first, 'and the same query resolves the same way every time');
|
||||||
'aef001',
|
|
||||||
'the stable key (lowest hex) wins, not the feed order',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
second,
|
|
||||||
first,
|
|
||||||
'and the same query resolves the same way every time',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ export {
|
|||||||
ERROR_BACKOFF_INTERVAL,
|
ERROR_BACKOFF_INTERVAL,
|
||||||
} from './recordPolicy.js';
|
} from './recordPolicy.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||||
|
|
||||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||||
|
|
||||||
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
|||||||
|
|
||||||
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||||
|
|
||||||
|
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
|
||||||
|
|
||||||
|
export const EMERGENCY_TINT =
|
||||||
|
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
|
||||||
|
|
||||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||||
// taxiing and landing") -----------------------------------------------------------
|
// taxiing and landing") -----------------------------------------------------------
|
||||||
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
FOCUS_EVIDENCE_DEV,
|
FOCUS_EVIDENCE_DEV,
|
||||||
FLEET_DR_INTERVAL_MS,
|
FLEET_DR_INTERVAL_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||||
|
|
||||||
export function createQueries({
|
export function createQueries({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -167,6 +168,9 @@ export function createQueries({
|
|||||||
flightState.records.missingPolls.get(icao24) ||
|
flightState.records.missingPolls.get(icao24) ||
|
||||||
flightState.feed._backoff,
|
flightState.feed._backoff,
|
||||||
),
|
),
|
||||||
|
// This poll's transponder broadcast (not sticky; see records.js).
|
||||||
|
squawk: info?.squawk ?? null,
|
||||||
|
emergency: info?.emergency ? { ...info.emergency } : null,
|
||||||
airline: info?.airline ?? null,
|
airline: info?.airline ?? null,
|
||||||
// CLASS label follows the TR-3B conversion so every downstream card
|
// CLASS label follows the TR-3B conversion so every downstream card
|
||||||
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
|
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
|
||||||
@@ -219,7 +223,10 @@ export function createQueries({
|
|||||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||||
* military: boolean, aircraftClass: string|null, originCountry: string|null,
|
* military: boolean, aircraftClass: string|null, originCountry: string|null,
|
||||||
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
|
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||||
|
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
|
||||||
|
* emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: string|null,
|
||||||
|
* routeDestination: string|null}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function mapAnalystRecord(
|
function mapAnalystRecord(
|
||||||
@@ -253,6 +260,20 @@ export function createQueries({
|
|||||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||||
originCountry: text(info?.originCountry),
|
originCountry: text(info?.originCountry),
|
||||||
operator: text(info?.airline),
|
operator: text(info?.airline),
|
||||||
|
squawk: text(info?.squawk),
|
||||||
|
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||||
|
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||||
|
// without the flag.
|
||||||
|
emergency: info?.emergency?.severity === 'emergency',
|
||||||
|
emergencyKind: text(info?.emergency?.kind),
|
||||||
|
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||||
|
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||||
|
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||||
|
emergencySource: text(info?.emergency?.source),
|
||||||
|
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||||
|
// Epoch ms of the last transponder message — when this squawk/status
|
||||||
|
// was heard (OpenSky time_contact; readsb snapshot time minus `seen`).
|
||||||
|
observedAtMs: num(info?.lastContactEpochMs),
|
||||||
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
||||||
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
pickRenderAltitudeM,
|
pickRenderAltitudeM,
|
||||||
} from '../../data/renderAltitude.js';
|
} from '../../data/renderAltitude.js';
|
||||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||||
|
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import {
|
import {
|
||||||
approxDistanceKm,
|
approxDistanceKm,
|
||||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||||
@@ -226,6 +227,10 @@ export class FlightRecords {
|
|||||||
// like callsign so a transient blank row doesn't blank the field.
|
// like callsign so a transient blank row doesn't blank the field.
|
||||||
originCountry:
|
originCountry:
|
||||||
stickyText(origin_country, prevMeta?.originCountry) || null,
|
stickyText(origin_country, prevMeta?.originCountry) || null,
|
||||||
|
// Per poll, NOT sticky: an emergency code clears when the crew resets
|
||||||
|
// it, so a blank squawk must not hold a stale 7700 on screen.
|
||||||
|
squawk: observation.squawk ?? null,
|
||||||
|
emergency: aircraftEmergency(observation),
|
||||||
// OpenSky distinguishes the last position epoch from the last
|
// OpenSky distinguishes the last position epoch from the last
|
||||||
// transponder message. The fleet coast horizon uses this actual
|
// transponder message. The fleet coast horizon uses this actual
|
||||||
// contact time so a temporarily old position does not hard-freeze
|
// contact time so a temporarily old position does not hard-freeze
|
||||||
|
|||||||
@@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col
|
|||||||
assert.equal(next.meta.renderAltitudeM, 1040);
|
assert.equal(next.meta.renderAltitudeM, 1040);
|
||||||
assert.equal(next.meta.wasAirborne, true);
|
assert.equal(next.meta.wasAirborne, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('civil emergency status follows the latest poll instead of sticking', () => {
|
||||||
|
const store = records();
|
||||||
|
const first = store.receive(observation({ squawk: '7700' }), view());
|
||||||
|
assert.equal(first.meta.squawk, '7700');
|
||||||
|
assert.equal(first.meta.emergency.kind, 'general');
|
||||||
|
const next = store.receive(observation({ squawk: '2000' }), view());
|
||||||
|
assert.equal(next.meta.squawk, '2000');
|
||||||
|
assert.equal(next.meta.emergency, null);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
|||||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||||
|
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||||
import {
|
import {
|
||||||
MIL_TINT,
|
MIL_TINT,
|
||||||
|
EMERGENCY_TINT,
|
||||||
GROUND_SCALE,
|
GROUND_SCALE,
|
||||||
COCKPIT_CONTACT_SIZE_PX,
|
COCKPIT_CONTACT_SIZE_PX,
|
||||||
COCKPIT_CIVILIAN_COLOR,
|
COCKPIT_CIVILIAN_COLOR,
|
||||||
@@ -54,13 +56,34 @@ export function createRendering({
|
|||||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
||||||
services.recession;
|
services.recession;
|
||||||
|
|
||||||
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
|
/** True while this contact's latest poll broadcast an emergency code. */
|
||||||
|
|
||||||
|
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
|
||||||
|
return isEmergency(info?.emergency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
|
||||||
|
* amber for known-military, white otherwise.
|
||||||
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
||||||
|
|
||||||
function _fleetBillboardColor(icao24) {
|
function _fleetBillboardColor(
|
||||||
|
icao24,
|
||||||
|
info = flightState.records.data.get(icao24),
|
||||||
|
) {
|
||||||
|
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
|
||||||
|
|
||||||
|
function _cockpitContactColor(
|
||||||
|
icao24,
|
||||||
|
info = flightState.records.data.get(icao24),
|
||||||
|
) {
|
||||||
|
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||||
|
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||||
|
|
||||||
function _fleetBillboardScale(icao24, klass) {
|
function _fleetBillboardScale(icao24, klass) {
|
||||||
@@ -129,9 +152,7 @@ export function createRendering({
|
|||||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||||
bb.scale = limbScale;
|
bb.scale = limbScale;
|
||||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||||
bb.color = (
|
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
|
||||||
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
|
|
||||||
).withAlpha(freshnessAlpha);
|
|
||||||
bb.rotation = 0;
|
bb.rotation = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +183,7 @@ export function createRendering({
|
|||||||
|
|
||||||
function _modelColor(icao24) {
|
function _modelColor(icao24) {
|
||||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
return _fleetBillboardColor(icao24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
@@ -1009,10 +1030,8 @@ export function createRendering({
|
|||||||
flightState._cockpitNearContacts.has(icao24);
|
flightState._cockpitNearContacts.has(icao24);
|
||||||
const layerBaseColor =
|
const layerBaseColor =
|
||||||
flightState._cockpitContactMode && !isCockpitNear
|
flightState._cockpitContactMode && !isCockpitNear
|
||||||
? isMilitaryIcao(icao24)
|
? _cockpitContactColor(icao24, info)
|
||||||
? MIL_TINT
|
: _fleetBillboardColor(icao24, info);
|
||||||
: COCKPIT_CIVILIAN_COLOR
|
|
||||||
: _fleetBillboardColor(icao24);
|
|
||||||
const baseColor = layerBaseColor;
|
const baseColor = layerBaseColor;
|
||||||
const treatment = applyAircraftBillboardTreatment({
|
const treatment = applyAircraftBillboardTreatment({
|
||||||
billboard: bb,
|
billboard: bb,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export {
|
|||||||
ERROR_BACKOFF_INTERVAL,
|
ERROR_BACKOFF_INTERVAL,
|
||||||
} from './recordPolicy.js';
|
} from './recordPolicy.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module militaryFlights
|
* @module militaryFlights
|
||||||
@@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800');
|
|||||||
|
|
||||||
export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
export const TRACKED_ICON_COLOR = Cesium.Color.fromCssColorString('#FFD166');
|
||||||
|
|
||||||
|
/** @constant {Cesium.Color} Red tint for aircraft broadcasting an emergency (mirror of flights) */
|
||||||
|
|
||||||
|
export const EMERGENCY_ICON_COLOR =
|
||||||
|
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
|
||||||
|
|
||||||
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
|
// --- Ground traffic (owner reversal 2026-07-03; mirror of flights.js) ---------------
|
||||||
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
|
// adsb.lol/readsb flags ground traffic with alt_baro === "ground" (no separate
|
||||||
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
// boolean). Such aircraft are RENDERED instead of floating at the 3 km altitude
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CONTACT_MATCH_TIER,
|
CONTACT_MATCH_TIER,
|
||||||
} from '../../data/contactMatch.js';
|
} from '../../data/contactMatch.js';
|
||||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||||
|
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||||
|
|
||||||
export function createQueries({
|
export function createQueries({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -133,6 +134,9 @@ export function createQueries({
|
|||||||
flightState.records.missingPolls.get(icao24) ||
|
flightState.records.missingPolls.get(icao24) ||
|
||||||
flightState.feed._backoff,
|
flightState.feed._backoff,
|
||||||
),
|
),
|
||||||
|
// This poll's transponder broadcast (not sticky; see records.js).
|
||||||
|
squawk: info?.squawk ?? null,
|
||||||
|
emergency: info?.emergency ? { ...info.emergency } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +160,9 @@ export function createQueries({
|
|||||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||||
* military: boolean, aircraftClass: string|null, originCountry: null,
|
* military: boolean, aircraftClass: string|null, originCountry: null,
|
||||||
* operator: string|null, routeOrigin: null, routeDestination: null}}
|
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||||
|
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
|
||||||
|
* emergencyBroadcast: string|null, observedAtMs: number|null, routeOrigin: null, routeDestination: null}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function mapAnalystRecord(icao24, info) {
|
function mapAnalystRecord(icao24, info) {
|
||||||
@@ -187,6 +193,20 @@ export function createQueries({
|
|||||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||||
originCountry: null,
|
originCountry: null,
|
||||||
operator: text(info?.operator),
|
operator: text(info?.operator),
|
||||||
|
squawk: text(info?.squawk),
|
||||||
|
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||||
|
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||||
|
// without the flag.
|
||||||
|
emergency: info?.emergency?.severity === 'emergency',
|
||||||
|
emergencyKind: text(info?.emergency?.kind),
|
||||||
|
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||||
|
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||||
|
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||||
|
emergencySource: text(info?.emergency?.source),
|
||||||
|
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||||
|
// Epoch ms of the last transponder message — when this squawk/status
|
||||||
|
// was heard (OpenSky time_contact; readsb snapshot time minus `seen`).
|
||||||
|
observedAtMs: num(info?.lastContactEpochMs),
|
||||||
routeOrigin: null,
|
routeOrigin: null,
|
||||||
routeDestination: null,
|
routeDestination: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||||||
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
|
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
|
||||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||||
|
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import {
|
import {
|
||||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||||
LANDED_MISSING_POLL_LIMIT,
|
LANDED_MISSING_POLL_LIMIT,
|
||||||
@@ -161,6 +162,9 @@ export class MilitaryFlightRecords {
|
|||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||||
|
// Per poll, NOT sticky (mirror of flights): a cleared code clears here.
|
||||||
|
squawk: aircraft.squawk ?? null,
|
||||||
|
emergency: aircraftEmergency(aircraft),
|
||||||
onGround,
|
onGround,
|
||||||
// Round 7: sticky airborne history (see _likelyLanded).
|
// Round 7: sticky airborne history (see _likelyLanded).
|
||||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||||
|
|||||||
@@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only
|
|||||||
'remove',
|
'remove',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('military emergency status follows the latest poll instead of sticking', () => {
|
||||||
|
const records = store();
|
||||||
|
const first = records.receive(
|
||||||
|
observation({ squawk: '1234', emergency: 'minfuel' }),
|
||||||
|
context(),
|
||||||
|
);
|
||||||
|
assert.equal(first.meta.squawk, '1234');
|
||||||
|
assert.equal(first.meta.emergency.kind, 'minfuel');
|
||||||
|
assert.equal(first.meta.emergency.severity, 'priority');
|
||||||
|
const next = records.receive(
|
||||||
|
observation({ squawk: '1234', emergency: 'none' }),
|
||||||
|
context(),
|
||||||
|
);
|
||||||
|
assert.equal(next.meta.emergency, null);
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
screenProjectedRotation,
|
screenProjectedRotation,
|
||||||
} from '../../data/iconOrientation.js';
|
} from '../../data/iconOrientation.js';
|
||||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||||
|
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||||
import {
|
import {
|
||||||
PLANE_MODEL_SCALE,
|
PLANE_MODEL_SCALE,
|
||||||
PLANE_MODEL_URL,
|
PLANE_MODEL_URL,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
COCKPIT_CONTACT_SIZE_PX,
|
COCKPIT_CONTACT_SIZE_PX,
|
||||||
MIL_ICON_COLOR,
|
MIL_ICON_COLOR,
|
||||||
TRACKED_ICON_COLOR,
|
TRACKED_ICON_COLOR,
|
||||||
|
EMERGENCY_ICON_COLOR,
|
||||||
MODEL_ALT_CEIL_M,
|
MODEL_ALT_CEIL_M,
|
||||||
MODEL_MAX_ALL,
|
MODEL_MAX_ALL,
|
||||||
MODEL_MAX,
|
MODEL_MAX,
|
||||||
@@ -159,7 +161,7 @@ export function createRendering({
|
|||||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||||
bb.scale = limbScale;
|
bb.scale = limbScale;
|
||||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||||
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
|
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
|
||||||
bb.rotation = 0;
|
bb.rotation = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,7 +175,9 @@ export function createRendering({
|
|||||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||||
bb.scale = _militaryBillboardScale(icao24) * limbScale;
|
bb.scale = _militaryBillboardScale(icao24) * limbScale;
|
||||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||||
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
|
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
|
||||||
|
cyberSonarBaseAlpha(bb),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||||
@@ -189,7 +193,17 @@ export function createRendering({
|
|||||||
|
|
||||||
function _modelColor(icao24) {
|
function _modelColor(icao24) {
|
||||||
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
||||||
return MIL_ICON_COLOR;
|
return _fleetBillboardColor(icao24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
||||||
|
* amber otherwise. Cockpit far-contact dots use it too. */
|
||||||
|
|
||||||
|
function _fleetBillboardColor(
|
||||||
|
icao24,
|
||||||
|
info = flightState.records.data.get(icao24),
|
||||||
|
) {
|
||||||
|
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||||
@@ -965,7 +979,7 @@ export function createRendering({
|
|||||||
const isCockpitNear =
|
const isCockpitNear =
|
||||||
flightState._cockpitContactMode &&
|
flightState._cockpitContactMode &&
|
||||||
flightState._cockpitNearContacts.has(icao24);
|
flightState._cockpitNearContacts.has(icao24);
|
||||||
const baseColor = MIL_ICON_COLOR;
|
const baseColor = _fleetBillboardColor(icao24, info);
|
||||||
const treatment = applyAircraftBillboardTreatment({
|
const treatment = applyAircraftBillboardTreatment({
|
||||||
billboard: bb,
|
billboard: bb,
|
||||||
baseScale:
|
baseScale:
|
||||||
@@ -1082,6 +1096,7 @@ export function createRendering({
|
|||||||
_applyFleetBillboardPresentation,
|
_applyFleetBillboardPresentation,
|
||||||
_iconKind,
|
_iconKind,
|
||||||
_modelColor,
|
_modelColor,
|
||||||
|
_fleetBillboardColor,
|
||||||
_modelRegimeActive,
|
_modelRegimeActive,
|
||||||
_modelCap,
|
_modelCap,
|
||||||
_modelAddDistM,
|
_modelAddDistM,
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ import {
|
|||||||
} from '../../data/motionModel.js';
|
} from '../../data/motionModel.js';
|
||||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||||
import {
|
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||||
BILLBOARD_SCALE,
|
|
||||||
GROUND_SCALE,
|
|
||||||
TRACKED_ICON_COLOR,
|
|
||||||
MIL_ICON_COLOR,
|
|
||||||
} from './policy.js';
|
|
||||||
|
|
||||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||||
export function createMilitarySnapshotRenderer({
|
export function createMilitarySnapshotRenderer({
|
||||||
@@ -161,7 +156,9 @@ export function createMilitarySnapshotRenderer({
|
|||||||
// Screen-projected rotation lands on the next fleet tick.
|
// Screen-projected rotation lands on the next fleet tick.
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||||
color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR,
|
color: isTracked
|
||||||
|
? TRACKED_ICON_COLOR
|
||||||
|
: rendering._fleetBillboardColor(icao24, meta),
|
||||||
sizeInMeters: false,
|
sizeInMeters: false,
|
||||||
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
scaleByDistance: rendering._normalBillboardScaleByDistance(),
|
||||||
// Grounded/near-surface planes sit at/below the photoreal tile
|
// Grounded/near-surface planes sit at/below the photoreal tile
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
TRAIL_COLOR,
|
TRAIL_COLOR,
|
||||||
BILLBOARD_SCALE,
|
BILLBOARD_SCALE,
|
||||||
GROUND_SCALE,
|
GROUND_SCALE,
|
||||||
MIL_ICON_COLOR,
|
|
||||||
AMBER_TRANSPARENT,
|
AMBER_TRANSPARENT,
|
||||||
TRACKED_ICON_COLOR,
|
TRACKED_ICON_COLOR,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -780,12 +779,16 @@ export function createTracking({
|
|||||||
bb.width = 20;
|
bb.width = 20;
|
||||||
bb.height = 20;
|
bb.height = 20;
|
||||||
// Ground-aware restore (a plane untracked while taxiing comes back at
|
// Ground-aware restore (a plane untracked while taxiing comes back at
|
||||||
// ground scale; tint is full amber on the ground and in the air).
|
// ground scale; the fleet tint is amber, or red while broadcasting an
|
||||||
|
// emergency, on the ground and in the air).
|
||||||
bb.scale =
|
bb.scale =
|
||||||
BILLBOARD_SCALE *
|
BILLBOARD_SCALE *
|
||||||
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
||||||
(meta?.onGround ? GROUND_SCALE : 1);
|
(meta?.onGround ? GROUND_SCALE : 1);
|
||||||
bb.color = MIL_ICON_COLOR;
|
bb.color = parts.rendering._fleetBillboardColor(
|
||||||
|
flightState._trackedIcao,
|
||||||
|
meta,
|
||||||
|
);
|
||||||
bb.rotation = flightState._lastTrackedRotation;
|
bb.rotation = flightState._lastTrackedRotation;
|
||||||
}
|
}
|
||||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export function normalizeOpenSkyAircraft(row) {
|
|||||||
typeCode: null,
|
typeCode: null,
|
||||||
registration: null,
|
registration: null,
|
||||||
operator: null,
|
operator: null,
|
||||||
|
squawk: cleanText(row[14]) || null,
|
||||||
|
// State vectors carry no ADS-B emergency field; the squawk stands alone.
|
||||||
|
emergency: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +71,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
|
|||||||
typeCode: cleanText(row.t),
|
typeCode: cleanText(row.t),
|
||||||
registration: cleanText(row.r),
|
registration: cleanText(row.r),
|
||||||
operator: cleanText(row.ownOp),
|
operator: cleanText(row.ownOp),
|
||||||
|
squawk: cleanText(row.squawk) || null,
|
||||||
|
emergency: cleanText(row.emergency).toLowerCase() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation'
|
|||||||
name: 'AbortError',
|
name: 'AbortError',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transponder squawk and ADS-B emergency status are carried, blanks stay null', () => {
|
||||||
|
const squawking = [...aircraft, '7700'];
|
||||||
|
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
|
||||||
|
assert.equal(civil.squawk, '7700');
|
||||||
|
assert.equal(civil.emergency, null);
|
||||||
|
assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null);
|
||||||
|
const mil = normalizeReadsbAircraft(
|
||||||
|
{ hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(mil.squawk, '7600');
|
||||||
|
assert.equal(mil.emergency, 'nordo');
|
||||||
|
const quiet = normalizeReadsbAircraft(
|
||||||
|
{ hex: 'abc123', lat: 30, lon: -97 },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
assert.equal(quiet.squawk, null);
|
||||||
|
assert.equal(quiet.emergency, null);
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
compassDivisions,
|
compassDivisions,
|
||||||
formatAltitudeRulerTick,
|
formatAltitudeRulerTick,
|
||||||
formatCompassDivision,
|
formatCompassDivision,
|
||||||
|
formatCockpitEmergency,
|
||||||
formatSpeedRulerTick,
|
formatSpeedRulerTick,
|
||||||
normalizeHeading,
|
normalizeHeading,
|
||||||
relativeBearing,
|
relativeBearing,
|
||||||
@@ -133,7 +134,15 @@ export function updateHud(
|
|||||||
: info.stale
|
: info.stale
|
||||||
? 'STALE FEED'
|
? 'STALE FEED'
|
||||||
: 'LIVE TRACK';
|
: 'LIVE TRACK';
|
||||||
this.aircraftMeta.textContent = `${info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL'} · ${feedState} · COURSE ALIGNED`;
|
const fleet = info.layerId === 'military' ? 'MILITARY' : 'COMMERCIAL';
|
||||||
|
const emergency = formatCockpitEmergency(info.emergency);
|
||||||
|
this.aircraftMeta.textContent = emergency
|
||||||
|
? `${fleet} · ${emergency} · ${feedState}`
|
||||||
|
: `${fleet} · ${feedState} · COURSE ALIGNED`;
|
||||||
|
this.aircraftMeta.classList.toggle(
|
||||||
|
'emergency',
|
||||||
|
info.emergency?.severity === 'emergency',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.updateRoute(info);
|
this.updateRoute(info);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
|
|
||||||
#cockpit-hud {
|
#cockpit-hud {
|
||||||
--cockpit-accent: #00dcff;
|
--cockpit-accent: #00dcff;
|
||||||
|
/* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */
|
||||||
|
--cockpit-emergency: #ff4444;
|
||||||
--cockpit-side-panel-width: min(340px, 28vw);
|
--cockpit-side-panel-width: min(340px, 28vw);
|
||||||
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
|
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
|
||||||
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
|
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
|
||||||
@@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
|
|||||||
font-size: 7px;
|
font-size: 7px;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
|
.cockpit-aircraft-meta.emergency {
|
||||||
|
color: var(--cockpit-emergency);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.cockpit-mode-chip {
|
.cockpit-mode-chip {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
padding: 7px 11px;
|
padding: 7px 11px;
|
||||||
|
|||||||
@@ -2270,6 +2270,12 @@
|
|||||||
color: rgba(205, 225, 226, 0.48);
|
color: rgba(205, 225, 226, 0.48);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-ui-theme='cyber']
|
||||||
|
body.cockpit-mode
|
||||||
|
.cockpit-aircraft-meta.emergency {
|
||||||
|
color: var(--cyber-red-bright);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
|
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
border-color: rgba(105, 203, 209, 0.38);
|
border-color: rgba(105, 203, 209, 0.38);
|
||||||
|
|||||||
Reference in new issue
Block a user