diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2bcca03..2e288e8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,20 @@ ## [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 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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 5bea2456d..4681f8984 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -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. - **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. +- **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. - **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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ccc6775da..a186b9c28 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 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 Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5eaaa6d6a..d9a732d93 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -18,6 +18,10 @@ "src/tooling/panelStorageDocs.test.mjs", "src/tooling/packageBoundaries.test.mjs", "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/localGeojsonCore.js", "src/data/localGeojsonLod.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b43565597..dd3442922 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -794,6 +794,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -837,6 +838,7 @@ "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -1494,6 +1496,7 @@ "src/contextModePolicy.js", "src/creditKeyboard.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2280,6 +2283,7 @@ "src/celestialRing.js", "src/cockpitMath.js", "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", @@ -2905,7 +2909,8 @@ "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, @@ -2924,7 +2929,8 @@ "src/layers/military/recordPolicy.js", "src/data/aircraftMeta.js", "src/data/renderAltitude.js", - "src/data/aircraftClass.js" + "src/data/aircraftClass.js", + "src/data/aircraftEmergency.js" ], "external": [] }, diff --git a/scripts/qa-emergency-squawk.mjs b/scripts/qa-emergency-squawk.mjs new file mode 100644 index 000000000..a1625ef6e --- /dev/null +++ b/scripts/qa-emergency-squawk.mjs @@ -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); +}); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index a0bd83676..74e6d1451 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) { 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. */ export function formatCockpitContextScope( subjectLabel, diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index ef1e8382b..2189ca35e 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -13,6 +13,7 @@ import { compassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, + formatCockpitEmergency, formatCompassDivision, formatSpeedRulerTick, normalizeHeading, @@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => { assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), 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); +}); diff --git a/src/data/aircraftEmergency.js b/src/data/aircraftEmergency.js new file mode 100644 index 000000000..3a555399e --- /dev/null +++ b/src/data/aircraftEmergency.js @@ -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'; +} diff --git a/src/data/aircraftEmergency.test.mjs b/src/data/aircraftEmergency.test.mjs new file mode 100644 index 000000000..4304dcb14 --- /dev/null +++ b/src/data/aircraftEmergency.test.mjs @@ -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); +}); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs new file mode 100644 index 000000000..8df428162 --- /dev/null +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -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']], + ); +}); diff --git a/src/data/analystEngine.test.mjs b/src/data/analystEngine.test.mjs index 8da85a4d6..3bb0dd06e 100644 --- a/src/data/analystEngine.test.mjs +++ b/src/data/analystEngine.test.mjs @@ -7,7 +7,7 @@ const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-10 const FLIGHTS = [ { 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: '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 }, ]; 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'); }); +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 () => { const SATS = [ { 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'); }); + test('follow-up provenance stays attached to old rows after a feed recovers', async () => { let state = 'stale'; const engine = createAnalystEngine({ diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index b09dc7014..6c6a586d6 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -18,10 +18,8 @@ import flightsLayer, { } from './flights.js'; import { setMilitaryLayerActive } from './militaryRegistry.js'; import { - GROUND_FLOOR_LIFT_M, - reportMeshFloorCell, - setMeshFloorPreferred, - _clearMeshFloorCellsForTest, + GROUND_FLOOR_LIFT_M, reportMeshFloorCell, + setMeshFloorPreferred, _clearMeshFloorCellsForTest, } from './groundFloor.js'; import { _setTrackedOverlayHostForTest, @@ -35,10 +33,7 @@ test('share-Follow absence requires an accepted OpenSky snapshot', async () => { (await flightsLayer.resolveTrackingRestoreTarget('abc123')).status, 'source-unavailable', ); - _setFlightTrackingRefreshOutcomeForTest({ - status: 'accepted', - ids: ['different'], - }); + _setFlightTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); const missing = await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); @@ -60,10 +55,7 @@ const FULL_INFO = { }; test('flights analyst record: full record maps every contract field', () => { - const r = mapAnalystRecord('a1b2c3', FULL_INFO, { - military: false, - routeOk: true, - }); + const r = mapAnalystRecord('a1b2c3', FULL_INFO, { military: false, routeOk: true }); assert.deepEqual(r, { id: 'SWA696', icao24: 'a1b2c3', @@ -79,6 +71,12 @@ test('flights analyst record: full record maps every contract field', () => { aircraftClass: 'airliner', originCountry: 'United States', operator: 'Southwest Airlines', + squawk: null, + emergency: false, + emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: 'AUS', routeDestination: 'LAX', }); @@ -98,8 +96,7 @@ test('flights analyst record: empty info yields nulls, never NaN/undefined', () assert.equal(r.military, false); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') - assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`); } }); @@ -107,31 +104,14 @@ test('flights analyst record: no callsign falls back to registration, then icao2 const r = mapAnalystRecord('abc123', { ...FULL_INFO, callsign: ' ' }); assert.equal(r.id, 'abc123'); // FULL_INFO carries no registration assert.equal(r.callsign, null); - assert.equal( - mapAnalystRecord('abc123', { - ...FULL_INFO, - callsign: '', - registration: 'N123AB', - }).id, - 'N123AB', - ); - assert.equal( - mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, - 'abc123', - ); + assert.equal(mapAnalystRecord('abc123', { ...FULL_INFO, callsign: '', registration: 'N123AB' }).id, 'N123AB'); + assert.equal(mapAnalystRecord('abc123', { callsign: '', registration: ' ' }).id, 'abc123'); }); test('flights analyst record: NaN kinematics become null, military flag passes through', () => { - const r = mapAnalystRecord( - 'ae01ce', - { - ...FULL_INFO, - velocity: NaN, - true_track: undefined, - verticalRate: null, - }, - { military: true }, - ); + const r = mapAnalystRecord('ae01ce', { + ...FULL_INFO, velocity: NaN, true_track: undefined, verticalRate: null, + }, { military: true }); assert.equal(r.speedMps, null); assert.equal(r.heading, null); assert.equal(r.verticalRateMps, null); @@ -146,25 +126,17 @@ test('flights analyst record: output is JSON-safe (no Cesium types)', () => { test('flights first update forwards caller cancellation into the feed request', async () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => - new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener( - 'abort', - () => { - reject(new DOMException('aborted', 'AbortError')); - }, - { once: true }, - ); - }); + globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); try { const controller = new AbortController(); - const work = flightsLayer.update( - { camera: { positionCartographic: null }, scene: {} }, - { - signal: controller.signal, - }, - ); + const work = flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }, { + signal: controller.signal, + }); await Promise.resolve(); assert.ok(observedSignal); controller.abort(); @@ -189,27 +161,14 @@ test('nonempty OpenSky payload with zero usable rows cannot prove share target a ok: true, status: 200, headers: { get: () => null }, - json: async () => ({ - states: [null, {}, ['abc123', null, null, null, null, null, null]], - }), + json: async () => ({ states: [null, {}, ['abc123', null, null, null, null, null, null]] }), }); try { - await flightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); - const resolution = - await flightsLayer.resolveTrackingRestoreTarget('abc123'); + await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); + const resolution = await flightsLayer.resolveTrackingRestoreTarget('abc123'); assert.equal(resolution.status, 'source-unavailable'); - assert.match( - flightsLayer.getStats().error, - /Malformed OpenSky aircraft rows/, - ); - assert.equal( - flightsLayer.getAnalystRecords().length, - 1, - 'warm aircraft data is preserved', - ); + assert.match(flightsLayer.getStats().error, /Malformed OpenSky aircraft rows/); + assert.equal(flightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); } finally { globalThis.fetch = realFetch; } @@ -347,31 +306,13 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA if (!String(url).startsWith('/api/flights')) { return { ok: true, status: 200, json: async () => ({ ac: [] }) }; } - const states = - openskyPoll++ === 0 - ? [ - [ - icao24, - 'DAL123 ', - 'United States', - nowSec, - nowSec, - -97.6, - 30.3, - 10_668, - false, - 250, - 95, - 5, - null, - 10_700, - null, - null, - null, - 5, - ], - ] - : []; + const states = openskyPoll++ === 0 + ? [[ + icao24, 'DAL123 ', 'United States', nowSec, nowSec, + -97.6, 30.3, 10_668, false, 250, 95, 5, null, 10_700, + null, null, null, 5, + ]] + : []; return { ok: true, status: 200, @@ -410,18 +351,14 @@ test('real civil track path creates no native label and publishes every cached h trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { - cancelledFlights += 1; - }, + cancelFlight() { cancelledFlights += 1; }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, + lookAtTransform() { appliedFrames += 1; }, }, scene: { canvas: { clientWidth: 1600, clientHeight: 900 }, @@ -460,12 +397,9 @@ test('real civil track path creates no native label and publishes every cached h const realFetch = globalThis.fetch; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener( - 'gev:awareness-subject-selected', - (event) => { - selectionEvents.push(event.detail); - }, - ); + globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { + selectionEvents.push(event.detail); + }); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -477,15 +411,13 @@ test('real civil track path creates no native label and publishes every cached h billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [ - { - time: now, - epochMs: Date.now(), - position, - velocity: 250, - track: 95, - }, - ], + history: [{ + time: now, + epochMs: Date.now(), + position, + velocity: 250, + track: 95, + }], meta: { callsign: 'N12345', altitude: 10_668, @@ -508,19 +440,11 @@ test('real civil track path creates no native label and publishes every cached h }, }); - assert.equal( - flightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); + assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + assert.ok(entities.values.every((candidate) => candidate.label === undefined)); assert.deepEqual(entity.gevLabelModel, { title: 'N12345 · FL350 · 486 kts', details: ['TEST AIR · A320', 'AUS → LAX'], @@ -530,44 +454,17 @@ test('real civil track path creates no native label and publishes every cached h const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - 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(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(flightsLayer.refocusTrackedById('different-flight'), false); - assert.equal( - flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), - true, - ); - assert.equal( - selectionEvents.at(-1)?.origin, - 'voice', - 'same-target refocus forwards explicit authority', - ); + assert.equal(flightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true); + assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority'); assert.equal(flightsLayer.refocusTrackedById(icao24), true); viewer.scene.preUpdate.raiseEvent(); - assert.equal( - viewer.trackedEntity, - entity, - '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', - ); + assert.equal(viewer.trackedEntity, entity, '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); assert.equal(publication.sourceId, 'tracked'); const entry = publication.entries[0]; @@ -578,11 +475,7 @@ test('real civil track path creates no native label and publishes every cached h const display = entity.position.getValue(now); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal( - entry.position(), - display, - 'host must reuse the exact cached display Cartesian', - ); + assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); flightsLayer.setParams( { selectedFlightsTrackingId: 'late001' }, @@ -590,17 +483,10 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late001'); assert.equal(flightsLayer.trackById(icao24, { origin: 'user' }), true); - assert.equal( - _pendingFlightTrackingRestoreForTest(), - null, - 'new user selection cancels stale restore', - ); + assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); _addFlightTrackingCandidateForTest({ icao24: 'late001', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.5, 30.4, 9_000) }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 30.4, rawLon: -97.5 }, history: [], }); @@ -614,17 +500,10 @@ test('real civil track path creates no native label and publishes every cached h ); assert.equal(_pendingFlightTrackingRestoreForTest(), 'late002'); flightsLayer.stopTracking({ origin: 'user' }); - assert.equal( - _pendingFlightTrackingRestoreForTest(), - null, - 'explicit clear cancels stale restore', - ); + assert.equal(_pendingFlightTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); _addFlightTrackingCandidateForTest({ icao24: 'late002', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.4, 30.5, 8_000) }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 30.5, rawLon: -97.4 }, history: [], }); @@ -644,10 +523,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late003', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); @@ -661,10 +537,7 @@ test('real civil track path creates no native label and publishes every cached h assert.equal(_pendingFlightTrackingRestoreForTest(), 'late004'); _addFlightTrackingCandidateForTest({ icao24: 'late004', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); @@ -675,10 +548,7 @@ test('real civil track path creates no native label and publishes every cached h { selectedFlightsTrackingId: 'late005' }, { origin: 'share-restore' }, ); - assert.equal( - flightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(flightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal( _pendingFlightTrackingRestoreForTest(), 'late005', @@ -686,10 +556,7 @@ test('real civil track path creates no native label and publishes every cached h ); _addFlightTrackingCandidateForTest({ icao24: 'late005', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.1, 30.8, 5_000) }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 30.8, rawLon: -97.1 }, history: [], }); @@ -724,11 +591,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, - billboard: { - position: LABEL_POSITION, - color: Cesium.Color.WHITE, - show: true, - }, + billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: true }, billboardCollection: { show: true, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, tracked, @@ -786,11 +649,7 @@ test('civil label chain: a blank callsign falls back to the registration, not th const labels = labelsFor({ callsign: ' ', registration: 'N123AB ' }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal( - value, - 'N123AB', - `${surface} must show the registration, not ${LABEL_ICAO}`, - ); + assert.equal(value, 'N123AB', `${surface} must show the registration, not ${LABEL_ICAO}`); } }); @@ -799,11 +658,7 @@ test('civil label chain: no callsign and no registration still reads as the ICAO const labels = labelsFor({ callsign: null, registration }); for (const [surface, value] of Object.entries(labels)) { if (surface === '_identity') continue; - assert.equal( - value, - LABEL_ICAO, - `${surface} must fall through to the hex`, - ); + assert.equal(value, LABEL_ICAO, `${surface} must fall through to the hex`); } } }); @@ -827,11 +682,7 @@ test('civil label chain: identity stays icao24 while the label moves', () => { ]) { const { _identity } = labelsFor(fixture); for (const [key, value] of Object.entries(_identity)) { - assert.equal( - value, - LABEL_ICAO, - `${key} identity must remain the ICAO hex`, - ); + assert.equal(value, LABEL_ICAO, `${key} identity must remain the ICAO hex`); } } }); @@ -878,11 +729,7 @@ test('civil label chain: the awareness selection EVENT uses the canonical chain' `selection event label for ${JSON.stringify(fixture)}`, ); // Identity must NOT move with the label. - assert.equal( - detail.id, - LABEL_ICAO, - 'selection event identity stays the ICAO hex', - ); + assert.equal(detail.id, LABEL_ICAO, 'selection event identity stays the ICAO hex'); assert.equal(detail.layerId, 'flights'); } }); @@ -893,11 +740,7 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity, - billboard: { - position: LABEL_POSITION, - color: Cesium.Color.WHITE, - show: false, - }, + billboard: { position: LABEL_POSITION, color: Cesium.Color.WHITE, show: false }, billboardCollection: { show: false, remove() {} }, viewer: { camera: { positionCartographic: null }, scene: {} }, meta: { @@ -918,16 +761,10 @@ test('civil tracked readout: a callsign-less enriched contact reads as its regis const realFetch = globalThis.fetch; globalThis.fetch = async () => ({ - ok: true, - status: 200, - headers: { get: () => null }, - json: async () => ({ time: 0, states: [] }), + ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }), }); try { - await flightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); + await flightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); assert.match(entity.gevLabelModel.title, /^N123AB\b/); assert.doesNotMatch( [entity.gevLabelModel.title, ...entity.gevLabelModel.details].join(' · '), @@ -1007,8 +844,7 @@ test('military suppression exempts the contact this layer is still restoring', ( // contacts only, never when a 3D model owns the visual (T7 one-shot). // --------------------------------------------------------------------------- -const _floorCarto = (pos) => - Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); +const _floorCarto = (pos) => Cesium.Cartographic.fromCartesian(pos, Cesium.Ellipsoid.WGS84); test('a newly seen taxiing aircraft starts on the floor under its delayed position', async () => { // A grounded contact is drawn at its delayed position lifted onto the floor @@ -1113,16 +949,10 @@ test('display floor: a grounded contact that drifted onto a higher cell is lifte // Fix cell floor was 124.7; the display has taxied into a 140.2 m cell. reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); const c = _floorCarto(out); - assert.ok( - Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `height ${c.height} should be 140.2 + lift`, - ); + assert.ok(Math.abs(c.height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `height ${c.height} should be 140.2 + lift`); // Lifted straight up — the drifted lat/lon is preserved exactly. assert.ok(Math.abs(Cesium.Math.toDegrees(c.latitude) - 30.2004) < 1e-6); assert.ok(Math.abs(Cesium.Math.toDegrees(c.longitude) + 97.6604) < 1e-6); @@ -1134,11 +964,7 @@ test('display floor: an AIRBORNE contact over the same cell is never lifted', () setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: false }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false); assert.equal(out, pos, 'airborne display positions pass through untouched'); }); @@ -1148,16 +974,8 @@ test('display floor: T7 — a model-owned grounded contact keeps its billboard d setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140.2); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - true, - ); - assert.equal( - out, - pos, - 'moving it would drag groundSnap past its 50 m re-sample threshold', - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, true); + assert.equal(out, pos, 'moving it would drag groundSnap past its 50 m re-sample threshold'); }); test('display floor: a contact already above the floor is passed through unchanged', () => { @@ -1166,11 +984,7 @@ test('display floor: a contact already above the floor is passed through unchang setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 124.7); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 140.2); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos, 'no rebuild when nothing needs lifting'); }); @@ -1179,11 +993,7 @@ test('display floor: a cold display cell leaves the contact where it is', () => _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos, 'no floor data → no clamp (never invent a surface)'); }); @@ -1194,11 +1004,7 @@ test('display floor: mesh cells are ignored outside the google-3d regime', () => reportMeshFloorCell(30.2004, -97.6604, 140.2); setMeshFloorPreferred(false); // globe stack: the DEM IS the rendered terrain const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false); assert.equal(out, pos); setMeshFloorPreferred(true); }); @@ -1214,23 +1020,14 @@ test('display floor: jitter across a cell edge does not alternate the floor', () reportMeshFloorCell(30.2014, -97.6604, 146); const at = (lat) => { const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), - false, - 'jitter', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'jitter', ); return _floorCarto(out).height; }; const settled = at(30.2004); // squarely inside the low cell - assert.ok( - Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, - `settled ${settled}`, - ); + assert.ok(Math.abs(settled - (140 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled ${settled}`); // 30.2006 belongs to the HIGH cell but is only ~11 m past the boundary. - assert.ok( - Math.abs(at(30.2006) - settled) < 0.05, - 'held the low cell across the edge', - ); + assert.ok(Math.abs(at(30.2006) - settled) < 0.05, 'held the low cell across the edge'); assert.ok(Math.abs(at(30.2004) - settled) < 0.05, 'and back again — no pop'); }); @@ -1240,21 +1037,12 @@ test('display floor: a real crossing does adopt the next cell', () => { setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); reportMeshFloorCell(30.2014, -97.6604, 146); - const at = (lat) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), - false, - 'crossing', - ), - ).height; + const at = (lat) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 120), false, 'crossing', + )).height; at(30.2004); const crossed = at(30.2014); // properly into the next cell - assert.ok( - Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, - `crossed ${crossed}`, - ); + assert.ok(Math.abs(crossed - (146 + GROUND_FLOOR_LIFT_M)) < 0.05, `crossed ${crossed}`); }); // --- F7: no rebuild when nothing changed ----------------------------------- @@ -1269,28 +1057,10 @@ test('display floor: a clamped stationary contact reuses its previous output', ( setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const first = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked', - ); - const second = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked', - ); - assert.notEqual( - first, - pos, - 'it really is clamped (not the pass-through case)', - ); - assert.equal( - second, - first, - 'same input + same floor ⇒ the very same object, no rebuild', - ); + const first = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); + const second = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'parked'); + assert.notEqual(first, pos, 'it really is clamped (not the pass-through case)'); + assert.equal(second, first, 'same input + same floor ⇒ the very same object, no rebuild'); }); test('display floor: the cached output is dropped when the floor changes under it', () => { @@ -1299,31 +1069,14 @@ test('display floor: the cached output is dropped when the floor changes under i setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120); - const before = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'warming', - ), - ).height; + const before = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; // A late mesh sample raises this cell (a fresh module state stands in for the // one-shot latch, which never overwrites). _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 175); - const after = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'warming', - ), - ).height; + const after = _floorCarto(_floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'warming')).height; assert.ok(Math.abs(before - 141.5) < 0.05, `before ${before}`); - assert.ok( - Math.abs(after - 176.5) < 0.05, - `after ${after} — a stale cache would still read 141.5`, - ); + assert.ok(Math.abs(after - 176.5) < 0.05, `after ${after} — a stale cache would still read 141.5`); }); // --- F8: hold the last known floor through a floor-data gap ---------------- @@ -1343,28 +1096,18 @@ test('display floor: a cold cell HOLDS the last floor that resolved for this con reportMeshFloorCell(30.2004, -97.6604, 140.2); // On the warm cell: clamped onto the floor as usual. const warm = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), - false, - 'held', - ); - assert.ok( - Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'held', ); + assert.ok(Math.abs(_floorCarto(warm).height - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05); // Taxis ~550 m onto a cell nothing has resolved, still at the geoid-ish // height the poll path handed it. Pre-fix this passed straight through — // buried. const cold = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), - false, - 'held', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 124.7), false, 'held', ); const h = _floorCarto(cold).height; - assert.ok( - Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`, - ); + assert.ok(Math.abs(h - (140.2 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${h} — a cold cell must hold 140.2 + lift, never the 124.7 it arrived with`); }); test('display floor: with NO prior anywhere the contact is left exactly where it is', () => { @@ -1372,17 +1115,8 @@ test('display floor: with NO prior anywhere the contact is left exactly where it _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'fresh', - ); - assert.equal( - out, - pos, - 'nothing measured anywhere ⇒ nothing invented — same object, no clamp', - ); + const out = _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'fresh'); + assert.equal(out, pos, 'nothing measured anywhere ⇒ nothing invented — same object, no clamp'); }); test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR', () => { @@ -1395,16 +1129,11 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' reportMeshFloorCell(30.2014, -97.6604, 141.0); reportMeshFloorCell(30.1994, -97.6604, 152.0); const out = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), - false, - 'neighbour', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7), false, 'neighbour', ); const h = _floorCarto(out).height; - assert.ok( - Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `neighbour hold ${h} — expected the lower of the two resolved neighbours`, - ); + assert.ok(Math.abs(h - (141.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `neighbour hold ${h} — expected the lower of the two resolved neighbours`); }); // Drives a stationary contact at the production 80 ms fleet cadence and reports @@ -1412,28 +1141,16 @@ test('display floor: a fresh contact on a cold cell adopts a resolved NEIGHBOUR' // `mutate` runs before the tick at that index, so a target can be moved // mid-approach. function _driveEase(id, pos, fromMs, ticks, mutate = () => {}) { - let previous = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - id, - fromMs, - ), - ).height; + let previous = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, id, fromMs, + )).height; const opened = previous; let maxStepM = 0; for (let i = 1; i <= ticks; i += 1) { mutate(i); - const h = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - id, - fromMs + i * 80, - ), - ).height; + const h = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, id, fromMs + i * 80, + )).height; maxStepM = Math.max(maxStepM, previous - h); previous = h; } @@ -1452,18 +1169,10 @@ test('display floor: a hold released onto a LOWER floor eases down, never snaps' if (i === 1) reportMeshFloorCell(30.2004, -97.6604, 160.0); // its own cell resolves lower }); const totalDropM = r.opened - (160.0 + GROUND_FLOOR_LIFT_M); - assert.ok( - Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `opened at ${r.opened}`, - ); - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`, - ); - assert.ok( - Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `settled at ${r.settled}`, - ); + assert.ok(Math.abs(r.opened - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `opened at ${r.opened}`); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — a snap is the whole drop at once`); + assert.ok(Math.abs(r.settled - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, `settled at ${r.settled}`); }); test('display floor: a hold released onto a HIGHER floor rises immediately', () => { @@ -1471,26 +1180,17 @@ test('display floor: a hold released onto a HIGHER floor rises immediately', () _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'rise', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'rise', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 160.0); reportMeshFloorCell(30.1994, -97.6604, 190.0); assert.ok(Math.abs(at(1000) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05); reportMeshFloorCell(30.2004, -97.6604, 205.0); // No easing UP: an eased rise is time spent under the mesh, which is the // whole failure this path exists to prevent. - assert.ok( - Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the next tick is already on the higher floor', - ); + assert.ok(Math.abs(at(1001) - (205.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the next tick is already on the higher floor'); }); // --- F9: the hold is a live state machine, not a one-shot ------------------ @@ -1504,24 +1204,17 @@ test('display floor: a STATIONARY unresolved contact still adopts a floor that a _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); - const at = (ms) => - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'parked-cold', - ms, - ); + const at = (ms) => _floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'parked-cold', ms, + ); assert.equal(at(1000), pos, 'nothing anywhere yet — passes through'); // The poll's warm batch lands the cells NEXT DOOR (this contact's own cell // is still cold, so only the hold chain can see them). reportMeshFloorCell(30.2014, -97.6604, 138.0); reportMeshFloorCell(30.1994, -97.6604, 160.0); const adopted = _floorCarto(at(1600)).height; // past the probe throttle - assert.ok( - Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`, - ); + assert.ok(Math.abs(adopted - (138.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `stationary contact ${adopted} — a memoized unresolved answer would still read 124.7`); }); test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills in', () => { @@ -1529,16 +1222,9 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'upgrade', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'upgrade', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); assert.ok(Math.abs(at(1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); @@ -1548,10 +1234,8 @@ test('display floor: a stationary contact FOLLOWS the neighbourhood as it fills reportMeshFloorCell(30.2004, -97.6614, 120.0); let h = 0; for (let ms = 1600; ms <= 6000; ms += 80) h = at(ms); - assert.ok( - Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `followed to ${h} — a borrowed floor must not outlive a better one`, - ); + assert.ok(Math.abs(h - (120.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `followed to ${h} — a borrowed floor must not outlive a better one`); }); test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the runway', () => { @@ -1565,35 +1249,23 @@ test('display floor: an on_ground FLAP mid-takeoff-roll never dips below the run // the geoid. Deleting the hold on the airborne poll made that switch visible: // the contact came back grounded with no prior, outrunning its own floor // cells at 23 m/s, and sat under the runway (owner sighting, VIR138M). - const GROUND = -28.5, - GEOID = -32.5, - LON = -73.78; + const GROUND = -28.5, GEOID = -32.5, LON = -73.78; reportMeshFloorCell(40.64, LON, GROUND); // only the cell it STARTED on is warm let lat = 40.64; const grounded = []; for (let i = 0; i <= 22; i += 1) { const onGround = i !== 10; // one airborne poll mid-roll const out = _floorGroundedDisplayPositionForTest( - { onGround }, - Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), - false, - 'VIR138M', - 1000 + i * 80, + { onGround }, Cesium.Cartesian3.fromDegrees(LON, lat, GEOID), false, 'VIR138M', 1000 + i * 80, ); if (onGround) grounded.push(_floorCarto(out).height); lat += 0.00021; // ~23 m per tick at 45 kt } const dipped = grounded.filter((h) => h < GROUND - 0.05); - assert.deepEqual( - dipped, - [], - `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`, - ); - assert.ok( - Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < - 0.05, - 'and it is still standing on the floor it held, not on nothing', - ); + assert.deepEqual(dipped, [], + `${dipped.length} of ${grounded.length} grounded ticks rendered below the runway`); + assert.ok(Math.abs(grounded[grounded.length - 1] - (GROUND + GROUND_FLOOR_LIFT_M)) < 0.05, + 'and it is still standing on the floor it held, not on nothing'); }); test('display floor: the rehydration seed EXPIRES on its own age, with NO calls in between', () => { @@ -1603,45 +1275,20 @@ test('display floor: the rehydration seed EXPIRES on its own age, with NO calls const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'longhaul', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'holding 200 m before departure', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, field, false, 'longhaul', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'holding 200 m before departure'); // ONE airborne tick parks the seed, and then this contact is not seen again // for 198 s: off the poll on a cruise, outside the corridor radius, tab // hidden. Nothing runs in between, so the retire path never gets a second // chance to expire it — the age has to be judged where it comes BACK. // An earlier cut checked expiry only on the retire path and cleared // `retiredMs` before validating it, so this reused the 200 m floor. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'longhaul', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'longhaul', 2000); _clearMeshFloorCellsForTest(); // a different airport: nothing warm here const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - back, - false, - 'longhaul', - 200000, - ), - back, + _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'longhaul', 200000), back, 'an expired seed is gone — no floor is invented from a 198-second-old measurement', ); }); @@ -1653,40 +1300,15 @@ test('display floor: the seed expires the same way when the contact keeps report const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 200.0); reportMeshFloorCell(30.1994, -97.6604, 240.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'cruise', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, field, false, 'cruise', 1000); // The other half of the same clock: an airborne contact that IS still being // ticked. The window is the window either way. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'cruise', - 2000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - field, - false, - 'cruise', - 200000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 2000); + _floorGroundedDisplayPositionForTest({ onGround: false }, field, false, 'cruise', 200000); _clearMeshFloorCellsForTest(); const back = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - back, - false, - 'cruise', - 201000, - ), - back, + _floorGroundedDisplayPositionForTest({ onGround: true }, back, false, 'cruise', 201000), back, 'no memory of the departure field survives the grace window', ); }); @@ -1702,28 +1324,10 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () setMeshFloorPreferred(true); const departed = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - departed, - false, - 'short-hop', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'standing on its own 200 m cell', - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - departed, - false, - 'short-hop', - 2000, - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, departed, false, 'short-hop', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'standing on its own 200 m cell'); + _floorGroundedDisplayPositionForTest({ onGround: false }, departed, false, 'short-hop', 2000); // Re-grounds 0.56 km away — INSIDE HELD_FLOOR_MAX_DRIFT_KM, so the drift // bound does not save us here; the tier order has to. Its own cell is cold, // but two adjacent cells have since resolved, and they are 100 m of ground. @@ -1731,19 +1335,11 @@ test('display floor: a rehydrated seed ranks BELOW fresh neighbour evidence', () reportMeshFloorCell(30.2044, -97.6604, 100.0); reportMeshFloorCell(30.2064, -97.6604, 105.0); const moved = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2054, 50); - const rendered = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - moved, - false, - 'short-hop', - 3000, - ), - ).height; - assert.ok( - Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`, - ); + const rendered = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, moved, false, 'short-hop', 3000, + )).height; + assert.ok(Math.abs(rendered - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rendered at ${rendered} — a seed that outranked the neighbourhood read 201.5, 100 m in the air`); }); test('display floor: with nothing fresh to contradict it, the seed still answers', () => { @@ -1755,34 +1351,14 @@ test('display floor: with nothing fresh to contradict it, the seed still answers // warmed either. Demoting the seed must not mean discarding it. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 50); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'flap', - 1000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - pos, - false, - 'flap', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'flap', 1000); + _floorGroundedDisplayPositionForTest({ onGround: false }, pos, false, 'flap', 2000); _clearMeshFloorCellsForTest(); // the whole neighbourhood goes cold - const held = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), - false, - 'flap', - 3000, - ), - ).height; - assert.ok( - Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `held ${held} — with no measurement anywhere the seed is the best thing available`, - ); + const held = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2014, 50), false, 'flap', 3000, + )).height; + assert.ok(Math.abs(held - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `held ${held} — with no measurement anywhere the seed is the best thing available`); }); test('display floor: re-measuring its own cell makes a seeded contact authoritative again', () => { @@ -1791,56 +1367,24 @@ test('display floor: re-measuring its own cell makes a seeded contact authoritat setMeshFloorPreferred(true); const pad = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2004, -97.6604, 300.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pad, - false, - 'relatch', - 1000, - ); - _floorGroundedDisplayPositionForTest( - { onGround: false }, - pad, - false, - 'relatch', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pad, false, 'relatch', 1000); + _floorGroundedDisplayPositionForTest({ onGround: false }, pad, false, 'relatch', 2000); // Re-grounds on its own, still-warm 300 m cell: that is a live reading, so the // demotion is over and the ordinary tier order applies again. - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pad, - false, - 'relatch', - 3000, - ), - ).height - - (300.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'back on its own measured cell', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pad, false, 'relatch', 3000, + )).height - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'back on its own measured cell'); // It now taxis 220 m onto a cold cell whose two adjacent cells read 100 m. An // own floor it measured while standing here outranks a borrowed one — the // seed flag has to have been cleared by that reading, or the contact drops // 200 m onto someone else's ground. reportMeshFloorCell(30.2014, -97.6604, 100.0); reportMeshFloorCell(30.2034, -97.6604, 100.0); - const taxied = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), - false, - 'relatch', - 4000, - ), - ).height; - assert.ok( - Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`, - ); + const taxied = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2024, 100), false, 'relatch', 4000, + )).height; + assert.ok(Math.abs(taxied - (300.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `taxied at ${taxied} — a still-seeded contact would have taken the 100 m neighbours`); }); test('display floor: taking off and landing elsewhere does NOT inherit the old field floor', () => { @@ -1850,62 +1394,25 @@ test('display floor: taking off and landing elsewhere does NOT inherit the old f const field = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); // 1. grounded on a 200 m floor. - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - field, - false, - 'trip', - 1000, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, field, false, 'trip', 1000, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); // 2. it taxis ~330 m onto ground nothing has resolved, so 200 m is now being // HELD — this is the state that must not survive the trip. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - assert.ok( - Math.abs( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'trip', - 1500, - ), - ).height - - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'the hold is active before takeoff', - ); + assert.ok(Math.abs(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'trip', 1500, + )).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, 'the hold is active before takeoff'); // 3. airborne — the hold belongs to the grounded-billboard regime, not to the // contact, so leaving that regime must retire it. - _floorGroundedDisplayPositionForTest( - { onGround: false }, - heldAt, - false, - 'trip', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: false }, heldAt, false, 'trip', 2000); // 4. lands at another field on a real 100 m floor. reportMeshFloorCell(31.5004, -97.6604, 100.0); - const landed = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), - false, - 'trip', - 3000, - ), - ).height; - assert.ok( - Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`, - ); + const landed = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 31.5004, 60), false, 'trip', 3000, + )).height; + assert.ok(Math.abs(landed - (100.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `landed at ${landed} — a stale hold would arrive at 201.5 and ease down from it`); }); test('display floor: a model-owned interval retires the hold too', () => { @@ -1914,47 +1421,21 @@ test('display floor: a model-owned interval retires the hold too', () => { setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'handoff', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'handoff', 1000); // It moves onto unresolved ground, so 200 m is being HELD. const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'handoff', - 1500, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'handoff', 1500); // T7: the 3D model takes the visual. Our state is retired (nothing visual is // touched — groundSnap's one-shot input is untouched either way). - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - true, - 'handoff', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'handoff', 2000); // The billboard resumes over ground that has since resolved LOWER. A stale // heldActive would read this as a hold release and ease; it is a fresh start. reportMeshFloorCell(30.2034, -97.6604, 150.0); - const back = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'handoff', - 3000, - ), - ).height; - assert.ok( - Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — no ease, because there was no hold to release`, - ); + const back = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'handoff', 3000, + )).height; + assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — no ease, because there was no hold to release`); }); test('display floor: a model handoff back onto a COLD cell takes the neighbourhood, not the seed', () => { @@ -1967,44 +1448,18 @@ test('display floor: a model handoff back onto a COLD cell takes the neighbourho // a floor it measured 330 m ago. Two adjacent cells have resolved at 150 m. const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 124.7); reportMeshFloorCell(30.2004, -97.6604, 200.0); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'cold-handoff', - 1000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, pos, false, 'cold-handoff', 1000); const heldAt = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2034, 124.7); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'cold-handoff', - 1500, - ); - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - true, - 'cold-handoff', - 2000, - ); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, false, 'cold-handoff', 1500); + _floorGroundedDisplayPositionForTest({ onGround: true }, heldAt, true, 'cold-handoff', 2000); _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2024, -97.6604, 150.0); reportMeshFloorCell(30.2044, -97.6604, 155.0); - const back = _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - heldAt, - false, - 'cold-handoff', - 3000, - ), - ).height; - assert.ok( - Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`, - ); + const back = _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, heldAt, false, 'cold-handoff', 3000, + )).height; + assert.ok(Math.abs(back - (150.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `resumed at ${back} — a seed that outranked the neighbourhood would resume at 201.5`); }); // --- F10: no contact waits on another -------------------------------------- @@ -2027,28 +1482,15 @@ test('display floor: every grounded contact is floored on the tick it asks', () } const heights = []; for (let i = 0; i < N; i += 1) { - heights.push( - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), - false, - `q${i}`, - 1000, - ), - ).height, - ); + heights.push(_floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66, latOf(i), 100), false, `q${i}`, 1000, + )).height); } const unfloored = heights - .map((h, i) => - Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i, - ) + .map((h, i) => (Math.abs(h - (300 + i + GROUND_FLOOR_LIFT_M)) < 0.05 ? null : i)) .filter((i) => i != null); - assert.deepEqual( - unfloored, - [], - 'a global budget would leave the tail of the fleet on the geoid for later ticks', - ); + assert.deepEqual(unfloored, [], + 'a global budget would leave the tail of the fleet on the geoid for later ticks'); }); test('display floor: a re-probe that finds a LOWER neighbour eases down, never snaps', () => { @@ -2065,14 +1507,9 @@ test('display floor: a re-probe that finds a LOWER neighbour eases down, never s if (i === 8) reportMeshFloorCell(30.2004, -97.6614, 120.0); // past the 500 ms probe throttle }); const totalDropM = r.opened - target; - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`, - ); - assert.ok( - Math.abs(r.settled - target) < 0.05, - `settled at ${r.settled}, expected ${target}`, - ); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m`); + assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); }); test('display floor: a SECOND lower re-latch mid-approach retargets without a jump', () => { @@ -2092,14 +1529,9 @@ test('display floor: a SECOND lower re-latch mid-approach retargets without a ju if (i === 16) reportMeshFloorCell(30.2004, -97.6594, 100.0); // -> 100 m, mid-approach }); const totalDropM = r.opened - target; - assert.ok( - r.maxStepM <= totalDropM * 0.25, - `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`, - ); - assert.ok( - Math.abs(r.settled - target) < 0.05, - `settled at ${r.settled}, expected ${target}`, - ); + assert.ok(r.maxStepM <= totalDropM * 0.25, + `largest single-tick drop ${r.maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m — the retarget must not be a seam`); + assert.ok(Math.abs(r.settled - target) < 0.05, `settled at ${r.settled}, expected ${target}`); }); test('display floor: a DELAYED tick cannot close more of the gap than a prompt one', () => { @@ -2107,16 +1539,9 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'stall', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'stall', ms, + )).height; reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m const opened = at(1000); @@ -2135,10 +1560,8 @@ test('display floor: a DELAYED tick cannot close more of the gap than a prompt o maxStepM = Math.max(maxStepM, previous - h); previous = h; } - assert.ok( - maxStepM <= totalDropM * 0.25, - `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`, - ); + assert.ok(maxStepM <= totalDropM * 0.25, + `largest step ${maxStepM.toFixed(1)} m of ${totalDropM.toFixed(1)} m across a stalled schedule`); assert.ok(previous < opened, 'and it is still making progress downward'); }); @@ -2149,16 +1572,9 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', const pos = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 100); reportMeshFloorCell(30.2014, -97.6604, 400.0); reportMeshFloorCell(30.1994, -97.6604, 440.0); // borrowed floor = 400 m - const at = (ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - pos, - false, - 'riseMid', - ms, - ), - ).height; + const at = (ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, pos, false, 'riseMid', ms, + )).height; at(1000); reportMeshFloorCell(30.2004, -97.6604, 150.0); // own cell resolves far below let h = 0; @@ -2169,10 +1585,8 @@ test('display floor: a floor rising MID-APPROACH is taken whole, on that tick', _clearMeshFloorCellsForTest(); reportMeshFloorCell(30.2004, -97.6604, 500.0); const risen = at(1000 + 5 * 80); - assert.ok( - Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - `rose to ${risen} — an eased rise is time spent under the mesh`, - ); + assert.ok(Math.abs(risen - (500.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + `rose to ${risen} — an eased rise is time spent under the mesh`); }); test('display floor: a held floor is dropped once the contact leaves the ground it describes', () => { @@ -2180,38 +1594,18 @@ test('display floor: a held floor is dropped once the contact leaves the ground _clearMeshFloorCellsForTest(); setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 200.0); - const at = (lat, ms) => - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), - false, - 'drift', - ms, - ); - assert.ok( - Math.abs( - _floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, + const at = (lat, ms) => _floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'drift', ms, ); + assert.ok(Math.abs(_floorCarto(at(30.2004, 1000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); // ~670 m on: cold cell, but that is inside one poll's rollout. Hold stands. - assert.ok( - Math.abs( - _floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M), - ) < 0.05, - 'inside the drift bound the held floor still describes the ground', - ); + assert.ok(Math.abs(_floorCarto(at(30.2064, 2000)).height - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'inside the drift bound the held floor still describes the ground'); // ~3.3 km on: the held value is no longer a measurement of anywhere this // contact has been, so it is dropped rather than stretched. const far = Cesium.Cartesian3.fromDegrees(-97.6604, 30.2304, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - far, - false, - 'drift', - 3000, - ), - far, + _floorGroundedDisplayPositionForTest({ onGround: true }, far, false, 'drift', 3000), far, 'past the drift bound: back to no clamp, not a stretched one', ); }); @@ -2225,21 +1619,12 @@ test('display floor: an ordinary cell-to-cell floor DROP keeps its existing timi // untouched by this round. reportMeshFloorCell(30.2004, -97.6604, 200.0); reportMeshFloorCell(30.2044, -97.6604, 160.0); - const at = (lat, ms) => - _floorCarto( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), - false, - 'plain', - ms, - ), - ).height; + const at = (lat, ms) => _floorCarto(_floorGroundedDisplayPositionForTest( + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, lat, 124.7), false, 'plain', ms, + )).height; assert.ok(Math.abs(at(30.2004, 1000) - (200.0 + GROUND_FLOOR_LIFT_M)) < 0.05); - assert.ok( - Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, - 'the very next tick is on the new cell — no ease was introduced here', - ); + assert.ok(Math.abs(at(30.2044, 1050) - (160.0 + GROUND_FLOOR_LIFT_M)) < 0.05, + 'the very next tick is on the new cell — no ease was introduced here'); }); test('display floor: the hold never applies to an airborne contact or a modelled one', () => { @@ -2251,23 +1636,11 @@ test('display floor: the hold never applies to an airborne contact or a modelled _floorGroundedDisplayPositionForTest({ onGround: true }, on, false, 'gates'); const cold = Cesium.Cartesian3.fromDegrees(-97.6704, 30.2104, 124.7); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: false }, - cold, - false, - 'gates', - ), - cold, + _floorGroundedDisplayPositionForTest({ onGround: false }, cold, false, 'gates'), cold, 'airborne heights belong to the fix-time clamp, held floor or not', ); assert.equal( - _floorGroundedDisplayPositionForTest( - { onGround: true }, - cold, - true, - 'gates', - ), - cold, + _floorGroundedDisplayPositionForTest({ onGround: true }, cold, true, 'gates'), cold, 'T7: a model-owned contact keeps its billboard datum, held floor or not', ); }); @@ -2278,21 +1651,11 @@ test('display floor: two contacts on the same cell get their own outputs', () => setMeshFloorPreferred(true); reportMeshFloorCell(30.2004, -97.6604, 140); const a = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), - false, - 'A', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.6604, 30.2004, 120), false, 'A', ); const b = _floorGroundedDisplayPositionForTest( - { onGround: true }, - Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), - false, - 'B', - ); - assert.notEqual( - a, - b, - 'a shared scratch would hand both contacts the same object', + { onGround: true }, Cesium.Cartesian3.fromDegrees(-97.66041, 30.20041, 121), false, 'B', ); + assert.notEqual(a, b, 'a shared scratch would hand both contacts the same object'); assert.ok(Math.abs(_floorCarto(a).height - _floorCarto(b).height) < 0.05); }); diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 597211bb7..b6441aa52 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -24,12 +24,8 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () => (await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status, 'source-unavailable', ); - _setMilitaryTrackingRefreshOutcomeForTest({ - status: 'accepted', - ids: ['different'], - }); - const missing = - await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + _setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] }); + const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(missing.status, 'missing'); assert.equal(missing.reason, 'target-absent-from-snapshot'); }); @@ -71,11 +67,34 @@ test('military analyst record: full record maps every contract field', () => { aircraftClass: 'widebody', originCountry: null, operator: 'United States Air Force', + squawk: null, + emergency: false, + emergencyKind: null, + emergencySource: null, + emergencyBroadcast: null, + observedAtMs: null, routeOrigin: 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', () => { const r = mapAnalystRecord('ae01ce', undefined); 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', () => { - assert.equal( - mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, - '05-8152', - ); - assert.equal( - mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, - 'ae01ce', - ); + assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152'); + assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce'); }); 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); for (const [key, value] of Object.entries(r)) { assert.notEqual(value, undefined, `${key} must not be undefined`); - if (typeof value === 'number') - assert.ok(Number.isFinite(value), `${key} must not be NaN`); + if (typeof value === 'number') 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 () => { const realFetch = globalThis.fetch; let observedSignal = null; - globalThis.fetch = (_url, options = {}) => - new Promise((_resolve, reject) => { - observedSignal = options.signal; - options.signal?.addEventListener( - 'abort', - () => { - reject(new DOMException('aborted', 'AbortError')); - }, - { once: true }, - ); - }); + globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => { + observedSignal = options.signal; + options.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); try { const controller = new AbortController(); 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' }] }), }); try { - await militaryFlightsLayer.update({ - camera: { positionCartographic: null }, - scene: {}, - }); - const resolution = - await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); + await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} }); + const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234'); assert.equal(resolution.status, 'source-unavailable'); - assert.match( - militaryFlightsLayer.getStats().error, - /Malformed adsb\.lol aircraft rows/, - ); - assert.equal( - militaryFlightsLayer.getAnalystRecords().length, - 1, - 'warm aircraft data is preserved', - ); + assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/); + assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved'); } finally { globalThis.fetch = realFetch; } @@ -215,26 +211,21 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p ok: true, status: 200, json: async () => ({ - ac: - poll++ === 0 - ? [ - { - hex: icao24, - lon: -96.9, - lat: 31.1, - alt_baro: 28_000, - alt_geom: 28_100, - track: 95, - gs: 400, - seen: 0, - seen_pos: 0, - flight: 'RCH451 ', - t: 'C17', - r: '05-8152', - ownOp: 'United States Air Force', - }, - ] - : [], + ac: poll++ === 0 ? [{ + hex: icao24, + lon: -96.9, + lat: 31.1, + alt_baro: 28_000, + alt_geom: 28_100, + track: 95, + gs: 400, + seen: 0, + seen_pos: 0, + flight: 'RCH451 ', + 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({ icao24: 'ae01ce', entity: { gevLabelModel: { title: 'RCH451', details: [] } }, - billboard: { - position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), - show: false, - }, + billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false }, billboardCollection: { show: false, remove() {} }, viewer, history, @@ -300,18 +288,11 @@ test('cached military snapshots retain their observation time and expose stale f await militaryFlightsLayer.update(viewer); assert.equal(history.length, 1); assert.equal(history[0].epochMs, receivedAt - 2000); - for (const [status, age] of [ - ['HIT', 5000], - ['STALE', 45000], - ]) { + for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) { cache = status; now = receivedAt + age; await militaryFlightsLayer.update(viewer); - assert.equal( - history.length, - 1, - 'replayed positions cannot create a new fix', - ); + assert.equal(history.length, 1, 'replayed positions cannot create a new fix'); assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt); assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE'); assert.equal(militaryFlightsLayer.getStats().error, null); @@ -338,18 +319,14 @@ test('real military track path creates no native label and publishes every cache trackedEntityChanged, clock: { currentTime: now }, camera: { - cancelFlight() { - cancelledFlights += 1; - }, + cancelFlight() { cancelledFlights += 1; }, position: new Cesium.Cartesian3(0, -10_000, 7_000), positionWC: new Cesium.Cartesian3(0, -10_000, 7_000), direction: Cesium.Cartesian3.UNIT_Y, transform: Cesium.Matrix4.IDENTITY, viewMatrix: Cesium.Matrix4.IDENTITY, frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY }, - lookAtTransform() { - appliedFrames += 1; - }, + lookAtTransform() { appliedFrames += 1; }, }, scene: { 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; globalThis.window = new EventTarget(); const selectionEvents = []; - globalThis.window.addEventListener( - 'gev:awareness-subject-selected', - (event) => { - selectionEvents.push(event.detail); - }, - ); + globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => { + selectionEvents.push(event.detail); + }); globalThis.fetch = async () => ({ ok: false }); _setTrackedOverlayHostForTest(host); try { @@ -405,15 +379,13 @@ test('real military track path creates no native label and publishes every cache billboardCollection: { show: true, remove() {} }, viewer, tracked: false, - history: [ - { - time: now, - epochMs: Date.now(), - position, - velocity: 231.5, - track: 92.1, - }, - ], + history: [{ + time: now, + epochMs: Date.now(), + position, + velocity: 231.5, + track: 92.1, + }], meta: { ...FULL_INFO, type: 'C17', @@ -423,72 +395,34 @@ test('real military track path creates no native label and publishes every cache }, }); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); const entity = viewer.trackedEntity; - assert.ok( - entity instanceof Cesium.Entity, - 'trackById must create the real Cesium entity', - ); + assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity'); assert.equal(entity.label, undefined); - assert.ok( - entities.values.every((candidate) => candidate.label === undefined), - ); + assert.ok(entities.values.every((candidate) => candidate.label === undefined)); assert.deepEqual(entity.gevLabelModel, { 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', }); viewer.scene.preUpdate.raiseEvent(); const initialAppliedFrames = appliedFrames; const initialCancelledFlights = cancelledFlights; - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'user' }), - true, - ); - 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(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); + 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( - militaryFlightsLayer.refocusTrackedById('different-flight'), - false, - ); - 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('different-flight'), false); + 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); viewer.scene.preUpdate.raiseEvent(); - assert.equal( - viewer.trackedEntity, - entity, - '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', - ); + assert.equal(viewer.trackedEntity, entity, '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); assert.equal(publication.sourceId, 'tracked'); 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); assert.ok(display, 'tracked position callback must seed its frame cache'); - assert.equal( - entry.position(), - display, - 'host must reuse the exact cached display Cartesian', - ); + assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian'); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late001' }, { origin: 'share-restore' }, ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001'); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'user' }), - true, - ); - assert.equal( - _pendingMilitaryTrackingRestoreForTest(), - null, - 'new user selection cancels stale restore', - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true); + assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore'); _addMilitaryTrackingCandidateForTest({ icao24: 'late001', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) }, meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - icao24, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24); militaryFlightsLayer.stopTracking(); militaryFlightsLayer.setParams( @@ -541,25 +458,15 @@ test('real military track path creates no native label and publishes every cache ); assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002'); militaryFlightsLayer.stopTracking({ origin: 'user' }); - assert.equal( - _pendingMilitaryTrackingRestoreForTest(), - null, - 'explicit clear cancels stale restore', - ); + assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore'); _addMilitaryTrackingCandidateForTest({ icao24: 'late002', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - null, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late003' }, @@ -574,18 +481,12 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late003', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) }, meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 }, history: [], }); _applyPendingMilitaryTrackingRestoreForTest(); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - null, - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late004' }, @@ -594,27 +495,18 @@ test('real military track path creates no native label and publishes every cache assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004'); _addMilitaryTrackingCandidateForTest({ icao24: 'late004', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) }, meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - 'late004', - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004'); militaryFlightsLayer.setParams( { selectedMilitaryTrackingId: 'late005' }, { origin: 'local-restore' }, ); - assert.equal( - militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), - true, - ); + assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true); assert.equal( _pendingMilitaryTrackingRestoreForTest(), 'late005', @@ -622,18 +514,12 @@ test('real military track path creates no native label and publishes every cache ); _addMilitaryTrackingCandidateForTest({ icao24: 'late005', - billboard: { - ...billboard, - position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000), - }, + billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) }, meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 }, history: [], }); assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true); - assert.equal( - militaryFlightsLayer.getParams().selectedMilitaryTrackingId, - 'late005', - ); + assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005'); } finally { militaryFlightsLayer.stopTracking(); 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. const analystId = mapAnalystRecord(icao24, { - callsign: null, - registration: '6606', - rawLat: 40.71, - rawLon: -74.01, + callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, }).id; - assert.equal( - analystId, - '6606', - 'the analyst names a callsign-less contact by its tail', - ); + assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail'); const found = militaryFlightsLayer.findByQuery(analystId); - assert.equal( - found?.icao24, - icao24, - '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', - ); + assert.equal(found?.icao24, icao24, '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', () => { const record = mapAnalystRecord('ae7f01', { - callsign: null, - registration: '6606', - rawLat: 40.71, - rawLon: -74.01, + callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01, }); - assert.equal( - record.icao24, - 'ae7f01', - 'a resolvable key must ride along with the display label', - ); + assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label'); }); 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, }); const base = { - type: 'C17', - operator: 'USAF', - altitudeFt: 25000, - speedMps: 180, - track: 90, - klass: 'widebody', - onGround: false, + type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180, + track: 90, klass: 'widebody', onGround: false, }; // Registered FIRST, so insertion order favours it. _addMilitaryTrackingCandidateForTest({ icao24: 'ae0aa1', - meta: { - ...base, - callsign: null, - registration: 'ZULU777', - rawLat: 31.0, - rawLon: -97.0, - }, + meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 }, billboard: billboardAt(-97.0, 31.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'ae0bb2', - meta: { - ...base, - callsign: 'ZULU777', - registration: '05-9999', - rawLat: 31.2, - rawLon: -97.2, - }, + meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 }, billboard: billboardAt(-97.2, 31.2), }); @@ -782,49 +625,24 @@ test('two contacts matching at the same strength resolve deterministically', () show: true, }); const base = { - type: 'C130', - operator: 'USAF', - altitudeFt: 21000, - speedMps: 150, - track: 45, - klass: 'turboprop', - onGround: false, + type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150, + track: 45, klass: 'turboprop', onGround: false, }; // Both registrations begin with QQ7 — a same-tier prefix tie. Registered // highest-hex first so insertion order would pick the wrong one. _addMilitaryTrackingCandidateForTest({ icao24: 'aef002', - meta: { - ...base, - callsign: null, - registration: 'QQ7-222', - rawLat: 32.0, - rawLon: -98.0, - }, + meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 }, billboard: billboardAt(-98.0, 32.0), }); _addMilitaryTrackingCandidateForTest({ icao24: 'aef001', - meta: { - ...base, - callsign: null, - registration: 'QQ7-111', - rawLat: 32.1, - rawLon: -98.1, - }, + meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 }, billboard: billboardAt(-98.1, 32.1), }); const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24; const second = militaryFlightsLayer.findByQuery('qq7')?.icao24; - assert.equal( - first, - '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', - ); + assert.equal(first, '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'); }); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..80d77dddd 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -7,6 +7,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; 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'); +/** 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 // taxiing and landing") ----------------------------------------------------------- // Present-but-grounded planes are RENDERED instead of being skipped: same class diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..0714acfc5 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -15,6 +15,7 @@ import { FOCUS_EVIDENCE_DEV, FLEET_DR_INTERVAL_MS, } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -167,6 +168,9 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || 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, // CLASS label follows the TR-3B conversion so every downstream card // (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, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * 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( @@ -253,6 +260,20 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: text(info?.originCountry), 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, routeDestination: routeOk ? text(info?.route?.destination?.code) : null, }; diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 43809f516..4b665d773 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -4,6 +4,7 @@ import { pickRenderAltitudeM, } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -226,6 +227,10 @@ export class FlightRecords { // like callsign so a transient blank row doesn't blank the field. originCountry: 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 // transponder message. The fleet coast horizon uses this actual // contact time so a temporarily old position does not hard-freeze diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index d001c7811..fff25b8b9 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -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.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); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..ae4b7c7ca 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { @@ -13,6 +14,7 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { MIL_TINT, + EMERGENCY_TINT, GROUND_SCALE, COCKPIT_CONTACT_SIZE_PX, COCKPIT_CIVILIAN_COLOR, @@ -54,13 +56,34 @@ export function createRendering({ const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } = 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). */ - 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; } + /** 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. */ function _fleetBillboardScale(icao24, klass) { @@ -129,9 +152,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = ( - isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR - ).withAlpha(freshnessAlpha); + bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -162,7 +183,7 @@ export function createRendering({ function _modelColor(icao24) { 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 @@ -1009,10 +1030,8 @@ export function createRendering({ flightState._cockpitNearContacts.has(icao24); const layerBaseColor = flightState._cockpitContactMode && !isCockpitNear - ? isMilitaryIcao(icao24) - ? MIL_TINT - : COCKPIT_CIVILIAN_COLOR - : _fleetBillboardColor(icao24); + ? _cockpitContactColor(icao24, info) + : _fleetBillboardColor(icao24, info); const baseColor = layerBaseColor; const treatment = applyAircraftBillboardTreatment({ billboard: bb, diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index 36d5ebde4..f375e5419 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -6,6 +6,7 @@ export { ERROR_BACKOFF_INTERVAL, } from './recordPolicy.js'; import * as Cesium from 'cesium'; +import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; /** * @module militaryFlights @@ -40,6 +41,11 @@ export const MIL_ICON_COLOR = Cesium.Color.fromCssColorString('#FFB800'); 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) --------------- // 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 diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..0d418ddc1 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -9,6 +9,7 @@ import { CONTACT_MATCH_TIER, } from '../../data/contactMatch.js'; import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js'; +import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js'; export function createQueries({ flightState, @@ -133,6 +134,9 @@ export function createQueries({ flightState.records.missingPolls.get(icao24) || 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, * heading: number|null, verticalRateMps: number|null, onGround: boolean, * 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) { @@ -187,6 +193,20 @@ export function createQueries({ aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)), originCountry: null, 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, routeDestination: null, }; diff --git a/src/layers/military/records.js b/src/layers/military/records.js index cbc62be79..9cd6754e6 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -1,6 +1,7 @@ import { pickRenderAltitudeM } from '../../data/renderAltitude.js'; import { stickyText, stickyNumber } from '../../data/aircraftMeta.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; +import { aircraftEmergency } from '../../data/aircraftEmergency.js'; import { GROUND_FLOOR_WARM_MAX_ALT_M, LANDED_MISSING_POLL_LIMIT, @@ -161,6 +162,9 @@ export class MilitaryFlightRecords { null, ), turnRateDps: prevMeta?.turnRateDps || 0, + // Per poll, NOT sticky (mirror of flights): a cleared code clears here. + squawk: aircraft.squawk ?? null, + emergency: aircraftEmergency(aircraft), onGround, // Round 7: sticky airborne history (see _likelyLanded). wasAirborne: prevMeta?.wasAirborne === true || !onGround, diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs index df42760ce..5054fb28a 100644 --- a/src/layers/military/records.test.mjs +++ b/src/layers/military/records.test.mjs @@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only '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); +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 9ebd03180..089ae9ec8 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -18,6 +18,7 @@ import { screenProjectedRotation, } from '../../data/iconOrientation.js'; import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; +import { isEmergency } from '../../data/aircraftEmergency.js'; import { PLANE_MODEL_SCALE, PLANE_MODEL_URL, @@ -33,6 +34,7 @@ import { COCKPIT_CONTACT_SIZE_PX, MIL_ICON_COLOR, TRACKED_ICON_COLOR, + EMERGENCY_ICON_COLOR, MODEL_ALT_CEIL_M, MODEL_MAX_ALL, MODEL_MAX, @@ -159,7 +161,7 @@ export function createRendering({ bb.height = COCKPIT_CONTACT_SIZE_PX; bb.scale = limbScale; bb.scaleByDistance = _cockpitBillboardScaleByDistance(); - bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha); + bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha); bb.rotation = 0; return; } @@ -173,7 +175,9 @@ export function createRendering({ bb.height = icao24 === flightState._trackedIcao ? 24 : 20; bb.scale = _militaryBillboardScale(icao24) * limbScale; 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 @@ -189,7 +193,17 @@ export function createRendering({ function _modelColor(icao24) { 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 @@ -965,7 +979,7 @@ export function createRendering({ const isCockpitNear = flightState._cockpitContactMode && flightState._cockpitNearContacts.has(icao24); - const baseColor = MIL_ICON_COLOR; + const baseColor = _fleetBillboardColor(icao24, info); const treatment = applyAircraftBillboardTreatment({ billboard: bb, baseScale: @@ -1082,6 +1096,7 @@ export function createRendering({ _applyFleetBillboardPresentation, _iconKind, _modelColor, + _fleetBillboardColor, _modelRegimeActive, _modelCap, _modelAddDistM, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 58eb57972..b9e54c173 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -7,12 +7,7 @@ import { } from '../../data/motionModel.js'; import { aircraftIcon } from '../../data/aircraftIcons.js'; import { POSITION_HISTORY_LIMIT } from './recordPolicy.js'; -import { - BILLBOARD_SCALE, - GROUND_SCALE, - TRACKED_ICON_COLOR, - MIL_ICON_COLOR, -} from './policy.js'; +import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js'; /** Apply military record changes to Cesium resources and existing follow operations. */ export function createMilitarySnapshotRenderer({ @@ -161,7 +156,9 @@ export function createMilitarySnapshotRenderer({ // Screen-projected rotation lands on the next fleet tick. rotation: 0, alignedAxis: Cesium.Cartesian3.ZERO, - color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR, + color: isTracked + ? TRACKED_ICON_COLOR + : rendering._fleetBillboardColor(icao24, meta), sizeInMeters: false, scaleByDistance: rendering._normalBillboardScaleByDistance(), // Grounded/near-surface planes sit at/below the photoreal tile diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b0cfb0368..5723ac39e 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -21,7 +21,6 @@ import { TRAIL_COLOR, BILLBOARD_SCALE, GROUND_SCALE, - MIL_ICON_COLOR, AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; @@ -780,12 +779,16 @@ export function createTracking({ bb.width = 20; bb.height = 20; // 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 = BILLBOARD_SCALE * (CLASS_SCALE_2D[meta?.klass] || 1) * (meta?.onGround ? GROUND_SCALE : 1); - bb.color = MIL_ICON_COLOR; + bb.color = parts.rendering._fleetBillboardColor( + flightState._trackedIcao, + meta, + ); bb.rotation = flightState._lastTrackedRotation; } flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 9534a5c3c..977801aa7 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -30,6 +30,9 @@ export function normalizeOpenSkyAircraft(row) { typeCode: null, registration: 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), registration: cleanText(row.r), operator: cleanText(row.ownOp), + squawk: cleanText(row.squawk) || null, + emergency: cleanText(row.emergency).toLowerCase() || null, }; } diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 391fe1e72..36fa9b95b 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation' 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); +}); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index e4814517d..437aa9b26 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -12,6 +12,7 @@ import { compassDivisions, formatAltitudeRulerTick, formatCompassDivision, + formatCockpitEmergency, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -133,7 +134,15 @@ export function updateHud( : info.stale ? 'STALE FEED' : '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); if ( diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index 8436c3a41..bed0c1772 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -43,6 +43,8 @@ #cockpit-hud { --cockpit-accent: #00dcff; + /* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */ + --cockpit-emergency: #ff4444; --cockpit-side-panel-width: min(340px, 28vw); /* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */ --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; letter-spacing: 0.16em; } +.cockpit-aircraft-meta.emergency { + color: var(--cockpit-emergency); + font-weight: 600; +} .cockpit-mode-chip { margin-top: 2px; padding: 7px 11px; diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index 5a25bfec8..c8f5b139f 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -2270,6 +2270,12 @@ 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 { margin-top: 0; border-color: rgba(105, 203, 209, 0.38);