mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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