mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat(layers): highlight aircraft broadcasting a transponder emergency
Flights and Military Flights tint aircraft red while the latest poll carries squawk 7500/7600/7700 or an adsb.lol ADS-B emergency status (general/unlawful/nordo/downed). Minimum-fuel and lifeguard priorities are reported but not tinted. The Cockpit HUD aircraft-meta line names the broadcast code in red. The status is not sticky: a cleared code stops highlighting on the next refresh. The analyst/voice query engine gains squawk, emergency and emergencyKind fields. No new source or request: OpenSky state vectors already carry the squawk and adsb.lol/readsb carries both fields. Built by the Hex project (Crew Leo Agile dev team). Co-Authored-By: Claude <[email protected]>
This commit is contained in:
1 parent
9beadbda63
commit
eb7f059750
26 files changed
+1174
-107
No files matched your search
@@ -95,6 +95,17 @@
|
||||
|
||||
## [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` and `emergencyKind`. 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
|
||||
|
||||
@@ -78,6 +78,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -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",
|
||||
@@ -2276,6 +2279,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",
|
||||
@@ -2897,7 +2901,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": []
|
||||
},
|
||||
@@ -2916,7 +2921,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": []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/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).
|
||||
*
|
||||
* 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');
|
||||
record(
|
||||
'no uncaught browser errors',
|
||||
errors.length === 0,
|
||||
errors.slice(0, 3).join(' | '),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok).length;
|
||||
const fail = results.length - pass;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
@@ -83,6 +83,20 @@ 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 the broadcast code, never a conclusion about the flight.
|
||||
* @param {{label?: string, squawk?: string|null}|null|undefined} emergency
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatCockpitEmergency(emergency) {
|
||||
const label = String(emergency?.label || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!label) return null;
|
||||
return emergency.squawk ? `SQUAWK ${emergency.squawk} · ${label}` : label;
|
||||
}
|
||||
|
||||
/** Format the cockpit Context scope without overstating installation coverage. */
|
||||
export function formatCockpitContextScope(
|
||||
subjectLabel,
|
||||
|
||||
+118
-41
@@ -10,6 +10,7 @@ import {
|
||||
cockpitGroundSafeHeight,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
formatCockpitEmergency,
|
||||
compassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCockpitContextScope,
|
||||
@@ -41,7 +42,11 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', ()
|
||||
const speedMps = 200;
|
||||
const dtSec = 0.1;
|
||||
const forwardStepM = speedMps * dtSec;
|
||||
const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec);
|
||||
const backwardCorrectionM = cockpitAnchorCorrectionStep(
|
||||
1000,
|
||||
speedMps,
|
||||
dtSec,
|
||||
);
|
||||
assert.ok(backwardCorrectionM > 0);
|
||||
assert.ok(backwardCorrectionM < forwardStepM);
|
||||
});
|
||||
@@ -77,10 +82,7 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in
|
||||
});
|
||||
|
||||
test('cockpit Context scope preserves zero and replaces missing values intentionally', () => {
|
||||
assert.equal(
|
||||
formatCockpitContextScope('', 0),
|
||||
'— · 0 KM AIR/SEA WINDOW',
|
||||
);
|
||||
assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW');
|
||||
assert.equal(
|
||||
formatCockpitContextScope(undefined, Number.NaN, {}),
|
||||
'— · — KM AIR/SEA WINDOW',
|
||||
@@ -168,26 +170,29 @@ test('relative bearing stays on the shortest signed arc', () => {
|
||||
});
|
||||
|
||||
test('HUD rail layout centers between intersecting upper and lower obstacles', () => {
|
||||
assert.deepEqual(resolveHudRailLayout({
|
||||
viewportHeight: 1000,
|
||||
panelHeight: 200,
|
||||
laneLeft: 50,
|
||||
laneRight: 410,
|
||||
baseTop: 280,
|
||||
baseBottom: 940,
|
||||
gap: 10,
|
||||
obstacles: [
|
||||
{ left: 40, right: 240, top: 320, bottom: 460 },
|
||||
{ left: 300, right: 1000, top: 780, bottom: 930 },
|
||||
{ left: 900, right: 1100, top: 300, bottom: 700 },
|
||||
],
|
||||
}), {
|
||||
top: 520,
|
||||
maxHeight: 300,
|
||||
safeTop: 470,
|
||||
safeBottom: 770,
|
||||
constrained: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveHudRailLayout({
|
||||
viewportHeight: 1000,
|
||||
panelHeight: 200,
|
||||
laneLeft: 50,
|
||||
laneRight: 410,
|
||||
baseTop: 280,
|
||||
baseBottom: 940,
|
||||
gap: 10,
|
||||
obstacles: [
|
||||
{ left: 40, right: 240, top: 320, bottom: 460 },
|
||||
{ left: 300, right: 1000, top: 780, bottom: 930 },
|
||||
{ left: 900, right: 1100, top: 300, bottom: 700 },
|
||||
],
|
||||
}),
|
||||
{
|
||||
top: 520,
|
||||
maxHeight: 300,
|
||||
safeTop: 470,
|
||||
safeBottom: 770,
|
||||
constrained: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD rail layout constrains an oversized panel to the safe corridor', () => {
|
||||
@@ -241,7 +246,10 @@ function contextSnapshot(subject, extra = {}) {
|
||||
}
|
||||
|
||||
test('Contact panel hides only when there is no snapshot at all', () => {
|
||||
const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO });
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: null,
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, false);
|
||||
assert.equal(readout.mode, 'standby');
|
||||
assert.equal(
|
||||
@@ -257,14 +265,22 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
|
||||
// camera: "click next... whole left panel disappears").
|
||||
for (const subject of [
|
||||
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
|
||||
{ layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' },
|
||||
{
|
||||
layerId: 'military-installations',
|
||||
id: 'fort-hood',
|
||||
label: 'FORT CAVAZOS',
|
||||
},
|
||||
{ layerId: 'military', id: 'ae01ce', label: 'RCH451' },
|
||||
]) {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot(subject),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`);
|
||||
assert.equal(
|
||||
readout.visible,
|
||||
true,
|
||||
`${subject.layerId} subject must keep the panel up`,
|
||||
);
|
||||
assert.equal(readout.mode, 'foreign');
|
||||
assert.equal(readout.subjectMatchesTracked, false);
|
||||
}
|
||||
@@ -272,7 +288,11 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
|
||||
|
||||
test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }),
|
||||
snapshot: contextSnapshot({
|
||||
layerId: 'ais-live-vessels',
|
||||
id: '353136000',
|
||||
label: 'MAERSK DETROIT',
|
||||
}),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true);
|
||||
@@ -281,12 +301,20 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l
|
||||
// frame while every other row value is measured from the subject; rendering
|
||||
// both live would present one mixed-frame reading as a single measurement.
|
||||
assert.equal(readout.aircraftRelative, false);
|
||||
assert.equal(readout.contactLost, false, 'subject-frame values still refresh');
|
||||
assert.equal(
|
||||
readout.contactLost,
|
||||
false,
|
||||
'subject-frame values still refresh',
|
||||
);
|
||||
});
|
||||
|
||||
test('the tracked aircraft as subject keeps every field in its own frame', () => {
|
||||
const readout = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }),
|
||||
snapshot: contextSnapshot({
|
||||
layerId: 'flights',
|
||||
id: 'aaa077',
|
||||
label: 'SWA1234',
|
||||
}),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true);
|
||||
@@ -305,7 +333,11 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
|
||||
snapshot: contextSnapshot(subject, { subjectPresent: false }),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(readout.visible, true, 'a lost contact must not take the panel down');
|
||||
assert.equal(
|
||||
readout.visible,
|
||||
true,
|
||||
'a lost contact must not take the panel down',
|
||||
);
|
||||
assert.equal(readout.mode, 'lost');
|
||||
assert.equal(readout.contactLost, true, 'last-known values stay on screen');
|
||||
}
|
||||
@@ -313,7 +345,10 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
|
||||
|
||||
test('an explicitly present subject is never reported lost', () => {
|
||||
const present = resolveCockpitContextReadout({
|
||||
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }),
|
||||
snapshot: contextSnapshot(
|
||||
{ layerId: 'flights', id: 'aaa077' },
|
||||
{ subjectPresent: true },
|
||||
),
|
||||
info: TRACKED_INFO,
|
||||
});
|
||||
assert.equal(present.contactLost, false);
|
||||
@@ -332,20 +367,36 @@ test('the tracked flight layer is resolved by normalized tracked identity', () =
|
||||
// Both layers describe a tracked aircraft during a cross-layer handoff;
|
||||
// civilian-first precedence would hand the cockpit the wrong aircraft.
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId,
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'military:ae01ce',
|
||||
}).layerId,
|
||||
'military',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24,
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'military:ae01ce',
|
||||
}).icao24,
|
||||
'ae01ce',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId,
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'flights:aaa077',
|
||||
}).layerId,
|
||||
'flights',
|
||||
);
|
||||
// Case-insensitive: layers stamp lowercase hex, callers may not.
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId,
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'MILITARY:AE01CE',
|
||||
}).layerId,
|
||||
'military',
|
||||
);
|
||||
});
|
||||
@@ -355,12 +406,38 @@ test('tracked-identity resolution falls back to layer precedence', () => {
|
||||
const military = { icao24: 'ae01ce' };
|
||||
// No stamped identity (or one that matches neither layer) keeps the historic
|
||||
// civilian-first order so tracking paths without gevTrackedId still work.
|
||||
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
|
||||
resolveTrackedAircraftInfo({ civilian, military }).layerId,
|
||||
'flights',
|
||||
);
|
||||
assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military');
|
||||
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({
|
||||
civilian,
|
||||
military,
|
||||
trackedId: 'satellites:25544',
|
||||
}).layerId,
|
||||
'flights',
|
||||
);
|
||||
assert.equal(
|
||||
resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId,
|
||||
'military',
|
||||
);
|
||||
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' }),
|
||||
'SQUAWK 7700 · GENERAL EMERGENCY',
|
||||
);
|
||||
assert.equal(
|
||||
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null }),
|
||||
'MINIMUM FUEL',
|
||||
);
|
||||
assert.equal(formatCockpitEmergency(null), null);
|
||||
assert.equal(formatCockpitEmergency({ label: '' }), null);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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}
|
||||
*/
|
||||
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: fromAdsb ? 'ads-b' : 'squawk',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the aircraft is broadcasting an emergency (not a priority status).
|
||||
* @param {{severity?: string}|null|undefined} status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmergency(status) {
|
||||
return status?.severity === 'emergency';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// src/data/aircraftEmergency.test.mjs
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
aircraftEmergency,
|
||||
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({ 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);
|
||||
});
|
||||
+285
-49
@@ -1,18 +1,89 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAnalystEngine, applyScope, haversineKm } from './analystEngine.js';
|
||||
import {
|
||||
createAnalystEngine,
|
||||
applyScope,
|
||||
haversineKm,
|
||||
} from './analystEngine.js';
|
||||
|
||||
// Stub world: a square "Texland" region, flights + ships + fires around it.
|
||||
const TEXLAND = { name: 'Texland', ring: [[-100, 28], [-94, 28], [-94, 33], [-100, 33]] };
|
||||
const TEXLAND = {
|
||||
name: 'Texland',
|
||||
ring: [
|
||||
[-100, 28],
|
||||
[-94, 28],
|
||||
[-94, 33],
|
||||
[-100, 33],
|
||||
],
|
||||
};
|
||||
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: 'GND1', lat: 30.19, lon: -97.66, altitudeM: 150, speedMps: 5, military: false, onGround: true, routeOrigin: null, routeDestination: null },
|
||||
{
|
||||
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,
|
||||
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 = [
|
||||
{ id: 'EVERGIVEN', lat: 29.5, lon: -94.9, speedKts: 12, shipType: 'Cargo', destination: 'OAKLAND', navStatus: 'under way' },
|
||||
{ id: 'SLOWBOAT', lat: 29.6, lon: -95.0, speedKts: 0.2, shipType: 'Tanker', destination: 'HOUSTON', navStatus: 'anchored' },
|
||||
{
|
||||
id: 'EVERGIVEN',
|
||||
lat: 29.5,
|
||||
lon: -94.9,
|
||||
speedKts: 12,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: 'under way',
|
||||
},
|
||||
{
|
||||
id: 'SLOWBOAT',
|
||||
lat: 29.6,
|
||||
lon: -95.0,
|
||||
speedKts: 0.2,
|
||||
shipType: 'Tanker',
|
||||
destination: 'HOUSTON',
|
||||
navStatus: 'anchored',
|
||||
},
|
||||
];
|
||||
const FIRES = [
|
||||
{ id: 'FIRE-1', lat: 30.5, lon: -98.2, frp: 1500 },
|
||||
@@ -22,7 +93,10 @@ const FIRES = [
|
||||
|
||||
function makeEngine() {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
||||
getRecords: (key) =>
|
||||
({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[
|
||||
key
|
||||
] || [],
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
@@ -31,7 +105,10 @@ function makeEngine() {
|
||||
/** Same world, but Contacts is up with a subject far from the parked camera. */
|
||||
function makeContactsEngine(subject) {
|
||||
return createAnalystEngine({
|
||||
getRecords: (key) => ({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES }[key] || []),
|
||||
getRecords: (key) =>
|
||||
({ flights: FLIGHTS, 'ais-live-vessels': SHIPS, 'local-firms': FIRES })[
|
||||
key
|
||||
] || [],
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
// Parked far away, as a high-altitude camera often is.
|
||||
getViewContext: () => ({ lat: 45.0, lon: -122.0, viewRadiusKm: 150 }),
|
||||
@@ -52,7 +129,11 @@ test('analyst: a radius query centers on the active contact, not the parked came
|
||||
assert.equal(r.ok, true);
|
||||
// Austin-area flights, not the Oregon one the camera is parked over.
|
||||
assert.equal(r.count, 3);
|
||||
assert.equal(r.centeredOn, 'SWA1', 'the answer names the centre it measured from');
|
||||
assert.equal(
|
||||
r.centeredOn,
|
||||
'SWA1',
|
||||
'the answer names the centre it measured from',
|
||||
);
|
||||
assert.ok(r.coverage.scope.includes('@SWA1'));
|
||||
});
|
||||
|
||||
@@ -63,7 +144,11 @@ test('analyst: an explicit center still wins over the active contact', async ()
|
||||
scope: { kind: 'radius', km: 250, center: { lat: 45.0, lon: -122.0 } },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'only the Oregon flight is within 250 km of the given center');
|
||||
assert.equal(
|
||||
r.count,
|
||||
1,
|
||||
'only the Oregon flight is within 250 km of the given center',
|
||||
);
|
||||
assert.equal(r.centeredOn, undefined, 'an explicit center is not relabelled');
|
||||
});
|
||||
|
||||
@@ -73,7 +158,11 @@ test('analyst: with Contacts off, radius still centers on the view', async () =>
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'view-centred behaviour is unchanged outside Contacts');
|
||||
assert.equal(
|
||||
r.count,
|
||||
1,
|
||||
'view-centred behaviour is unchanged outside Contacts',
|
||||
);
|
||||
assert.equal(r.centeredOn, undefined);
|
||||
assert.equal(r.coverage.scope, 'radius:250km');
|
||||
});
|
||||
@@ -83,13 +172,25 @@ test('analyst: a subject without usable coordinates cannot lend its name to a ca
|
||||
// number. A subject present but position-less fell back to the camera and
|
||||
// kept the contact's name on the answer, so a camera-centred count read as
|
||||
// contact-centred with nothing in the payload to catch it.
|
||||
const r = await makeContactsEngine({ lat: null, lon: null, label: 'SWA1' }).query({
|
||||
const r = await makeContactsEngine({
|
||||
lat: null,
|
||||
lon: null,
|
||||
label: 'SWA1',
|
||||
}).query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.count, 1, 'the count is the camera-centred one it actually measured');
|
||||
assert.equal(r.centeredOn, undefined, 'and it must not claim a centre it did not use');
|
||||
assert.equal(
|
||||
r.count,
|
||||
1,
|
||||
'the count is the camera-centred one it actually measured',
|
||||
);
|
||||
assert.equal(
|
||||
r.centeredOn,
|
||||
undefined,
|
||||
'and it must not claim a centre it did not use',
|
||||
);
|
||||
assert.equal(r.coverage.scope, 'radius:250km');
|
||||
assert.equal(r.scopeLabel, 'within 250 km');
|
||||
});
|
||||
@@ -99,33 +200,47 @@ test('analyst: every scope names itself in words', async () => {
|
||||
// answers look like a contradiction, so each scope carries its own phrasing.
|
||||
const subject = { lat: 30.2, lon: -97.7, label: 'DYNO11' };
|
||||
const centred = await makeContactsEngine(subject).query({
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(centred.scopeLabel, 'within 250 km of DYNO11');
|
||||
|
||||
const plainRadius = await makeContactsEngine(null).query({
|
||||
layers: ['flights'], scope: { kind: 'radius', km: 250 }, limit: 1,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'radius', km: 250 },
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(plainRadius.scopeLabel, 'within 250 km');
|
||||
|
||||
const inView = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'view' }, limit: 1,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'view' },
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(inView.scopeLabel, 'in view');
|
||||
|
||||
const region = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 1,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(region.scopeLabel, 'over Texland');
|
||||
|
||||
const anywhere = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' }, limit: 1,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(anywhere.scopeLabel, 'anywhere in the loaded data');
|
||||
});
|
||||
|
||||
test('analyst: count flights over a region', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Texland' }, limit: 50 });
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
limit: 50,
|
||||
});
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.count, 3, 'Oregon flight excluded');
|
||||
assert.ok(r.coverage.scope.includes('Texland'));
|
||||
@@ -133,32 +248,65 @@ test('analyst: count flights over a region', async () => {
|
||||
|
||||
test('analyst: attribute filter — above 40,000 ft (~12,192 m)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['RCH01']);
|
||||
assert.deepEqual(
|
||||
r.items.map((i) => i.id),
|
||||
['RCH01'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: military flag + region compose', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'region', name: 'Texland' },
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
filters: [{ field: 'military', op: 'eq', value: true }],
|
||||
});
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items[0].id, 'RCH01');
|
||||
});
|
||||
|
||||
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: ships headed to Oakland (destination contains)', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['ais-live-vessels'], scope: { kind: 'anywhere' },
|
||||
layers: ['ais-live-vessels'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'destination', op: 'contains', value: 'oakland' }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['EVERGIVEN']);
|
||||
assert.deepEqual(
|
||||
r.items.map((i) => i.id),
|
||||
['EVERGIVEN'],
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: superlative — biggest fire in view radius', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['local-firms'], scope: { kind: 'view' }, sortBy: 'frp', limit: 1,
|
||||
layers: ['local-firms'],
|
||||
scope: { kind: 'view' },
|
||||
sortBy: 'frp',
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(r.items[0].id, 'FIRE-1', 'BC monster is out of view scope');
|
||||
assert.equal(r.summary.frpMax, 1500);
|
||||
@@ -166,7 +314,10 @@ test('analyst: superlative — biggest fire in view radius', async () => {
|
||||
|
||||
test('analyst: nearest sorting attaches distanceKm ascending', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'view' }, sortBy: 'distance', limit: 3,
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'view' },
|
||||
sortBy: 'distance',
|
||||
limit: 3,
|
||||
});
|
||||
assert.ok(r.items[0].distanceKm <= r.items[1].distanceKm);
|
||||
assert.ok(Number.isFinite(r.items[0].distanceKm));
|
||||
@@ -266,7 +417,10 @@ test('analyst: failed pre-commit work cannot replace follow-up memory', async ()
|
||||
});
|
||||
|
||||
test('analyst: unresolved region is an honest failure, not empty success', async () => {
|
||||
const r = await makeEngine().query({ layers: ['flights'], scope: { kind: 'region', name: 'Atlantis' } });
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Atlantis' },
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /Atlantis/);
|
||||
});
|
||||
@@ -274,10 +428,17 @@ test('analyst: unresolved region is an honest failure, not empty success', async
|
||||
test('analyst: a region lookup timeout is reported as region-timeout', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }),
|
||||
resolveRegionRing: async (name) => ({
|
||||
name,
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
}),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } });
|
||||
const r = await eng.query({
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'region', name: 'Texas' },
|
||||
});
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'region-timeout');
|
||||
assert.match(r.error, /Texas/);
|
||||
@@ -319,32 +480,84 @@ test('analyst: region lookup receives and obeys the owning action signal', async
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
layers: ['flights'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'routeDestination', op: 'eq', value: 'LAX' }],
|
||||
});
|
||||
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
|
||||
assert.deepEqual(
|
||||
r.items.map((i) => i.id),
|
||||
['SWA1'],
|
||||
'null route fields never match',
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst: 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' },
|
||||
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
|
||||
{
|
||||
id: 'ISS (ZARYA)',
|
||||
noradId: '25544',
|
||||
name: 'ISS (ZARYA)',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
altitudeM: 410000,
|
||||
satelliteClass: 'STATION · ISS',
|
||||
group: 'stations',
|
||||
},
|
||||
{
|
||||
id: 'GPS BIIR-2',
|
||||
noradId: '24876',
|
||||
name: 'GPS BIIR-2',
|
||||
lat: 51.0,
|
||||
lon: 0.0,
|
||||
altitudeM: 20200000,
|
||||
satelliteClass: 'NAV · GPS',
|
||||
group: 'gps-ops',
|
||||
},
|
||||
];
|
||||
const DAMS = [
|
||||
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
|
||||
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
|
||||
{
|
||||
id: 'Austin Dam',
|
||||
name: 'Austin Dam',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
operator: 'LCRA',
|
||||
river: 'Colorado',
|
||||
output: '2 MW',
|
||||
},
|
||||
{
|
||||
id: 'Far Dam',
|
||||
name: 'Far Dam',
|
||||
lat: 45.0,
|
||||
lon: -122.0,
|
||||
operator: 'USACE',
|
||||
river: 'Columbia',
|
||||
output: '1000 MW',
|
||||
},
|
||||
];
|
||||
const DCS = [
|
||||
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
|
||||
{
|
||||
id: 'AUS-1',
|
||||
name: 'AUS-1',
|
||||
lat: 30.28,
|
||||
lon: -97.75,
|
||||
operator: 'Example Cloud',
|
||||
capacity: '27 MW',
|
||||
},
|
||||
];
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
|
||||
getRecords: (key) =>
|
||||
({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS })[
|
||||
key
|
||||
] || [],
|
||||
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
|
||||
const sats = await eng.query({
|
||||
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
|
||||
layers: ['satellites'],
|
||||
scope: { kind: 'view' },
|
||||
sortBy: 'distance',
|
||||
limit: 2,
|
||||
});
|
||||
assert.equal(sats.ok, true);
|
||||
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
|
||||
@@ -352,20 +565,28 @@ test('analyst: satellites and local infrastructure are queryable layers', async
|
||||
assert.ok(Number.isFinite(sats.items[0].distanceKm));
|
||||
|
||||
const nav = await eng.query({
|
||||
layers: ['satellites'], scope: { kind: 'anywhere' },
|
||||
layers: ['satellites'],
|
||||
scope: { kind: 'anywhere' },
|
||||
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
|
||||
});
|
||||
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
|
||||
assert.deepEqual(
|
||||
nav.items.map((i) => i.id),
|
||||
['GPS BIIR-2'],
|
||||
);
|
||||
|
||||
const dams = await eng.query({
|
||||
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
|
||||
layers: ['local-dams'],
|
||||
scope: { kind: 'view' },
|
||||
sortBy: 'distance',
|
||||
limit: 5,
|
||||
});
|
||||
assert.equal(dams.count, 1);
|
||||
assert.equal(dams.items[0].id, 'Austin Dam');
|
||||
assert.equal(dams.items[0].river, 'Colorado');
|
||||
|
||||
const dcs = await eng.query({
|
||||
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
|
||||
layers: ['local-datacenters'],
|
||||
scope: { kind: 'region', name: 'Texland' },
|
||||
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
|
||||
});
|
||||
assert.equal(dcs.count, 1);
|
||||
@@ -375,7 +596,11 @@ test('analyst: satellites and local infrastructure are queryable layers', async
|
||||
test('helpers: haversine sanity + scope radius', () => {
|
||||
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
|
||||
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
|
||||
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
|
||||
const scoped = applyScope(
|
||||
FLIGHTS,
|
||||
{ kind: 'radius' },
|
||||
{ center: { lat: 30.27, lon: -97.74 }, km: 50 },
|
||||
);
|
||||
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
|
||||
});
|
||||
|
||||
@@ -385,7 +610,11 @@ test('capped cohorts report returned/total/truncated and keep it on follow-up',
|
||||
getRecordCoverage: () => ({ total: 3000, truncated: true }),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
|
||||
const result = await engine.query({
|
||||
layers: ['local-dams'],
|
||||
scope: { kind: 'anywhere' },
|
||||
sortBy: 'distance',
|
||||
});
|
||||
assert.equal(result.count, 1);
|
||||
const layer = result.coverage.layersQueried[0];
|
||||
assert.deepEqual({ returned: layer.returned, total: layer.total, truncated: layer.truncated }, { returned: 1, total: 3000, truncated: true });
|
||||
@@ -510,7 +739,11 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
|
||||
let state = 'stale';
|
||||
const engine = createAnalystEngine({
|
||||
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
|
||||
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
|
||||
getLayerSnapshot: () => ({
|
||||
id: 'flights',
|
||||
enabled: true,
|
||||
feedState: state,
|
||||
}),
|
||||
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
|
||||
});
|
||||
const first = await engine.query();
|
||||
@@ -520,7 +753,10 @@ test('follow-up provenance stays attached to old rows after a feed recovers', as
|
||||
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
|
||||
engine.reset();
|
||||
assert.equal(engine.hasMemory(), false);
|
||||
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
|
||||
assert.equal(
|
||||
(await engine.query()).coverage.feedProvenance.overall,
|
||||
'nominal',
|
||||
);
|
||||
});
|
||||
|
||||
test('a large loaded set sorts and summarizes without overflowing the stack', async () => {
|
||||
|
||||
@@ -79,6 +79,9 @@ test('flights analyst record: full record maps every contract field', () => {
|
||||
aircraftClass: 'airliner',
|
||||
originCountry: 'United States',
|
||||
operator: 'Southwest Airlines',
|
||||
squawk: null,
|
||||
emergency: false,
|
||||
emergencyKind: null,
|
||||
routeOrigin: 'AUS',
|
||||
routeDestination: 'LAX',
|
||||
});
|
||||
|
||||
@@ -71,11 +71,31 @@ 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,
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,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 +222,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: string|null,
|
||||
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
|
||||
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||
* emergencyKind: string|null, routeOrigin: string|null,
|
||||
* routeDestination: string|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(
|
||||
@@ -253,6 +258,12 @@ 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),
|
||||
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
||||
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,28 @@ 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) {
|
||||
return isEmergency(flightState.records.data.get(icao24)?.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) {
|
||||
if (_isEmergency(icao24)) 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) {
|
||||
if (_isEmergency(icao24)) 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 +146,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 +177,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,9 +1024,7 @@ export function createRendering({
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? isMilitaryIcao(icao24)
|
||||
? MIL_TINT
|
||||
: COCKPIT_CIVILIAN_COLOR
|
||||
? _cockpitContactColor(icao24)
|
||||
: _fleetBillboardColor(icao24);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -133,6 +133,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 +159,8 @@ 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, routeOrigin: null, routeDestination: null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(icao24, info) {
|
||||
@@ -187,6 +191,12 @@ 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),
|
||||
routeOrigin: null,
|
||||
routeDestination: null,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,7 @@ 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).withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
|
||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||
@@ -189,7 +191,16 @@ 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) {
|
||||
return isEmergency(flightState.records.data.get(icao24)?.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 +976,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor = MIL_ICON_COLOR;
|
||||
const baseColor = _fleetBillboardColor(icao24);
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
@@ -1082,6 +1093,7 @@ export function createRendering({
|
||||
_applyFleetBillboardPresentation,
|
||||
_iconKind,
|
||||
_modelColor,
|
||||
_fleetBillboardColor,
|
||||
_modelRegimeActive,
|
||||
_modelCap,
|
||||
_modelAddDistM,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -496,6 +496,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.cockpit-aircraft-meta.emergency {
|
||||
color: #ff4444;
|
||||
}
|
||||
.cockpit-mode-chip {
|
||||
margin-top: 2px;
|
||||
padding: 7px 11px;
|
||||
|
||||
@@ -2270,6 +2270,12 @@
|
||||
color: rgba(205, 225, 226, 0.48);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
.cockpit-aircraft-meta.emergency {
|
||||
color: #ff4444;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
|
||||
margin-top: 0;
|
||||
border-color: rgba(105, 203, 209, 0.38);
|
||||
|
||||
Reference in new issue
Block a user