Merge upstream/main into pr/panoramax

CHANGELOG: keep both new [Unreleased] entries (Panoramax, and the
emergency-transponder highlight from #856).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-15 12:57:53 +01:00
commit de83e1c17f
131 files changed
+7123 -275

No files matched your search

+5 -2
View File
@@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
+4
View File
@@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -120,6 +121,9 @@ export function createApplicationCatalog({
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 30);
assert.equal(first.layers.length, 31);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.equal(catalog.layers.length, 31);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+6
View File
@@ -0,0 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
}
+1
View File
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
assert.equal(actual.layers.length, 31);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+20
View File
@@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* @param {{label?: string, squawk?: string|null, source?: string,
* severity?: string}|null|undefined} emergency
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
if (emergency.source === 'squawk' && emergency.squawk)
return `SQUAWK ${emergency.squawk} · ${label}`;
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
return `ADS-B ${field} · ${label}`;
}
/** Format the cockpit Context scope without overstating installation coverage. */
export function formatCockpitContextScope(
subjectLabel,
+19
View File
@@ -13,6 +13,7 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatSpeedRulerTick,
normalizeHeading,
@@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
+5 -2
View File
@@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
// Built with path.join so the stub matches the resolver on Windows too,
// where the separators are backslashes.
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
if (candidate === installed) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
installed,
);
assert.equal(checked.length, 1);
+37 -8
View File
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null;
if (
!hex ||
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return null;
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean);
return { time: nowSeconds, states };
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
const states = [];
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
// status travels alongside them, keyed only to admitted aircraft.
const admitted = new Set();
const statuses = [];
for (const row of aircraft) {
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
if (!state) continue;
states.push(state);
// The live contract admits the first valid vector for each ICAO. Its
// status must come from that same row, never an invalid or later duplicate.
const id = state[0];
if (admitted.has(id)) continue;
admitted.add(id);
if (typeof row.emergency === 'string')
statuses.push([id, row.emergency.trim().toLowerCase()]);
}
const emergencyByIcao = Object.fromEntries(statuses);
return {
time: nowSeconds,
states,
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
};
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 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. Highlighting follows the latest
* broadcast, which does not confirm an incident or its resolution.
*/
/** @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. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
* 'squawk' when the Mode A code itself names the status (so it can be
* quoted), else 'ads-b'.
*/
export function aircraftEmergency({ squawk, emergency } = {}) {
const code = normalizeSquawk(squawk);
const reported = String(emergency ?? '')
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
...KINDS[kind],
squawk: code,
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
};
}
/**
* How a summary may phrase the status without flattening it into an incident:
* quote the code when the Mode A squawk itself names the status, otherwise
* attribute it to the ADS-B emergency field.
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
* or 'ADS-B reports general emergency'; null when there is no status.
*/
export function emergencyBroadcastPhrase(status) {
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
const label = KINDS[status.kind].label.toLowerCase();
return status.source === 'squawk' && status.squawk
? `broadcasting squawk ${status.squawk} (${label})`
: `ADS-B reports ${label}`;
}
/**
* True when the aircraft is broadcasting an emergency (not a priority status).
* @param {{severity?: string}|null|undefined} status
* @returns {boolean}
*/
export function isEmergency(status) {
return status?.severity === 'emergency';
}
+95
View File
@@ -0,0 +1,95 @@
// src/data/aircraftEmergency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
aircraftEmergency,
emergencyBroadcastPhrase,
isEmergency,
normalizeSquawk,
} from './aircraftEmergency.js';
test('the three ICAO emergency squawks map to their kinds', () => {
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
const general = aircraftEmergency({ squawk: ' 7700 ' });
assert.deepEqual(general, {
kind: 'general',
label: 'General emergency',
severity: 'emergency',
squawk: '7700',
source: 'squawk',
});
});
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
assert.equal(aircraftEmergency(), null);
assert.equal(normalizeSquawk('7800'), null);
assert.equal(normalizeSquawk(7700), '7700');
assert.equal(normalizeSquawk('770'), null);
});
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
assert.equal(minfuel.kind, 'minfuel');
assert.equal(minfuel.severity, 'priority');
assert.equal(minfuel.source, 'ads-b');
assert.equal(minfuel.squawk, '4521');
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
'squawk',
);
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
'squawk',
'a code that names the status stays quotable',
);
assert.equal(
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
'ads-b',
);
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
});
test('priority statuses are reported but are not emergencies', () => {
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
assert.equal(
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
false,
);
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
assert.equal(isEmergency(null), false);
});
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(
emergencyBroadcastPhrase(
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
),
'ADS-B reports general emergency',
);
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
'ADS-B reports lifeguard (medical priority)',
);
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+524
View File
@@ -0,0 +1,524 @@
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
import { FlightRecords } from '../layers/flights/records.js';
import { MilitaryFlightRecords } from '../layers/military/records.js';
import { mapAnalystRecord as mapFlight } from './flights.js';
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
import { createAnalystEngine } from './analystEngine.js';
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
import { createGevActionRunner } from '../voice/gevActions.js';
const NOW_MS = 1800000000000;
test('fallback status belongs to the first valid admitted aircraft row', () => {
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
for (const status of [undefined, 'general', 'none']) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [
{ ...valid, lat: 100, emergency: 'general' },
{ ...valid, emergency: status },
{ ...valid, lat: null, emergency: 'general' },
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
],
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
function civilStore() {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
approxDistanceKm: () => 0,
});
}
const receiveContext = {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
};
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
const store = civilStore();
const aircraft = {
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
seen: 3,
seen_pos: 1,
};
for (const [status, expectedKind, flagged] of [
['general', 'general', true],
['minfuel', 'minfuel', false],
['none', null, false],
]) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [{ ...aircraft, emergency: status }],
});
assert.equal(
payload.states[0].length,
18,
'fallback vectors retain the OpenSky shape',
);
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
const result = mapFlight(
aircraft.hex,
store.receive(observation, receiveContext).meta,
);
assert.equal(result.emergency, flagged);
assert.equal(result.emergencyKind, expectedKind);
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
}
});
test('changed status without contact time retains general contact, including voice projection', async () => {
const store = civilStore();
const [observation] = readsbSnapshot(
{
ac: [
{
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS, now: NOW_MS },
).records;
store.receive(observation, receiveContext);
const meta = store.receive(
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
receiveContext,
).meta;
const record = mapFlight(observation.id, meta);
assert.equal(record.emergencyKind, 'nordo');
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const flights = {
id: 'flights',
source: 'fixture',
getStats: () => ({ count: 1 }),
getAnalystRecords: () => [record],
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionCartographic: {
height: 300000,
latitude: 0.52,
longitude: -1.71,
},
},
},
styleManager: {
getContextModeState: () => ({ mode: null, active: false }),
getCockpitState: () => ({ active: false }),
},
dataManager: {
layers: new Map([['flights', { module: flights }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
],
},
});
try {
const result = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'emergency', op: 'eq', value: true }],
});
assert.equal(result.ok, true);
assert.equal(result.items[0].emergency, true);
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
assert.equal(result.items[0].emergencySource, 'squawk');
assert.equal(
result.items[0].emergencyBroadcast,
'broadcasting squawk 7600 (radio failure)',
);
assert.equal(
Object.hasOwn(result.items[0], 'time'),
false,
'general contact is not a generic incident timestamp',
);
} finally {
runner.dispose?.();
}
});
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
const OPENSKY = {
time: NOW_MS / 1000,
states: [
[
'a1b2c3',
'SWA696 ',
'United States',
NOW_MS / 1000 - 6,
NOW_MS / 1000 - 3,
-97.67,
30.19,
3000,
false,
150,
180,
-5,
null,
3040,
'7700',
false,
0,
4,
],
],
};
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
const ADSB_LOL = {
ac: [
{
hex: 'ae01ce',
flight: 'RCH123 ',
t: 'C17',
lat: 31.1,
lon: -97.4,
alt_baro: 18000,
gs: 300,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 1.5,
seen_pos: 2,
},
],
};
function civilAnalystRecord() {
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
const store = new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
});
const { meta } = store.receive(row, {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
return mapFlight(row.id, meta);
}
function militaryAnalystRecord() {
const [row] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
});
const { meta } = store.receive(row, {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
});
return mapMilitary(row.id, meta);
}
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
const civil = civilAnalystRecord();
const military = militaryAnalystRecord();
assert.equal(civil.emergency, true);
assert.equal(civil.emergencyKind, 'general');
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergencySource, 'squawk');
assert.equal(
civil.emergencyBroadcast,
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
assert.equal(military.emergency, true);
assert.equal(military.emergencyKind, 'general');
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
assert.equal(military.emergencySource, 'ads-b');
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
});
test('the analyst can filter emergencies by source', async () => {
const records = {
flights: [civilAnalystRecord()],
military: [militaryAnalystRecord()],
};
const engine = createAnalystEngine({
getRecords: (key) => records[key] || [],
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
});
const query = (value) =>
engine.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'emergency', op: 'eq', value: true },
{ field: 'emergencySource', op: 'eq', value },
],
});
const bySquawk = await query('squawk');
assert.deepEqual(
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
);
const byAdsb = await query('ads-b');
assert.deepEqual(
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['ae01ce', 'ADS-B reports general emergency']],
);
});
test('military status changes retain only the latest known general contact', () => {
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (v) => v,
});
const [observation] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const context = {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
};
const unknown = mapMilitary(
observation.id,
store.receive({ ...observation, contactTimeMs: null }, context).meta,
);
assert.equal(unknown.lastContactAtMs, null);
store.receive(observation, context);
const changed = mapMilitary(
observation.id,
store.receive(
{
...observation,
contactTimeMs: null,
squawk: '7600',
emergency: 'none',
},
context,
).meta,
);
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+5 -2
View File
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
}
/**
* Apply the already-composed aircraft alpha to an ambient glTF model without
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
model.color.red === baseColor.red &&
model.color.green === baseColor.green &&
model.color.blue === baseColor.blue
)
return 0;
model.color = baseColor.withAlpha(alpha);
+12
View File
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
assert.equal(model.colorBlendAmount, 0.9);
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
});
test('model hue changes at constant alpha, then clears without redundant writes', () => {
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
const model = {color:color(1,1,1).withAlpha(0.65)};
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
assert.equal(model.color.green,0.2);
assert.equal(model.color.alpha,0.65);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
assert.equal(model.color.green,1);
});
+1 -1
View File
@@ -111,7 +111,7 @@ export const DATA_CREDITS = [
{
key: 'adsblol',
html:
'Military flights, aircraft traces &amp; bounded regional flight fallback: ' +
'Military flights, aircraft traces, bounded regional flight fallback &amp; GNSS navigation integrity: ' +
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseFirmsCsv } from './firmsCsv.js';
import { adaptFirmsRecords } from './firmsAdapt.js';
test('missing positions do not become fire detections at zero coordinates', () => {
const csv =
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[0, 0],
[-90, -180],
],
);
const records = [
{ lat: null, lon: 1 },
{ lat: '', lon: 1 },
{ lat: false, lon: 1 },
{ lat: 95, lon: 1 },
{ lat: 1, lon: 181 },
{ lat: '0', lon: 0 },
{ lat: -90, lon: -180 },
];
assert.deepEqual(
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
[
[0, 0, 0],
[1, -90, -180],
],
);
});
test('both axes keep their exact bounds and drop values just past them', () => {
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
const csv = [
'latitude,longitude,acq_date,acq_time,confidence,frp',
row(90, 180),
row(-90, -180),
row(90.000001, 0),
row(-90.000001, 0),
row(0, 180.000001),
row(0, -180.000001),
row(' ', 5),
row(5, '\t'),
].join('\n');
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
const kept = adaptFirmsRecords([
{ lat: 90, lon: 180 },
{ lat: '-90', lon: '-180' },
{ lat: -90.000001, lon: 0 },
{ lat: 0, lon: -180.000001 },
{ lat: 5, lon: ' ' },
{ lat: 5, lon: undefined },
{ lat: [5], lon: 5 },
]);
assert.deepEqual(
kept.map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
});
+19 -3
View File
@@ -7,8 +7,8 @@
*/
/**
* Map proxy fire records into internal fire records. Records with
* non-finite coordinates are skipped; `index` is the post-skip position
* Map proxy fire records into internal fire records. Records with missing,
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
* (it keys pick ids and context-store ids, so it must stay sequential).
* `contextEntity`/`position` start null and are lazily filled by the layer.
* @param {?Array<Object>} records - /api/firms `fires` array.
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
for (const record of records) {
const lat = Number(record?.lat);
const lon = Number(record?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (
!hasCoordinateValue(record?.lat) ||
!hasCoordinateValue(record?.lon) ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
fires.push({
index: fires.length,
lat,
@@ -110,3 +118,11 @@ function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
function hasCoordinateValue(value) {
return (
typeof value === 'number' ||
(typeof value === 'string' && value.trim() !== '')
);
}
+11 -1
View File
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
if (parts.length < header.length) continue; // malformed row — skip
const lat = Number(parts[iLat]);
const lon = Number(parts[iLon]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Blank or out-of-range positions are dropped, never read as 0 (see
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
if (
!parts[iLat].trim() ||
!parts[iLon].trim() ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
records.push({
lat,
+6
View File
@@ -79,6 +79,12 @@ test('flights analyst record: full record maps every contract field', () => {
aircraftClass: 'airliner',
originCountry: 'United States',
operator: 'Southwest Airlines',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: 'AUS',
routeDestination: 'LAX',
});
+429
View File
@@ -0,0 +1,429 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gnssIntegrityAnchor,
gnssIntegrityProxy,
gnssRetryCooldownMs,
} from '../../server/providers/gnssIntegrity.js';
const snapshot = {
now: 4_000_000_000_000,
ac: [
{
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 0,
nac_p: 0,
seen_pos: 1,
flight: 'RYR1AB ',
r: 'EI-ABC',
},
{ hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
],
};
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options });
assert.equal(plugin.name, 'gnss-integrity');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/gnss-integrity');
handler = callback;
},
},
});
return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
lat: 50,
lon: 20,
});
assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json(snapshot);
},
},
hook,
);
const res = await request(
'/?lat=50.2&lon=19.8&url=https://invalid.example',
);
assert.equal(res.status, 200);
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
assert.deepEqual(res.body, {
fetchedAt: 1234,
ageMs: 0,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
classifier: 'gev-nic-nacp-v2',
rows: [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 0,
nacp: 0,
gpsLost: false,
degraded: true,
},
],
});
assert.equal(res.headers['Cache-Control'], 'no-store');
});
}
test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return Response.json(snapshot);
},
});
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
assert.equal(calls, 1);
clock = 59_000;
const cached = await request();
assert.equal(calls, 1);
assert.equal(cached.body.fetchedAt, 0);
assert.equal(
cached.body.ageMs,
59_000,
'a cached answer reports its age on the server clock',
);
clock = 61_000;
await request();
assert.equal(calls, 2);
await request('/?lat=10&lon=10');
assert.equal(calls, 3);
});
test('an upstream failure serves the last snapshot as stale, else 502', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 120_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(
stale.body.fetchedAt,
0,
'a stale answer keeps the time it was observed',
);
assert.equal(stale.body.ageMs, 120_000);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
});
test('a stale snapshot older than the layer window is no longer served', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 30 * 60_000;
assert.equal((await request()).status, 200);
clock = 30 * 60_000 + 1;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
});
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return new Response('slow down', {
status: 429,
headers: { 'retry-after': '90' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.headers['Retry-After'], '90');
clock = 30_500;
const cooling = await request('/?lat=1&lon=1');
assert.equal(cooling.status, 429);
// The browser is told the remaining shared cooldown, rounded up.
assert.equal(cooling.headers['Retry-After'], '60');
assert.equal(calls, 1);
clock = 91_000;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
});
test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => {
const nowMs = Date.parse('2026-09-29T12:00:00Z');
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs),
3_600_000,
);
assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs),
45_000,
);
// Garbage, a past date or no header fall back to the default minute.
assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs),
60_000,
);
assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000);
});
test('overlapping 429s preserve the longest shared deadline', async () => {
let clock = 0,
calls = 0;
const pending = [];
const request = install({
now: () => clock,
fetchImpl: () => {
calls += 1;
return new Promise((resolve) => pending.push(resolve));
},
});
const long = request('/?lat=1&lon=0'),
short = request('/?lat=2&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[0](
new Response('', { status: 429, headers: { 'Retry-After': '3600' } }),
);
assert.equal((await long).headers['Retry-After'], '3600');
clock = 1000;
pending[1](
new Response('', { status: 429, headers: { 'Retry-After': '5' } }),
);
assert.equal((await short).headers['Retry-After'], '3599');
clock = 7000;
assert.equal((await request('/?lat=3&lon=0')).status, 429);
assert.equal(calls, 2);
clock = 3_600_001;
const resumed = request('/?lat=3&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[2](Response.json(snapshot));
assert.equal((await resumed).status, 200);
assert.equal(calls, 3);
});
test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => {
let clock = 0;
let limited = false;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return limited
? new Response('slow down', { status: 429 })
: Response.json(snapshot);
},
});
await request();
limited = true;
clock = 61_000;
// Another anchor trips the shared cooldown…
assert.equal((await request('/?lat=5&lon=5')).status, 429);
clock = 62_000;
// …and the cached anchor is answered from cache without an upstream call.
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows.length, 1);
assert.equal(calls, 2);
});
test('an oversized upstream body is refused as 502 without being parsed', async () => {
const request = install({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(9 * 1024 * 1024) },
}),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' });
});
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
const request = install({
fetchImpl: async () => Response.json({ nope: true }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/other?lat=1&lon=1')).status, 404);
assert.equal((await request('/?lat=1')).status, 400);
assert.equal((await request('/?lat=1&lon=1')).status, 502);
const flood = install({ fetchImpl: async () => Response.json(snapshot) });
let limited = 0;
for (let i = 0; i < 40; i += 1)
if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429)
limited += 1;
assert.ok(limited > 0);
});
test('GERMAN: pathological retry deadlines are bounded', () => {
const at = Date.parse('2026-10-14T12:00:00Z');
assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000);
assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000);
assert.equal(
gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at),
3600000,
);
assert.equal(gnssRetryCooldownMs('0x10', at), 60000);
});
test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => {
for (const fail of [
async () => new Response('', { status: 503 }),
async () => {
throw new DOMException('timeout', 'TimeoutError');
},
async () => new Response('bad JSON'),
]) {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls++;
return fail();
},
});
assert.equal((await request()).status, 502);
clock = 14000;
assert.equal((await request('/?lat=1&lon=1')).status, 502);
assert.equal(calls, 1);
clock = 15001;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
}
});
test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => {
const now = 1800000000000;
const request = install({
now: () => now,
fetchImpl: async () =>
Response.json({ ...snapshot, now: (now - 120000) / 1000 }),
});
const res = await request();
assert.equal(res.body.ageMs, 120000);
assert.equal(res.body.stale, true);
for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) {
const missing = install({
now: () => now,
fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }),
});
assert.equal((await missing()).status, 502);
}
});
test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => {
let wall = 1800000000000,
mono = 0,
calls = 0;
const request = install({
now: () => wall,
monotonicNow: () => mono,
fetchImpl: async () => {
calls++;
return calls === 1
? Response.json({ ...snapshot, now: wall / 1000 })
: new Response('', { status: 429, headers: { 'Retry-After': '60' } });
},
});
await request();
wall -= 3600000;
mono = 61000;
const stale = await request();
assert.equal(stale.body.ageMs, 61000);
assert.equal(stale.body.stale, true);
mono += 1000;
assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59');
assert.equal(calls, 2);
});
test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => {
let calls = 0;
const request = install({
now: () => 1800000000000,
fetchImpl: async () => {
calls++;
return Response.json(snapshot);
},
});
for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200);
assert.equal((await request('/?lat=1&lon=1')).status, 200);
assert.equal(calls, 2);
});
test('GERMAN: distinct concurrent upstream reads are bounded', async () => {
const pending = [];
const request = install({
fetchImpl: () => new Promise((resolve) => pending.push(resolve)),
});
const jobs = Array.from({ length: 30 }, (_, i) =>
request(`/?lat=${i}&lon=1`),
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(pending.length <= 8);
pending.forEach((resolve) => resolve(Response.json(snapshot)));
const results = await Promise.all(jobs);
assert.ok(results.some((result) => result.status === 429));
});
+5
View File
@@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'flights',
}),
Object.freeze({
id: 'gnss-integrity',
token: '3',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-datacenters',
+30 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 29);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.equal(REGISTERED_LAYER_IDS.length, 30);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '3');
assert.equal(nextLayerStateToken(), '4');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
alpha: '4',
bravo: '5',
}),
'5',
'6',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
future: '4',
next: '5',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
/next free token 4/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
'fire-perimeters',
'gnss-integrity',
'recent-imagery',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token,
'3',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+33 -29
View File
@@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-integrity': '3',
},
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
.future,
'3',
'4',
);
assert.throws(
() =>
@@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '3']] },
{ rows: [...reservationRows, ['retired-layer', '4']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
assert.equal(nextLayerStateToken(published), '5');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '4',
newcomer: '5',
}),
true,
);
@@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '3',
competing: '4',
}),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '3',
newcomer: '4',
}),
/changed or removed: retired-layer/,
);
@@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
[...reservationRows, ['pr-b', '4']],
[{ id: 'pr-b', token: '4' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '4' },
],
);
const staleRebase = runCheck();
@@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
assert.equal(nextLayerStateToken(published), '5');
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 4/);
assert.match(helper.stdout, /pr-b: 5/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '5' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /30 published, 1 new/);
assert.match(corrected.stdout, /31 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'3456789'].map((digit) => [
const priorDigits = [...'456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 69);
assert.equal(params.get('l').length, 71);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 30);
assert.equal(Object.keys(published).length, 31);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -9,6 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["gnss-integrity", "3"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
+10 -4
View File
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
removeGuard();
// The newest intent must be able to abort the hung compensation and run.
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
let giveUpTimer;
const finalOn = await Promise.race([
pendingFinalOn,
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
releaseFirstDisable();
const blockedOn = await pendingBlockedOn;
let giveUpTimer;
const reentrant = await Promise.race([
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
await pendingOff;
await mgr.waitForLayerSettled('flights');
unsubscribe();
+6
View File
@@ -71,6 +71,12 @@ test('military analyst record: full record maps every contract field', () => {
aircraftClass: 'widebody',
originCountry: null,
operator: 'United States Air Force',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: null,
routeDestination: null,
});
+15 -7
View File
@@ -87,14 +87,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
return articles;
}
/** A reported number, or null when the observation is missing or not numeric. */
function observedNumber(value) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
if (!current || observedNumber(current.temperature_2m) == null) return null;
const numberOrNull = observedNumber;
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw =
@@ -119,8 +127,8 @@ export function normalizeRegionalWeather(payload) {
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
const value = observedNumber(code);
if (value == null) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
const step = (type) => ({
maneuver: { type, location: [10, 20] },
distance: 10,
duration: 1,
});
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
assert.equal(
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
.truncated,
true,
);
steps[steps.length - 1] = step('roundabout');
const result = normalizeOsrmSteps({
legs: [{ steps: [...steps, step('exit roundabout')] }],
});
assert.equal(result.truncated, false);
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
assert.equal(result.steps.at(-1).distanceM, 20);
});
+1 -1
View File
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
}
continue;
}
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
step.instruction = instructionFor(step);
out.push(step);
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
}
}
return { steps: out, truncated: false };
+2 -2
View File
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
// would present a route that simply stops in the middle of a road.
assert.equal(cut.truncated, true);
const exact = normalizeOsrmSteps({
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
});
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
assert.equal(exact.truncated, false, 'a route that fits is not cut');
});
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
test('missing weather observations never assert clear conditions', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
assert.equal(
normalizeRegionalWeather({ current: { temperature_2m: value } }),
null,
);
}
assert.equal(weatherCodeLabel(0), 'CLEAR');
assert.equal(weatherCodeLabel('0'), 'CLEAR');
const record = normalizeRegionalWeather({
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
});
assert.equal(record.temperatureC, 0);
assert.equal(record.weatherCode, null);
assert.equal(record.windKph, null);
});
+6 -1
View File
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
};
}
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
export function normalizeCameraLongitude(longitude) {
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { cameraAtProgress } from './timeline.js';
const camera = (lon) => ({
lat: 10,
lon,
alt: 1000,
heading: 0,
pitch: -40,
roll: 0,
});
test('director seek follows the short dateline arc in both directions', () => {
for (const [a, b] of [
[179, -179],
[-179, 179],
]) {
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
}
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
});
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
for (const [a, b] of [
[180, -180],
[-180, 180],
]) {
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
}
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
});
test('director seek turns heading and roll the short way for any authored angles', () => {
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
for (const value of [mid.heading, mid.roll]) {
const wrapped = ((value % 360) + 360) % 360;
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
}
});
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAssetDirectorySource } from './source.js';
test('cancellation during the terminating read cannot return a scene asset', async () => {
for (const cancel of [true, false]) {
const controller = new AbortController();
let reads = 0,
cancelled = 0,
released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'content-type': 'application/octet-stream' }),
body: {
getReader() {
return {
async read() {
reads++;
if (reads === 1)
return { done: false, value: new Uint8Array([1, 2]) };
if (cancel) controller.abort();
return { done: true };
},
async cancel() {
cancelled++;
},
releaseLock() {
released++;
},
};
},
},
}),
});
const pending = source({ path: 'scene.bin', signal: controller.signal });
if (cancel) await assert.rejects(pending, { name: 'AbortError' });
else assert.deepEqual([...(await pending).bytes], [1, 2]);
assert.equal(cancelled, 1);
assert.equal(released, 1);
}
});
test('cancellation during reader cleanup cannot return a scene asset', async () => {
const controller = new AbortController();
const reason = new DOMException('scene changed', 'AbortError');
let released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
body: {
getReader() {
let reads = 0;
return {
async read() {
reads++;
return reads === 1
? { done: false, value: new Uint8Array([1]) }
: { done: true };
},
async cancel() {
controller.abort(reason);
},
releaseLock() {
released++;
},
};
},
},
}),
});
await assert.rejects(
source({ path: 'scene.bin', signal: controller.signal }),
(error) => error === reason,
);
assert.equal(released, 1);
});
+3
View File
@@ -41,6 +41,7 @@ export function createAssetDirectorySource({
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
@@ -50,6 +51,8 @@ export function createAssetDirectorySource({
await reader.cancel().catch(() => {});
reader.releaseLock();
}
// Cleanup awaits too; a cancel during it must still discard the asset.
signal?.throwIfAborted();
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
+10 -2
View File
@@ -2,6 +2,7 @@ import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
normalizeCameraLongitude,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
// Double modulo keeps the delta in [-180, 180) for any authored angles,
// as sampleCameraMove does; a single % goes negative below -540.
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
lon:
t === 0
? Number(source.lon)
: t === 1
? Number(target.lon)
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
+3 -3
View File
@@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level adds toggle enum values and the generated alias hint.
assert.equal(block.length, 29802, 'serialized tool schema length drifted');
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+27 -1
View File
@@ -1,3 +1,28 @@
/**
* A v4 UUID for the client lease. `crypto.randomUUID` only exists in secure
* contexts, so a page opened over plain HTTP from another machine (the
* HOST=0.0.0.0 LAN opt-in) has none; `getRandomValues` exists in every context
* and yields the same shape the server's lease check accepts.
*/
export function createLeaseId(cryptoImpl = globalThis.crypto) {
if (typeof cryptoImpl.randomUUID === 'function') {
return cryptoImpl.randomUUID();
}
const bytes = cryptoImpl.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4),
hex.slice(4, 6),
hex.slice(6, 8),
hex.slice(8, 10),
hex.slice(10),
]
.map((group) => group.join(''))
.join('-');
}
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
@@ -7,13 +32,14 @@ export function attachCctvVideo(
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
cryptoImpl = globalThis.crypto,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const leaseId = feedType === 'hls' ? createLeaseId(cryptoImpl) : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
+38 -1
View File
@@ -1,6 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
import { webcrypto } from 'node:crypto';
import { attachCctvVideo, createLeaseId } from './videoPlayback.js';
const LEASE_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
/** What an insecure context exposes: getRandomValues but no randomUUID. */
const insecureCrypto = {
getRandomValues: (array) => webcrypto.getRandomValues(array),
};
function video() {
const v = new EventTarget();
Object.assign(v, {
@@ -69,6 +78,34 @@ test('native HLS releases its client lease without response-header access', asyn
assert.equal(releases[0].init.method, 'DELETE');
});
test('lease ids are v4 UUIDs with or without crypto.randomUUID', () => {
assert.match(createLeaseId(), LEASE_V4);
const ids = new Set();
for (let i = 0; i < 64; i++) ids.add(createLeaseId(insecureCrypto));
assert.equal(ids.size, 64);
for (const id of ids) assert.match(id, LEASE_V4);
});
test('HLS starts on a plain-HTTP LAN page without crypto.randomUUID', async () => {
const source = video();
source.canPlayType = () => 'probably';
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async () => {},
cryptoImpl: insecureCrypto,
});
try {
await playback.ready;
const lease = new URL(
source.src,
'http://192.168.1.10:4173/',
).searchParams.get('lease');
assert.match(lease, LEASE_V4);
} finally {
playback.dispose();
}
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
+6
View File
@@ -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
+23 -1
View File
@@ -15,6 +15,7 @@ import {
FOCUS_EVIDENCE_DEV,
FLEET_DR_INTERVAL_MS,
} from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -167,6 +168,10 @@ 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,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
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 +224,10 @@ export function createQueries({
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: string|null,
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null,
* routeDestination: string|null}}
*/
function mapAnalystRecord(
@@ -253,6 +261,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: text(info?.originCountry),
operator: text(info?.airline),
squawk: text(info?.squawk),
// True while this poll broadcast an emergency (7500/7600/7700 or the
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
// without the flag.
emergency: info?.emergency?.severity === 'emergency',
emergencyKind: text(info?.emergency?.kind),
// Provenance, so a summary can say "broadcasting squawk 7700" rather
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
// when the Mode A code names the status, 'ads-b' for the readsb field.
emergencySource: text(info?.emergency?.source),
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
// Latest known general aircraft-message contact, retained if a poll
// has no time. This is not the receipt time of the current status.
lastContactAtMs: num(info?.lastContactEpochMs),
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
};
+17
View File
@@ -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,22 @@ 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,
// One source-switch omission may retain an ADS-B status, while an
// explicit clear or changed squawk always takes effect immediately.
emergency:
observation.emergency === undefined &&
observation.squawk === prevMeta?.squawk &&
prevMeta?.emergency?.source === 'ads-b' &&
(prevMeta?.missingEmergencyPolls ?? 0) < 1
? prevMeta.emergency
: aircraftEmergency(observation),
missingEmergencyPolls:
observation.emergency === undefined
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
: 0,
// 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
+10
View File
@@ -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);
});
+29 -10
View File
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -13,6 +14,7 @@ import {
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_TINT,
EMERGENCY_TINT,
GROUND_SCALE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR,
@@ -54,13 +56,34 @@ export function createRendering({
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
services.recession;
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
/** True while this contact's latest poll broadcast an emergency code. */
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
return isEmergency(info?.emergency);
}
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
* amber for known-military, white otherwise.
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
function _fleetBillboardColor(icao24) {
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
}
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
function _cockpitContactColor(
icao24,
info = flightState.records.data.get(icao24),
) {
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
}
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
function _fleetBillboardScale(icao24, klass) {
@@ -129,9 +152,7 @@ export function createRendering({
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = (
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
).withAlpha(freshnessAlpha);
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -162,7 +183,7 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
return _fleetBillboardColor(icao24);
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -1009,10 +1030,8 @@ export function createRendering({
flightState._cockpitNearContacts.has(icao24);
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? isMilitaryIcao(icao24)
? MIL_TINT
: COCKPIT_CIVILIAN_COLOR
: _fleetBillboardColor(icao24);
? _cockpitContactColor(icao24, info)
: _fleetBillboardColor(icao24, info);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
+422
View File
@@ -0,0 +1,422 @@
import * as Cesium from 'cesium';
import {
GNSS_LEVEL_COLORS,
GNSS_MIN_AIRCRAFT,
GNSS_WINDOW_MS,
accumulateGnssObservations,
binGnssCells,
gnssProvenance,
pruneGnssObservations,
} from './records.js';
export * from './records.js';
export { createAdsbGnssSource } from './source.js';
/**
* GroundPrimitive allowPicking:false suppresses ids but still submits stencil
* commands in Cesium's pick pass, erasing picks of overlapping ground geometry.
* This fill participates only in rendering, so sibling polygons remain pickable.
*/
class GnssGroundFill extends Cesium.GroundPrimitive {
update(frameState) {
if (!frameState.passes.pick) super.update(frameState);
}
}
/** Low stays visible over land so coverage reads apart from no data. */
const FILL_ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 });
const LEVEL_LABELS = Object.freeze({
low: 'Low-accuracy score < 2%',
medium: 'Low-accuracy score 2–10%',
high: 'Low-accuracy score > 10%',
});
/** Degrees at the centre of the current view, or null before the camera settles. */
export function defaultGnssViewAnchor(viewer) {
const canvas = viewer?.scene?.canvas;
const ellipsoid = viewer?.scene?.globe?.ellipsoid;
// At globe scale the visible rectangle can be the whole world, whose
// midpoint is unrelated to the place under the screen centre.
const centre =
canvas?.clientWidth > 0 &&
canvas.clientHeight > 0 &&
viewer.camera?.pickEllipsoid?.(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
);
const rectangle =
!centre && viewer?.camera?.computeViewRectangle?.(ellipsoid);
const carto = centre
? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre)
: rectangle
? Cesium.Rectangle.center(rectangle)
: viewer?.camera?.positionCartographic;
if (!carto) return null;
const latitude = Cesium.Math.toDegrees(carto.latitude);
const longitude = Cesium.Math.toDegrees(carto.longitude);
return Number.isFinite(latitude) && Number.isFinite(longitude)
? { latitude, longitude }
: null;
}
/**
* Own one GNSS navigation-integrity display: rolling integrity observations
* around the views the user visits, binned into cells coloured by the
* gpsjam low-accuracy score. Share links retain token 3.
*/
export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
let _viewer = null;
let _primitives = null;
let _request = null;
let _enabled = false;
let _cells = [];
let _signature = null;
let _lastUpdate = null;
let _lastError = null;
let _stale = false;
let _manager = null;
let _cameraTimer = null;
let _backgroundRefresh = null;
let _lastQueryKey = null;
let _lastRequestAt = -Infinity;
let _removeCameraEnd = null;
let _removeCameraChanged = null;
let _coverage = null;
let _retryTimer = null;
let _retryAt = 0;
let _failureCount = 0;
const _observations = new Map();
const provenance = gnssProvenance({ windowMs });
const queryKey = (anchor) =>
anchor
? `${Math.round(anchor.latitude)},${Math.round(anchor.longitude)}`
: null;
function scheduleCameraRefresh() {
if (!_enabled || !_primitives) return;
if (now() < _retryAt) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
clearTimeout(_cameraTimer);
// Debounce drags and bound moving/follow cameras to at most 24 requests
// per minute. Nearby views share the proxy's whole-degree cache key.
_cameraTimer = setTimeout(
async () => {
_cameraTimer = null;
if (!_enabled || _backgroundRefresh || _request) return;
const key = queryKey(viewAnchor(_viewer));
if (!key || key === _lastQueryKey) return;
await refreshInBackground();
},
Math.max(600, 2500 - (monotonicNow() - _lastRequestAt)),
);
}
async function refreshInBackground() {
if (!_enabled || _backgroundRefresh) return;
const controller = new AbortController();
_backgroundRefresh = controller;
try {
if (_manager)
await _manager.refreshLayer(layer.id, { signal: controller.signal });
else await layer.update(undefined, { signal: controller.signal });
} finally {
if (_backgroundRefresh === controller) {
_backgroundRefresh = null;
scheduleCameraRefresh();
scheduleRetry();
}
}
}
function scheduleRetry() {
clearTimeout(_retryTimer);
_retryTimer = null;
if (!_enabled || !_retryAt) return;
// Long provider cooldowns keep their full deadline. Browser timers cannot
// exceed ~24 days, so wake in bounded chunks without fetching early.
_retryTimer = setTimeout(
() => {
_retryTimer = null;
void refreshInBackground();
},
Math.min(2147483647, Math.max(0, _retryAt - now())),
);
_retryTimer?.unref?.();
}
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
pruneGnssObservations(_observations, now(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
function render() {
// Only the id and band change what is drawn; counts alone do not.
const signature = JSON.stringify(
_cells.map(({ id, level }) => [id, level]),
);
if (signature === _signature) return;
_signature = signature;
_primitives.removeAll();
if (_cells.length) {
_primitives.add(
new GnssGroundFill({
geometryInstances: _cells.map(
(cell) =>
new Cesium.GeometryInstance({
id: `${layer.id}:${cell.id}`,
geometry: new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
cell.west,
cell.south,
cell.east,
cell.north,
),
vertexFormat: Cesium.PerInstanceColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
Cesium.Color.fromCssColorString(
GNSS_LEVEL_COLORS[cell.level],
).withAlpha(FILL_ALPHA[cell.level]),
),
},
}),
),
appearance: new Cesium.PerInstanceColorAppearance({
flat: true,
translucent: true,
}),
classificationType: Cesium.ClassificationType.BOTH,
allowPicking: false,
// Retain the compact rectangle descriptors for inspection, not Entity visualizers.
releaseGeometryInstances: false,
}),
);
}
_viewer?.scene?.requestRender?.();
}
const layer = {
id: 'gnss-integrity',
name: 'GNSS Integrity',
icon: '📡',
source: 'adsb.lol',
/** Where each part of the method comes from; see records.js. */
provenance,
updateInterval: 60000,
attachDataManager(manager) {
_manager = manager;
},
init(viewer) {
if (_viewer)
throw new Error('GNSS integrity layer is already initialized');
_viewer = viewer;
_primitives = new Cesium.PrimitiveCollection();
_primitives.id = layer.id;
_primitives.show = false;
viewer.scene.groundPrimitives.add(_primitives);
_removeCameraEnd = viewer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
_removeCameraChanged = viewer.camera?.changed?.addEventListener(
scheduleCameraRefresh,
);
},
enable() {
_enabled = true;
// Cells left from before a long disable must not reappear past the window.
if (_primitives) {
expire();
_primitives.show = true;
}
},
disable() {
clearTimeout(_cameraTimer);
_cameraTimer = null;
clearTimeout(_retryTimer);
_retryTimer = null;
_backgroundRefresh?.abort();
_backgroundRefresh = null;
_request?.abort();
_request = null;
_enabled = false;
if (_primitives) _primitives.show = false;
},
async update(_viewerArg, { signal } = {}) {
if (!_enabled || !_primitives || signal?.aborted) return false;
if (now() < _retryAt) {
expire();
scheduleRetry();
return true;
}
_retryAt = 0;
clearTimeout(_retryTimer);
_retryTimer = null;
const anchor = viewAnchor(_viewer);
// No anchor yet (camera still settling) is not a failure: returning
// false would make the manager reject the enable. The next interval
// retries; cells already drawn still age out of the window.
if (!anchor) {
expire();
return true;
}
_request?.abort();
const request = new AbortController();
_request = request;
const onAbort = () => request.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
_lastQueryKey = queryKey(anchor);
_lastRequestAt = monotonicNow();
_coverage = {
kind: 'regional',
latitude: Math.round(anchor.latitude),
longitude: Math.round(anchor.longitude),
radiusNm: 250,
};
let received = false;
try {
const snapshot = await source.getSnapshot(anchor, {
signal: request.signal,
});
if (request.signal.aborted || _request !== request || !_enabled)
return false;
received = true;
// Date the rows by when the proxy observed them: a cached or stale
// replay keeps its original age instead of restarting the window.
// The proxy reports that age, so client and server clocks never mix.
const at = now();
accumulateGnssObservations(_observations, snapshot.rows, at, {
windowMs,
observedAt: at - snapshot.ageMs,
});
_cells = binGnssCells(_observations);
render();
_stale = snapshot.stale === true;
_lastUpdate = at - snapshot.ageMs;
_lastError = null;
_retryAt = 0;
_failureCount = 0;
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// A renderer/programming failure remains a real lifecycle failure.
// Only acquisition failures are kept enabled for recovery.
if (received) throw e;
console.warn('[Data:GNSS] Fetch error:', e);
_lastError = e?.message || 'GNSS integrity source unavailable';
// Keep the last cells only while their evidence is inside the window.
expire();
if (e?.retryable === false) return false;
_failureCount = Math.min(3, _failureCount + 1);
const delay =
Number.isFinite(e?.retryAfterMs) && e.retryAfterMs > 0
? e.retryAfterMs
: Math.min(120000, 30000 * 2 ** (_failureCount - 1));
_retryAt = Math.max(
_retryAt,
Math.min(Number.MAX_VALUE, now() + delay),
);
// Enabling the display succeeded. Stats still report an unavailable
// or degraded feed; the manager keeps its normal refresh failure lane.
return true;
} finally {
signal?.removeEventListener('abort', onAbort);
if (_request === request) _request = null;
scheduleCameraRefresh();
scheduleRetry();
}
},
destroy(viewer = _viewer) {
layer.disable();
_removeCameraEnd?.();
_removeCameraChanged?.();
_removeCameraEnd = null;
_removeCameraChanged = null;
_observations.clear();
_cells = [];
_signature = null;
if (_primitives) {
viewer.scene.groundPrimitives.remove(_primitives);
_primitives = null;
}
_viewer = null;
_lastUpdate = null;
_lastError = null;
_stale = false;
_coverage = null;
_retryAt = 0;
_failureCount = 0;
_lastQueryKey = null;
_manager = null;
},
getRowControls() {
return {
chips: [],
info:
'Regional sample · 250 nm radius · cells from visited views over 30 min' +
(_lastError && _retryAt
? ` · ${_lastError} · Retrying automatically${
_retryAt <= 8640000000000000
? ` at ${new Date(_retryAt).toISOString().slice(11, 19)} UTC`
: ' after provider cooldown'
}`
: ''),
infoTitle:
'Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.',
legend: ['high', 'medium', 'low'].map((level, index) => ({
label: LEVEL_LABELS[level],
color: GNSS_LEVEL_COLORS[level],
count: _cells.filter((cell) => cell.level === level).length,
...(index === 0
? {
blurb: `Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need ${GNSS_MIN_AIRCRAFT} aircraft over ${provenance.window.minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.`,
}
: {}),
})),
};
},
getStats() {
return {
count: _cells.length,
levels: Object.fromEntries(
['low', 'medium', 'high'].map((level) => [
level,
_cells.filter((cell) => cell.level === level).length,
]),
),
lastUpdate: _lastUpdate,
error: _lastError,
retryInSec: _retryAt
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
: null,
stale: _stale,
provenance,
coverage: _coverage,
// Load-bearing: without an explicit boolean, layerFeedState reads the
// adsb.lol source name as a flights fallback (src/data/feedState.js).
fallback: false,
};
},
};
return layer;
}
+700
View File
@@ -0,0 +1,700 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
const row = (hex, lat, degraded) => ({
hex,
lat,
lon: 30.2,
nic: degraded ? 0 : 8,
nacp: degraded ? 0 : 10,
gpsLost: false,
degraded,
});
function harness(
getSnapshot,
{
anchor: initialAnchor = { latitude: 50, longitude: 30 },
initialize = true,
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
// The real source refuses an undated snapshot; stubs default to a fresh one.
source: {
async getSnapshot(...args) {
const snapshot = await getSnapshot(...args);
return { ageMs: 0, ...snapshot };
},
},
viewAnchor: () => {
anchors.push(anchor);
return anchor;
},
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
return {
layer,
sources,
anchors,
viewer,
advance(ms) {
clock += ms;
},
now: () => clock,
setAnchor(value) {
anchor = value;
},
};
}
const MIN = 60_000;
const threeHealthy = () => [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
];
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
});
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
});
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls, 2, 'one initial read and one read for the latest view');
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
assert.ok(changes.includes('refresh-transition'));
assert.ok(changes.includes('refresh'));
assert.equal(manager.isEnabled(h.layer.id), true);
assert.deepEqual(h.layer.getStats().coverage, {
kind: 'regional',
latitude: 55,
longitude: 20,
radiusNm: 250,
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolvePending;
const h = harness((anchor) => {
calls.push(anchor);
if (calls.length === 2)
return new Promise((resolve) => {
resolvePending = resolve;
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 2, 'only one camera request in flight');
resolvePending({ rows: threeHealthy() });
await flush();
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls.length, 3, 'disable drains the camera timer');
h.layer.destroy();
});
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
rows: [
row('a1', 50.1, true),
row('a2', 50.1, true),
row('a3', 50.2, false),
row('a4', 50.3, false),
row('b1', 55.1, false),
],
stale: false,
};
});
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(await layer.update(), true);
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
assert.equal(stats.fallback, false);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ count }) => count),
[1, 0, 0],
);
assert.match(legend[0].blurb, /not a detection/);
assert.match(legend[0].blurb, /need 3 aircraft/);
});
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
const { layer } = harness(async () => ({ rows: [], stale: false }));
assert.equal(
layer.id,
'gnss-integrity',
'the unpublished id describes the measured state',
);
assert.equal(layer.name, 'GNSS Integrity');
assert.doesNotMatch(layer.name, /interference|jamming/i);
const { legend } = layer.getRowControls();
assert.deepEqual(
legend.map(({ label }) => label),
[
'Low-accuracy score > 10%',
'Low-accuracy score 2–10%',
'Low-accuracy score < 2%',
],
);
const { blurb } = legend[0];
assert.match(blurb, /one aircraft is discounted/);
assert.match(blurb, /not the raw share/);
assert.match(blurb, /not a detection/);
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
assert.equal(layer.provenance.classifier.validated, false);
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
assert.deepEqual(layer.provenance.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(layer.provenance.interpretation.validated, false);
assert.equal(layer.getStats().provenance, layer.provenance);
});
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
row('a1', 1.1, false),
row('a2', 1.2, false),
row('a3', 1.3, false),
],
stale: true,
};
});
layer.enable();
await layer.update();
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
signal.addEventListener('abort', () => reject(signal.reason));
}),
);
layer.enable();
let result = layer.update();
pending.resolve({
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
});
assert.equal(await result, true);
assert.equal(layer.getStats().count, 1);
advance(31 * 60_000);
result = layer.update();
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
let calls = 0;
const { layer, sources } = harness(
async () => {
calls += 1;
return { rows: [] };
},
{ anchor: null },
);
layer.enable();
assert.equal(
await layer.update(),
true,
'a settling camera is not a failure',
);
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance, now } = harness(async () => ({
rows: threeHealthy(),
ageMs: now() - 1_000,
stale: true,
}));
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(29 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(2 * MIN);
assert.equal(
await layer.update(),
true,
'the replay is not an error, only no evidence',
);
assert.equal(
layer.getStats().count,
0,
'the replay did not restart the window',
);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
fail = true;
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1, 'still inside the window');
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy() };
});
layer.enable();
await layer.update();
setAnchor(null);
advance(20 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(layer.getStats().error, null);
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance } = harness(async () => ({
rows: threeHealthy(),
}));
layer.enable();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
layer.enable();
await layer.update();
const first = sources[0].get(0);
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
let calls = 0;
const h = harness(
async () => {
calls++;
if (calls === 1) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((c) => changes.push(c.type));
assert.equal(await manager.setEnabled(h.layer.id, true), true);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
assert.equal(h.layer.getStats().retryInSec, 30);
assert.match(
h.layer.getRowControls().info,
/HTTP 502.*Retrying automatically at/,
);
assert.equal(changes.includes('visibility-failed'), false);
assert.equal(
await manager.refreshLayer(h.layer.id),
false,
'feed remains honestly unhealthy during cooldown',
);
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(calls, 2);
assert.equal(manager.isEnabled(h.layer.id), true);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
assert.equal(h.layer.getStats().count, 1);
assert.equal(h.layer.getStats().retryInSec, null);
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
await manager.destroyAll();
});
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
if (calls === 1)
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
retryAfterMs: 3600000,
});
return { rows: threeHealthy() };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 10, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
await h.layer.update();
assert.equal(calls, 1);
assert.equal(h.layer.getStats().retryInSec, 3570);
h.layer.disable();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
h.layer.enable();
await h.layer.update();
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
h.advance(3540000);
t.mock.timers.tick(3540000);
await flush();
assert.equal(calls, 2);
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
h.layer.destroy();
const stopped = harness(async () => {
throw new Error('network unavailable');
});
stopped.layer.enable();
await stopped.layer.update();
stopped.layer.disable();
stopped.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
stopped.layer.destroy();
});
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
throw new Error('network unavailable');
});
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().retryInSec, 30);
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 60);
h.advance(60000);
t.mock.timers.tick(60000);
await flush();
assert.equal(h.layer.getStats().retryInSec, 120);
assert.equal(calls, 3);
h.layer.destroy();
const fatal = harness(async () => {
throw Object.assign(new Error('invalid request (HTTP 400)'), {
retryable: false,
});
});
fatal.layer.enable();
assert.equal(await fatal.layer.update(), false);
assert.equal(fatal.layer.getStats().retryInSec, null);
fatal.layer.destroy();
});
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
const h = harness(async () => ({
rows: threeHealthy(),
ageMs: 120000,
stale: true,
}));
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
h.layer.destroy();
});
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const h = harness(async () => {
calls++;
return { rows: [] };
});
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
assert.equal(calls, 1);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
assert.equal(calls, 2);
h.advance(1);
t.mock.timers.tick(1);
await flush();
assert.equal(calls, 3);
h.layer.destroy();
});
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const calls = [];
let resolve;
const h = harness((anchor) => {
calls.push(anchor);
return calls.length === 1
? new Promise((r) => (resolve = r))
: Promise.resolve({ rows: threeHealthy() });
});
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
assert.equal(calls.length, 1);
resolve({ rows: threeHealthy() });
assert.equal(await initial, true);
h.advance(600);
t.mock.timers.tick(600);
await flush();
assert.equal(calls.length, 2);
h.layer.destroy();
});
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const h = harness(async () => ({ rows: [] }));
h.layer.destroy();
let wall = 1800000000000,
mono = 0,
anchor = { latitude: 50, longitude: 30 },
calls = 0;
const layer = createGnssIntegrityLayer({
source: {
getSnapshot: async () => {
calls++;
return { rows: [], ageMs: 0 };
},
},
viewAnchor: () => anchor,
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
assert.equal(calls, 2);
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
});
+318
View File
@@ -0,0 +1,318 @@
/**
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
*
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
* their own position: NIC (integrity containment radius) and NACp (position
* accuracy). A cluster of aircraft reporting low values in one area is a
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
* such clusters are a well-known public proxy for GNSS interference, but
* avionics faults and old transponders also report low values: suspected
* interference is a second, unvalidated interpretation, never the primary
* state.
*
* Three separately sourced parts make up the method (see `gnssProvenance`):
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
* flag. gpsjam.org does not publish its per-aircraft threshold.
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
*/
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
const QUALIFYING_VERSIONS = new Set([1, 2]);
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
export const GNSS_NIC_THRESHOLD = 7;
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
export const GNSS_NACP_THRESHOLD = 8;
/** Positions older than this are not current evidence of the local sky. */
const MAX_POSITION_AGE_S = 60;
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
export const GNSS_CELL_DEG = 0.5;
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
export const GNSS_MIN_AIRCRAFT = 3;
/** Rolling window over which aircraft seen in a cell are counted. */
export const GNSS_WINDOW_MS = 30 * 60_000;
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
export const GNSS_MEDIUM_PERCENT = 2;
export const GNSS_HIGH_PERCENT = 10;
/** Primary cell state: what the data shows (low navigation accuracy). */
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
export const GNSS_NOMINAL_STATE = 'nominal';
/** Secondary, unvalidated reading of an anomaly cell. */
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
/** The per-aircraft rule; GEV-defined and not validated against known events. */
export const GNSS_CLASSIFIER = Object.freeze({
id: 'gev-nic-nacp-v2',
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
/**
* Machine-readable provenance of the cell output: which part of the method
* comes from where, and which parts are unvalidated.
*
* @param {{windowMs?: number}} [options] The window the layer actually uses.
*/
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
return Object.freeze({
classifier: GNSS_CLASSIFIER,
aggregation: Object.freeze({
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
}),
window: Object.freeze({
minutes: windowMs / 60_000,
scope: 'visited-view',
cellDeg: GNSS_CELL_DEG,
minAircraft: GNSS_MIN_AIRCRAFT,
definedBy: 'GEV',
}),
state: Object.freeze({
id: GNSS_ANOMALY_STATE,
meaning: 'low navigation accuracy reported by ADS-B aircraft',
}),
interpretation: Object.freeze({
id: GNSS_INTERPRETATION,
validated: false,
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
}),
});
}
/** Provenance at the default window. */
export const GNSS_PROVENANCE = gnssProvenance();
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
/**
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
* facts the layer needs. Returns null when the payload has no aircraft array.
*
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
*/
export function normalizeGnssAircraft(payload) {
if (!Array.isArray(payload?.ac)) return null;
const rows = [];
const seen = new Set();
for (const aircraft of payload.ac) {
if (!aircraft || typeof aircraft !== 'object') continue;
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
let { lat, lon } = aircraft;
const derived = (field) =>
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
const nacp =
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
if (!finite(lat) || Math.abs(lat) > 90) continue;
if (!finite(lon) || Math.abs(lon) > 180) continue;
// Only a position confirmed within the last minute is current evidence; a
// missing, invalid or negative age confirms nothing.
const age = aircraft.seen_pos;
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
// readsb exposes the flag while GPS is lost (15 s–15 min after the
// last good position), and removes it on recovery. Locate that evidence
// at the last good GNSS position, rather than a later MLAT estimate.
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
if (nic === null && nacp === null && !gpsLost) continue;
if (
gpsLost &&
finite(aircraft.gpsOkLat) &&
Math.abs(aircraft.gpsOkLat) <= 90 &&
finite(aircraft.gpsOkLon) &&
Math.abs(aircraft.gpsOkLon) <= 180
) {
lat = aircraft.gpsOkLat;
lon = aircraft.gpsOkLon;
}
seen.add(hex);
rows.push({
hex,
lat,
lon,
nic,
nacp,
gpsLost,
degraded:
gpsLost ||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
(nic !== null &&
nic < GNSS_NIC_THRESHOLD &&
!(nic === 0 && nacp !== null && nacp >= 10)),
});
}
return rows;
}
/** Stable key of the cell containing a position. */
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
return `${row}:${col}`;
}
/**
* Fold one snapshot into a rolling observation store. Each aircraft counts
* once per cell per window. Presence and degraded evidence age separately: a
* degraded report keeps the aircraft degraded in that cell for one window
* after the report itself, however many healthy reports follow, so an
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
* is not held degraded forever.
*
* Every row is dated by when the snapshot was observed, not when it arrived:
* replaying a cached or stale snapshot never makes its evidence younger, and
* a snapshot older than the window adds nothing.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
* @param {number} nowMs
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
* @returns {Map} The same store, pruned to the window.
*/
export function accumulateGnssObservations(
store,
rows,
nowMs,
{
windowMs = GNSS_WINDOW_MS,
cellDeg = GNSS_CELL_DEG,
observedAt = nowMs,
} = {},
) {
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
if (Number.isFinite(at) && nowMs - at <= windowMs) {
for (const row of rows || []) {
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
const key = `${row.hex}|${cell}`;
const previous = store.get(key);
const previousDegradedAt = previous?.degradedAt ?? null;
store.set(key, {
cell,
seenAt: Math.max(previous?.seenAt ?? at, at),
degradedAt: row.degraded
? Math.max(previousDegradedAt ?? at, at)
: previousDegradedAt,
});
}
}
return pruneGnssObservations(store, nowMs, { windowMs });
}
/**
* Drop observations older than the window and degraded evidence older than
* the window, without adding anything. Used when no fresh snapshot arrives.
*
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
* @param {number} nowMs
* @param {{windowMs?: number}} [options]
* @returns {Map} The same store.
*/
export function pruneGnssObservations(
store,
nowMs,
{ windowMs = GNSS_WINDOW_MS } = {},
) {
for (const [key, entry] of store) {
if (nowMs - entry.seenAt > windowMs) store.delete(key);
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
store.set(key, { ...entry, degradedAt: null });
}
return store;
}
/**
* Navigation-integrity level of a cell, using gpsjam.org's published cell
* formula and bands: the first degraded aircraft is discounted to limit false
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
* high cells are navigation-integrity anomalies.
*/
export function gnssIntegrityLevel(total, degraded) {
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
const level =
percent > GNSS_HIGH_PERCENT
? 'high'
: percent >= GNSS_MEDIUM_PERCENT
? 'medium'
: 'low';
const anomaly = level !== 'low';
return {
percent,
level,
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
interpretation: anomaly ? GNSS_INTERPRETATION : null,
};
}
/**
* Aggregate the observation store into displayable cells. Expects a store
* already pruned to the window by `accumulateGnssObservations` or
* `pruneGnssObservations`.
*
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
*/
export function binGnssCells(
store,
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
) {
const tallies = new Map();
for (const { cell, degradedAt } of store.values()) {
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
tally.aircraft += 1;
if (degradedAt != null) tally.degraded += 1;
tallies.set(cell, tally);
}
const cells = [];
for (const [id, { aircraft, degraded }] of tallies) {
if (aircraft < minAircraft) continue;
const [row, col] = id.split(':').map(Number);
const south = row * cellDeg - 90;
const west = col * cellDeg - 180;
const { percent, level, state, interpretation } = gnssIntegrityLevel(
aircraft,
degraded,
);
cells.push({
id,
south,
west,
north: south + cellDeg,
east: west + cellDeg,
lat: south + cellDeg / 2,
lon: west + cellDeg / 2,
aircraft,
degraded,
percentDegraded: Math.round(percent * 10) / 10,
level,
state,
interpretation,
});
}
return cells.sort((a, b) => a.id.localeCompare(b.id));
}
/** Legend colors: gpsjam's green / amber / red convention. */
export const GNSS_LEVEL_COLORS = Object.freeze({
low: '#3fb950',
medium: '#e3b341',
high: '#f85149',
});
+491
View File
@@ -0,0 +1,491 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accumulateGnssObservations,
binGnssCells,
GNSS_CLASSIFIER,
GNSS_PROVENANCE,
GNSS_WINDOW_MS,
gnssCellKey,
gnssIntegrityLevel,
gnssProvenance,
normalizeGnssAircraft,
pruneGnssObservations,
} from './records.js';
const aircraft = (overrides = {}) => ({
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 8,
nac_p: 10,
seen_pos: 1.2,
...overrides,
});
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft(),
aircraft({
hex: '000001',
type: 'mlat',
nic: undefined,
nac_p: undefined,
}),
aircraft({ hex: '000002', type: 'tisb_icao' }),
aircraft({ hex: '000003', version: 0 }),
aircraft({ hex: '000004', alt_baro: 'ground' }),
aircraft({ hex: '000005', seen_pos: 120 }),
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
aircraft({ hex: '000007', lat: 95 }),
aircraft({ hex: '000008', version: undefined }),
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
aircraft({ hex: '000009', version: 3 }),
aircraft({ hex: '00000a', version: '2' }),
aircraft({ hex: '00000b', version: 1.5 }),
// A position is current only when its age is known, finite and 0..60 s.
aircraft({ hex: '00000c', seen_pos: undefined }),
aircraft({ hex: '00000d', seen_pos: null }),
aircraft({ hex: '00000e', seen_pos: -0.5 }),
aircraft({ hex: '00000f', seen_pos: '1.2' }),
aircraft({ hex: '000010', seen_pos: Number.NaN }),
aircraft({ hex: '000011', seen_pos: 60.1 }),
aircraft({ hex: 'not-hex' }),
aircraft(),
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
aircraft({ hex: '~a1b2c3' }),
// Version 1, and both ends of the position-age range, qualify.
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
aircraft({ hex: 'b00002', seen_pos: 60 }),
null,
],
});
assert.deepEqual(rows, [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
{
hex: '~a1b2c3',
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
},
...['b00001', 'b00002'].map((hex) => ({
hex,
lat: 50.1,
lon: 19.9,
nic: 8,
nacp: 10,
gpsLost: false,
degraded: false,
})),
]);
assert.equal(normalizeGnssAircraft({}), null);
assert.equal(normalizeGnssAircraft(null), null);
});
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nic: 6 }),
aircraft({ hex: 'a00002', nac_p: 7 }),
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
],
});
assert.deepEqual(
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
[
['a00001', true, false],
['a00002', true, false],
['a00003', true, true],
['a00004', false, false],
],
);
});
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
assert.equal(gnssCellKey(-90, -180), '0:0');
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
});
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
const nominal = {
percent: 0,
level: 'low',
state: 'nominal',
interpretation: null,
};
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
// One degraded aircraft is always discounted.
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
});
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
for (const [total, bad] of [
[50, 2],
[10, 3],
]) {
const { state, interpretation } = gnssIntegrityLevel(total, bad);
assert.equal(state, 'navigation-integrity-anomaly');
assert.equal(interpretation, 'suspected-gnss-interference');
}
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
],
0,
);
const [cell] = binGnssCells(store);
assert.equal(cell.level, 'high');
assert.equal(cell.state, 'navigation-integrity-anomaly');
assert.equal(cell.interpretation, 'suspected-gnss-interference');
for (const key of Object.keys(cell))
assert.doesNotMatch(key, /interference|jamming|spoof/i);
});
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
assert.deepEqual(GNSS_PROVENANCE.classifier, {
id: 'gev-nic-nacp-v2',
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
definedBy: 'GEV',
validated: false,
});
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
id: 'gpsjam-cell-bands',
formula: '100 * max(0, bad - 1) / total',
bands: [2, 10],
source: 'https://gpsjam.org/faq',
definedBy: 'gpsjam.org',
});
assert.deepEqual(GNSS_PROVENANCE.window, {
minutes: 30,
scope: 'visited-view',
cellDeg: 0.5,
minAircraft: 3,
definedBy: 'GEV',
});
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
assert.equal(
GNSS_PROVENANCE.interpretation.id,
'suspected-gnss-interference',
);
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
// The bands in provenance are the ones the level function applies.
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
});
test('observations count each aircraft once per cell and expire after the window', () => {
const store = new Map();
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
accumulateGnssObservations(
store,
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
0,
{ windowMs: 1000 },
);
// a1 recovers inside the same cell: it stays degraded for the window.
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
windowMs: 1000,
});
let [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 3);
assert.equal(cell.degraded, 1);
assert.equal(cell.level, 'low');
assert.deepEqual(
[cell.south, cell.west, cell.north, cell.east],
[50, 20, 50.5, 20.5],
);
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
assert.equal(store.size, 1);
assert.deepEqual(binGnssCells(store), []);
});
const MIN = 60_000;
const report = (hex, degraded, lat = 50.1) => ({
hex,
lat,
lon: 20.1,
degraded,
});
const entry = (store, hex) =>
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 keeps reporting healthy every minute in the same cell.
for (let minute = 1; minute <= 30; minute += 1)
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
assert.equal(
entry(store, 'a1').degradedAt,
0,
'healthy reports do not refresh the degraded evidence',
);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// A new degraded report starts its own window.
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
});
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
const store = new Map();
const healthy = [report('a2', false), report('a3', false)];
accumulateGnssObservations(
store,
[report('a1', true), report('b1', true), ...healthy],
0,
);
assert.equal(binGnssCells(store)[0].level, 'high');
for (let minute = 10; minute <= 30; minute += 10)
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
minute * MIN,
);
assert.equal(binGnssCells(store)[0].degraded, 2);
accumulateGnssObservations(
store,
[report('a1', false), report('b1', false), ...healthy],
31 * MIN,
);
const [cell] = binGnssCells(store);
assert.equal(cell.aircraft, 4);
assert.equal(cell.degraded, 0);
assert.equal(cell.level, 'low');
});
test('an aircraft returning after the window starts fresh', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
// a1 leaves; nothing is heard from it for longer than the window.
accumulateGnssObservations(store, [], 31 * MIN);
assert.equal(store.size, 0);
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 32 * MIN,
degradedAt: null,
});
// Returning degraded dates the evidence from the new report.
const again = new Map();
accumulateGnssObservations(again, [report('a9', true)], 0);
accumulateGnssObservations(again, [], 31 * MIN);
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
});
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
// No prune runs in between: the old entry is still in the store when the
// healthy report arrives, so the new report alone must not carry it over.
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
assert.deepEqual(entry(store, 'a1'), {
cell: gnssCellKey(50.1, 20.1),
seenAt: 31 * MIN,
degradedAt: null,
});
// Exactly one window after the degraded report it still counts.
const edge = new Map();
accumulateGnssObservations(edge, [report('a1', true)], 0);
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
assert.equal(entry(edge, 'a1').degradedAt, 0);
});
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
const store = new Map();
const snapshot = [report('a1', true), report('a2', false)];
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
// The same snapshot replayed (cached or stale) 29 minutes later.
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
assert.equal(entry(store, 'a1').seenAt, 0);
assert.equal(entry(store, 'a1').degradedAt, 0);
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
// An older replay never moves an entry's age backwards either.
accumulateGnssObservations(store, snapshot, 40 * MIN);
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
observedAt: 35 * MIN,
});
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
// A future time is clamped to now; an undated snapshot adds nothing.
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
observedAt: 99 * MIN,
});
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
for (const observedAt of [null, Number.NaN])
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
observedAt,
});
assert.equal(entry(store, 'u1'), undefined);
});
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
const store = new Map();
accumulateGnssObservations(store, [report('a1', true)], 0);
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
accumulateGnssObservations(store, [report('a2', false)], 0);
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
assert.deepEqual(
[...store.keys()].map((key) => key.split('|')[0]),
['a1'],
);
assert.equal(entry(store, 'a1').degradedAt, null);
pruneGnssObservations(store, 51 * MIN);
assert.equal(store.size, 0);
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
});
test('rows without finite coordinates never key a cell', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
null,
],
0,
);
assert.equal(store.size, 0);
});
test('cells below the minimum aircraft count are withheld', () => {
const store = new Map();
accumulateGnssObservations(
store,
[
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
],
0,
);
assert.deepEqual(binGnssCells(store), []);
const [cell] = binGnssCells(store, { minAircraft: 2 });
assert.equal(cell.level, 'high');
assert.equal(cell.percentDegraded, 50);
});
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
const [row] = normalizeGnssAircraft({
ac: [
aircraft({
type: 'mlat',
nac_p: 0,
nic: 0,
mlat: ['lat', 'lon', 'nic'],
gpsOkBefore: 1800000000,
gpsOkLat: 54.2,
gpsOkLon: 19.3,
}),
],
});
assert.ok(row);
assert.equal(row.lat, 54.2);
assert.equal(row.lon, 19.3);
assert.equal(row.nic, null);
assert.equal(row.degraded, true);
const [healthy] = normalizeGnssAircraft({
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
});
assert.equal(healthy.degraded, false);
});
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
const rows = normalizeGnssAircraft({
ac: [
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
],
});
assert.deepEqual(
rows.map((r) => r.degraded),
[false, false, false, true, true],
);
});
// Quality-field subsets captured from public adsb.lol US point responses on
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
const tisbQuality = {
nic: 0,
nac_p: 0,
tisb: [
'altitude',
'alt_baro',
'gs',
'track',
'geom_rate',
'lat',
'lon',
'nic',
'rc',
'nac_p',
'nac_v',
'sil',
'sil_type',
],
mlat: [],
};
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
const unknownContainment = {
nic: 0,
nac_p: 10,
tisb: ['callsign'],
mlat: [],
};
assert.equal(
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
false,
);
});
+76
View File
@@ -0,0 +1,76 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
function retryDelay(raw, nowMs) {
if (!raw) return null;
const seconds = Number(raw);
const ms = seconds * 1000;
if (Number.isFinite(ms) && seconds > 0)
return Math.min(3600000, Math.max(5000, ms));
const at = Date.parse(raw);
return Number.isFinite(at) && at > nowMs
? Math.min(3600000, Math.max(5000, at - nowMs))
: null;
}
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
export function createAdsbGnssSource({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
return {
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
signal?.throwIfAborted();
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
throw new Error('GNSS snapshot needs a view anchor');
const params = new URLSearchParams({
lat: String(Math.round(latitude)),
lon: String(Math.round(longitude)),
});
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
signal,
});
if (!response.ok) {
const limited = response.status === 429;
const retryAfterMs = retryDelay(
response.headers.get('retry-after'),
now(),
);
throw Object.assign(
new Error(
limited
? 'GNSS feed rate limited (HTTP 429)'
: `GNSS feed unavailable (HTTP ${response.status})`,
),
{
status: response.status,
retryable: limited || response.status >= 500,
retryAfterMs: retryAfterMs ?? (limited ? 60000 : null),
},
);
}
const payload = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed GNSS integrity snapshot');
// The snapshot's age (measured on the proxy's clock) dates every row in
// the rolling window; an undated snapshot could only be treated as new,
// so it is refused.
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
throw new Error('GNSS integrity snapshot has no observation time');
return {
rows: payload.rows,
ageMs: payload.ageMs,
fetchedAt: Number.isFinite(payload.fetchedAt)
? payload.fetchedAt
: null,
stale: payload.stale === true,
classifier:
typeof payload.classifier === 'string' ? payload.classifier : null,
};
},
};
}
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAdsbGnssSource } from './source.js';
test('the source asks the same-origin proxy for the view anchor', async () => {
const calls = [];
const source = createAdsbGnssSource({
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
return Response.json({
fetchedAt: 5,
ageMs: 0,
classifier: 'gev-nic-nacp-v2',
rows: [{ hex: 'abc123' }],
});
},
});
const snapshot = await source.getSnapshot(
{ latitude: 50.06143, longitude: 19.93658 },
{ signal: new AbortController().signal },
);
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
assert.deepEqual(snapshot, {
rows: [{ hex: 'abc123' }],
ageMs: 0,
fetchedAt: 5,
stale: false,
classifier: 'gev-nic-nacp-v2',
});
});
test('the source reports stale proxy data and rejects bad input or responses', async () => {
const reply = (body, init) => async () => Response.json(body, init);
const stale = await createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
}).getSnapshot({ latitude: 1, longitude: 2 });
assert.equal(stale.stale, true);
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
assert.equal(stale.fetchedAt, 7);
assert.equal(stale.classifier, null);
// Without an observation age the rows could only be dated as new.
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/no observation time/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
/view anchor/,
);
await assert.rejects(
createAdsbGnssSource({
fetchImpl: reply({ error: 'x' }, { status: 502 }),
}).getSnapshot({ latitude: 1, longitude: 2 }),
/HTTP 502/,
);
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
latitude: 1,
longitude: 2,
}),
/Malformed/,
);
const controller = new AbortController();
controller.abort();
await assert.rejects(
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
{ latitude: 1, longitude: 2 },
{ signal: controller.signal },
),
);
});
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
const at = Date.UTC(2026, 9, 13, 12);
for (const [status, header, delay, retryable] of [
[429, '3600', 3600000, true],
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
[429, null, 60000, true],
[429, 'Infinity', 60000, true],
[502, '5', 5000, true],
[502, null, null, true],
[400, null, null, false],
]) {
const source = createAdsbGnssSource({
now: () => at,
fetchImpl: async () =>
Response.json(
{},
{ status, headers: header ? { 'Retry-After': header } : {} },
),
});
await assert.rejects(
source.getSnapshot({ latitude: 50, longitude: 20 }),
(e) => {
assert.equal(e.status, status);
assert.equal(e.retryAfterMs, delay);
assert.equal(e.retryable, retryable);
assert.match(
e.message,
status === 429 ? /rate limited/ : /feed unavailable/,
);
return true;
},
);
}
});
+6
View File
@@ -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
+22 -1
View File
@@ -9,6 +9,7 @@ import {
CONTACT_MATCH_TIER,
} from '../../data/contactMatch.js';
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
export function createQueries({
flightState,
@@ -133,6 +134,10 @@ 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,
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
};
}
@@ -156,7 +161,9 @@ export function createQueries({
* lon: number|null, altitudeM: number|null, speedMps: number|null,
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
* military: boolean, aircraftClass: string|null, originCountry: null,
* operator: string|null, routeOrigin: null, routeDestination: null}}
* operator: string|null, squawk: string|null, emergency: boolean,
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: null, routeDestination: null}}
*/
function mapAnalystRecord(icao24, info) {
@@ -187,6 +194,20 @@ export function createQueries({
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
originCountry: null,
operator: text(info?.operator),
squawk: text(info?.squawk),
// True while this poll broadcast an emergency (7500/7600/7700 or the
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
// without the flag.
emergency: info?.emergency?.severity === 'emergency',
emergencyKind: text(info?.emergency?.kind),
// Provenance, so a summary can say "broadcasting squawk 7700" rather
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
// when the Mode A code names the status, 'ads-b' for the readsb field.
emergencySource: text(info?.emergency?.source),
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
// Latest known general aircraft-message contact, retained if a poll
// has no time. This is not the receipt time of the current status.
lastContactAtMs: num(info?.lastContactEpochMs),
routeOrigin: null,
routeDestination: null,
};
+4
View File
@@ -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,
+16
View File
@@ -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);
});
+19 -4
View File
@@ -18,6 +18,7 @@ import {
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import { isEmergency } from '../../data/aircraftEmergency.js';
import {
PLANE_MODEL_SCALE,
PLANE_MODEL_URL,
@@ -33,6 +34,7 @@ import {
COCKPIT_CONTACT_SIZE_PX,
MIL_ICON_COLOR,
TRACKED_ICON_COLOR,
EMERGENCY_ICON_COLOR,
MODEL_ALT_CEIL_M,
MODEL_MAX_ALL,
MODEL_MAX,
@@ -159,7 +161,7 @@ export function createRendering({
bb.height = COCKPIT_CONTACT_SIZE_PX;
bb.scale = limbScale;
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
bb.rotation = 0;
return;
}
@@ -173,7 +175,9 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
cyberSonarBaseAlpha(bb),
);
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -189,7 +193,17 @@ export function createRendering({
function _modelColor(icao24) {
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
return MIL_ICON_COLOR;
return _fleetBillboardColor(icao24);
}
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
* amber otherwise. Cockpit far-contact dots use it too. */
function _fleetBillboardColor(
icao24,
info = flightState.records.data.get(icao24),
) {
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
}
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
@@ -965,7 +979,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const baseColor = _fleetBillboardColor(icao24, info);
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -1082,6 +1096,7 @@ export function createRendering({
_applyFleetBillboardPresentation,
_iconKind,
_modelColor,
_fleetBillboardColor,
_modelRegimeActive,
_modelCap,
_modelAddDistM,
+10 -7
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import {
turnRateFromFixHistory,
@@ -7,12 +8,7 @@ import {
} from '../../data/motionModel.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
import {
BILLBOARD_SCALE,
GROUND_SCALE,
TRACKED_ICON_COLOR,
MIL_ICON_COLOR,
} from './policy.js';
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
/** Apply military record changes to Cesium resources and existing follow operations. */
export function createMilitarySnapshotRenderer({
@@ -125,6 +121,11 @@ export function createMilitarySnapshotRenderer({
if (flightState._billboards.has(icao24)) {
const bb = flightState._billboards.get(icao24);
if (!isTracked) {
bb.color = rendering
._fleetBillboardColor(icao24, meta)
.withAlpha(cyberSonarBaseAlpha(bb));
}
// Position AND rotation are owned by the fleet pass (_fleetTick);
// course changes land on the next rotation pass (forced below).
// Ground flips (landing/takeoff) restyle this SAME billboard in
@@ -161,7 +162,9 @@ export function createMilitarySnapshotRenderer({
// Screen-projected rotation lands on the next fleet tick.
rotation: 0,
alignedAxis: Cesium.Cartesian3.ZERO,
color: isTracked ? TRACKED_ICON_COLOR : MIL_ICON_COLOR,
color: isTracked
? TRACKED_ICON_COLOR
: rendering._fleetBillboardColor(icao24, meta),
sizeInMeters: false,
scaleByDistance: rendering._normalBillboardScaleByDistance(),
// Grounded/near-surface planes sit at/below the photoreal tile
+6 -3
View File
@@ -21,7 +21,6 @@ import {
TRAIL_COLOR,
BILLBOARD_SCALE,
GROUND_SCALE,
MIL_ICON_COLOR,
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
@@ -780,12 +779,16 @@ export function createTracking({
bb.width = 20;
bb.height = 20;
// Ground-aware restore (a plane untracked while taxiing comes back at
// ground scale; tint is full amber on the ground and in the air).
// ground scale; the fleet tint is amber, or red while broadcasting an
// emergency, on the ground and in the air).
bb.scale =
BILLBOARD_SCALE *
(CLASS_SCALE_2D[meta?.klass] || 1) *
(meta?.onGround ? GROUND_SCALE : 1);
bb.color = MIL_ICON_COLOR;
bb.color = parts.rendering._fleetBillboardColor(
flightState._trackedIcao,
meta,
);
bb.rotation = flightState._lastTrackedRotation;
}
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxCentre,
boxFromRectangle,
dequantizeBox,
validateBox,
} from './model.js';
import { searchHls } from './catalog.js';
test('missing imagery edges do not turn into invented zero coordinates', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
const box = { west: value, south: 0, east: 1, north: 1 };
assert.equal(validateBox(box).ok, false);
assert.equal(validateBox(box).reason, 'invalid');
assert.equal(boxCentre(box), null);
}
assert.equal(validateBox({ west: 0, south: 0, east: 1, north: 1 }).ok, true);
assert.equal(
validateBox({ west: '0', south: '0', east: '1', north: '1' }).ok,
true,
);
});
test('every edge and every box entry point refuses a missing edge', async () => {
const blanks = [null, undefined, '', ' ', false, [], {}];
for (const edge of ['west', 'south', 'east', 'north']) {
for (const value of blanks) {
const box = { west: 1, south: 1, east: 2, north: 2, [edge]: value };
assert.equal(validateBox(box).ok, false, `${edge}=${String(value)}`);
// Share-link integers and Cesium radians are converted per edge.
assert.equal(dequantizeBox(box), null, `share ${edge}`);
assert.equal(boxFromRectangle(box), null, `radians ${edge}`);
}
}
let fetched = false;
await assert.rejects(
searchHls({
box: { west: ' ', south: 1, east: 2, north: 2 },
fetchImpl: async () => {
fetched = true;
},
}),
TypeError,
);
assert.equal(fetched, false);
assert.ok(dequantizeBox({ west: 0, south: 0, east: 100000, north: 100000 }));
});
+19 -6
View File
@@ -75,6 +75,14 @@ const pad2 = (value) => String(value).padStart(2, '0');
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
/** A box edge as a number, or null when it is missing, blank or not numeric. */
function edgeNumber(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || !value.trim()) return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
@@ -82,8 +90,8 @@ function normalizeBox(box) {
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
].map(edgeNumber);
if ([west, south, east, north].some((edge) => edge == null)) return null;
return {
west,
south: Math.min(south, north),
@@ -211,11 +219,16 @@ export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
// Converting first would turn a blank edge into 0 before normalizeBox sees it.
const edge = (value) => {
const number = edgeNumber(value);
return number == null ? null : fn(number);
};
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
west: edge(box.west),
south: edge(box.south),
east: edge(box.east),
north: edge(box.north),
});
}
@@ -0,0 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { tilesForBbox } from './tileMath.js';
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
const bbox = [-10, 60, -20, 85];
const result = tilesForBbox(bbox, 3);
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(keys.length, 24);
assert.equal(new Set(keys).size, 24);
assert.equal(result.total, 24);
assert.equal(result.truncated, false);
const overview = tilesForBbox(bbox, 3, { limit: 16 });
assert.equal(overview.total, 24);
assert.equal(overview.tiles.length, 16);
assert.equal(
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
16,
);
assert.equal(overview.truncated, true);
});
test('a limit that fits every distinct tile is not spent on repeats', () => {
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
// 24-slot overview held repeats and still reported itself truncated.
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(new Set(keys).size, 24);
assert.equal(result.truncated, false);
});
+10 -2
View File
@@ -146,8 +146,16 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
// The date-line halves share a column when the gap between east and west is
// narrower than a tile; count that column once so a capped overview does not
// spend its slots on repeats. Each tile's distance is computed once; ties keep
// their row order.
const unique = [
...new Map(
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
).values(),
];
const ranked = unique
.map((tile) => ({ tile, d: distance2(tile) }))
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
+4 -1
View File
@@ -131,7 +131,8 @@ export class MapSourceController {
* @returns {() => void} Unsubscribe.
*/
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
// A destroyed controller never settles again, so it takes no listeners.
if (typeof listener !== 'function' || this._destroyed) return () => {};
this._subscribers.add(listener);
return () => {
this._subscribers.delete(listener);
@@ -142,6 +143,8 @@ export class MapSourceController {
if (this._destroyed || !this._subscribers.size) return;
const state = this.getState();
for (const listener of [...this._subscribers]) {
if (this._destroyed) return;
if (!this._subscribers.has(listener)) continue;
try {
listener(state);
} catch (error) {
+37
View File
@@ -25,5 +25,42 @@ export function createEsriImagery() {
export function createIonImagery(style, accessToken) {
accessToken = String(accessToken || '').trim();
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
if (
style === Cesium.IonWorldImageryStyle.AERIAL ||
style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS
) {
return createBingIonImagery(style, accessToken);
}
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
async function createBingIonImagery(style, accessToken) {
const endpoint = await new Cesium.Resource({
url: `https://api.cesium.com/v1/assets/${style}/endpoint`,
queryParameters: { access_token: accessToken },
}).fetchJson();
if (
endpoint?.type !== 'IMAGERY' ||
endpoint.externalType !== 'BING' ||
typeof endpoint.options?.url !== 'string'
) {
throw new Error('Ion world imagery did not return a Bing imagery endpoint');
}
// Bing otherwise follows the page's HTTP scheme on localhost. Resolve its
// ion-issued credentials through public APIs so tiles use HTTPS without
// weakening CSP, changing SDK defaults or touching provider internals.
const { url, ...options } = endpoint.options;
const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, {
...options,
tileProtocol: 'https',
});
const ionCredits = (endpoint.attributions ?? []).map(
({ html, collapsible }) => new Cesium.Credit(html, collapsible === false),
);
const getTileCredits = provider.getTileCredits.bind(provider);
provider.getTileCredits = (x, y, level) => [
...ionCredits,
...(getTileCredits(x, y, level) ?? []),
];
return provider;
}
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
function fixture() {
return new MapSourceController(
{
scene: {
globe: { show: true },
requestRender() {},
frameState: {
creditDisplay: { addStaticCredit() {}, removeStaticCredit() {} },
},
primitives: { add() {}, remove() {} },
},
imageryLayers: { add() {}, remove() {} },
},
{
registry: {
defaultId: 'fixture',
sources: [
{
descriptor: { id: 'fixture', label: 'Fixture', kind: 'imagery' },
imagery: async () => ({}),
},
],
},
createImageryLayer: (provider) => ({ provider }),
},
);
}
test('an unsubscribed owner is skipped in the same map settlement delivery', async () => {
const controller = fixture();
const calls = [];
let detach;
controller.subscribe(() => {
calls.push('first');
detach();
});
detach = controller.subscribe(() => calls.push('detached'));
controller.subscribe(() => calls.push('last'));
await controller.setStack('fixture');
assert.deepEqual(calls, ['first', 'last']);
controller.destroy();
});
test('destroying the controller during delivery stops remaining listeners', async () => {
const controller = fixture();
const calls = [];
controller.subscribe(() => {
calls.push('destroy');
controller.destroy();
});
controller.subscribe(() => calls.push('detached'));
await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']);
});
test('a listener re-added after destroy during delivery is not called', async () => {
const controller = fixture();
const calls = [];
const later = () => calls.push('later');
controller.subscribe(() => {
calls.push('destroy');
controller.destroy();
controller.subscribe(later);
});
controller.subscribe(later);
await controller.setStack('fixture');
assert.deepEqual(calls, ['destroy']);
});
test('delivery order: added during delivery waits, duplicates run once, re-added runs', async () => {
const controller = fixture();
const calls = [];
const twice = () => calls.push('twice');
const readded = () => calls.push('readded');
let offReadded;
controller.subscribe(() => {
calls.push('first');
controller.subscribe(() => calls.push('added-late'));
offReadded();
controller.subscribe(readded);
});
controller.subscribe(twice);
controller.subscribe(twice);
offReadded = controller.subscribe(readded);
await controller.setStack('fixture');
assert.deepEqual(calls, ['first', 'twice', 'readded']);
controller.destroy();
});
+157 -1
View File
@@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul
calls.push({ kind: 'terrain', resource, options });
return { id: 'terrain' };
};
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
await createIonImagery(12345, 'imagery-token');
const result = await createWorldTerrain('terrain-token');
assert.equal(calls[0].options.accessToken, 'imagery-token');
assert.equal(calls[1].options.accessToken, 'terrain-token');
@@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul
}
});
test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => {
const defaultToken = Cesium.Ion.defaultAccessToken;
const metadataCalls = [];
const bingCalls = [];
const perTileCalls = [];
const bingCredit = new Cesium.Credit('Bing tile credit', true);
const endpoint = {
type: 'IMAGERY',
externalType: 'BING',
options: Object.freeze({
url: 'https://dev.virtualearth.net',
key: 'ion-issued-bing-fixture',
mapStyle: 'Aerial',
tileProtocol: 'http',
}),
attributions: [
{ html: 'Ion required credit', collapsible: false },
{ html: 'Ion optional credit', collapsible: true },
],
};
t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () {
metadataCalls.push({
url: this.url,
token: this.queryParameters.access_token,
});
const mapStyle = this.url.includes(
`/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`,
)
? 'AerialWithLabels'
: 'Aerial';
return {
...endpoint,
options: Object.freeze({ ...endpoint.options, mapStyle }),
};
});
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () =>
assert.fail('Bing must not inherit the local HTTP page protocol'),
);
t.mock.method(
Cesium.BingMapsImageryProvider,
'fromUrl',
async (url, options) => {
bingCalls.push({ url, options });
const provider = {
errorEvent: new Cesium.Event(),
getTileCredits(x, y, level) {
assert.equal(this, provider);
perTileCalls.push([x, y, level]);
return level === 0 ? undefined : [bingCredit];
},
};
return provider;
},
);
for (const style of [
Cesium.IonWorldImageryStyle.AERIAL,
Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
]) {
const provider = await createIonImagery(style, ' own-ion-token ');
const credits = provider.getTileCredits(3, 4, 5);
assert.deepEqual(
credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })),
[
{ html: 'Ion required credit', showOnScreen: true },
{ html: 'Ion optional credit', showOnScreen: false },
{ html: 'Bing tile credit', showOnScreen: true },
],
);
assert.equal(credits[2], bingCredit);
assert.equal(provider.getTileCredits(0, 0, 0).length, 2);
assert.ok(provider.errorEvent instanceof Cesium.Event);
assert.equal(metadataCalls.at(-1).token, 'own-ion-token');
const requested = new URL(metadataCalls.at(-1).url);
assert.equal(requested.origin, 'https://api.cesium.com');
assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`);
}
assert.deepEqual(perTileCalls, [
[3, 4, 5],
[0, 0, 0],
[3, 4, 5],
[0, 0, 0],
]);
assert.equal(bingCalls.length, 2);
for (const [index, { url, options }] of bingCalls.entries()) {
assert.equal(url, 'https://dev.virtualearth.net');
assert.deepEqual(options, {
key: 'ion-issued-bing-fixture',
mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels',
tileProtocol: 'https',
});
}
assert.equal(endpoint.options.tileProtocol, 'http');
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
});
test('Bing ion metadata and construction failures propagate without another source', async (t) => {
const style = Cesium.IonWorldImageryStyle.AERIAL;
const metadataError = new Error('ion metadata fixture failure');
const providerError = new Error('Bing construction fixture failure');
let metadata = () => {
throw metadataError;
};
let bingCalls = 0;
t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata());
t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => {
bingCalls += 1;
throw providerError;
});
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () =>
assert.fail('Bing failures must not silently use an alternate provider'),
);
await assert.rejects(createIonImagery(style, 'test-token'), metadataError);
for (const endpoint of [
undefined,
{
type: 'TERRAIN',
externalType: 'BING',
options: { url: 'https://example.test' },
},
{
type: 'IMAGERY',
externalType: 'OTHER',
options: { url: 'https://example.test' },
},
{ type: 'IMAGERY', externalType: 'BING', options: {} },
]) {
metadata = () => endpoint;
await assert.rejects(
createIonImagery(style, 'test-token'),
/Bing imagery endpoint/,
);
}
assert.equal(bingCalls, 0);
metadata = () => ({
type: 'IMAGERY',
externalType: 'BING',
options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' },
});
await assert.rejects(createIonImagery(style, 'test-token'), providerError);
assert.equal(bingCalls, 1);
});
test('other ion imagery assets keep the standard SDK factory', async (t) => {
const expected = {};
const calls = [];
t.mock.method(Cesium.Resource.prototype, 'fetchJson', () =>
assert.fail('generic assets must use the SDK ion factory'),
);
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => {
calls.push(args);
return expected;
});
assert.equal(await createIonImagery(12345, ' custom-token '), expected);
assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]);
});
test('cancellation after ion metadata prevents terrain construction', async () => {
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs';
test('gates keep macOS Chrome where it is installed', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: (p) => p === MACOS_CHROME }),
MACOS_CHROME,
);
});
test('gates fall back to Puppeteer’s browser without macOS Chrome', () => {
assert.equal(
chromeExecutablePath({ env: {}, exists: () => false }),
undefined,
);
});
test('an explicit executable path wins', () => {
assert.equal(
chromeExecutablePath({
env: { PUPPETEER_EXECUTABLE_PATH: 'C:\\Chrome\\chrome.exe' },
exists: () => true,
}),
'C:\\Chrome\\chrome.exe',
);
});
+5 -4
View File
@@ -357,10 +357,11 @@ test('WebUSB picker remains available when no authorized receiver matches', asyn
/** Resolve with the promise's value, or 'timed out' after `ms`. */
function within(promise, ms = 1_000) {
return Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve('timed out'), ms)),
]);
let timer;
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve('timed out'), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/**
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCoordinateQuery } from './coordinateParser.js';
test('search refuses omitted components and repeated separators', () => {
for (const q of ['30,, -97', ',30,-97', '30,-97,', '30;; -97', '30,; -97'])
assert.equal(parseCoordinateQuery(q), null, q);
for (const q of ['30,-97', '30; -97', '30 -97', 'N 30, W 97'])
assert.deepEqual(
[parseCoordinateQuery(q).lat, parseCoordinateQuery(q).lon],
[30, -97],
);
});
+2 -2
View File
@@ -49,8 +49,8 @@ function splitComponents(query) {
const parts = /[,;]/.test(text)
? text.split(/[,;]/)
: text.split(/\s+(?=[+-]?[.\d]|[NSEW]\s*[+-]?[.\d])/i);
const trimmed = parts.map((part) => part.trim()).filter(Boolean);
return trimmed.length === 2 ? trimmed : null;
const trimmed = parts.map((part) => part.trim());
return trimmed.length === 2 && trimmed.every(Boolean) ? trimmed : null;
}
/** Present a parsed pair the way a coordinate is normally written. */
+10 -12
View File
@@ -272,28 +272,26 @@ export function createTerrainHeights({
const uncached = [];
const seenKeys = new Set();
const now = Date.now();
// What this call resolved or found warm, so the assembly below can report a
// point even if a later chunk's writes pushed it past CACHE_MAX_ENTRIES.
// Without it a batch big enough to evict its own entries would report
// points as unresolved that it had in fact resolved.
const resolvedThisCall = new Map();
for (const item of work) {
const entry = cache.get(item.key);
const entry = cacheTouch(item.key);
const fallbackCooling =
entry?.source === 'geoid-fallback' &&
Number.isFinite(entry.retryAt) &&
now < entry.retryAt;
if (
(entry && entry.source === 'reearth') ||
fallbackCooling ||
seenKeys.has(item.key)
)
if ((entry && entry.source === 'reearth') || fallbackCooling) {
resolvedThisCall.set(item.key, entry);
continue;
}
if (seenKeys.has(item.key)) continue;
seenKeys.add(item.key);
uncached.push(item);
}
// What this call resolved, so the assembly below can report a point even
// if a later chunk's writes pushed it past CACHE_MAX_ENTRIES. Without it a
// batch big enough to evict its own earlier chunks would report points as
// unresolved that it had in fact just resolved.
const resolvedThisCall = new Map();
// Resolve the network path in sequential <=CHUNK_SIZE chunks. Each chunk's
// failure is isolated to that chunk's points (geoid fallback), so a single
// bad chunk doesn't lose results for the rest of a large batch.
+41
View File
@@ -114,6 +114,47 @@ test('a batch larger than the cache bound still reports every point it resolved'
assert.equal(warm.length, 4);
});
test('warm batch consumers retain their terrain cells ahead of unused cells', async () => {
let calls = 0;
const terrain = createTerrainHeights({
maxCacheEntries: 2,
source: {
async getHeights(points) {
calls++;
return points.map((p) => ({ ellipsoid: 100 + p.lon }));
},
},
});
const p = (lon) => ({ lat: 0, lon });
await terrain.resolveEllipsoidalGround([p(1)]);
await terrain.resolveEllipsoidalGround([p(2)]);
await terrain.resolveEllipsoidalGround([p(1)]);
assert.equal(calls, 2);
await terrain.resolveEllipsoidalGround([p(3)]);
assert.equal(terrain.cachedEllipsoidalGround(0, 1), 101);
assert.equal(terrain.cachedEllipsoidalGround(0, 2), null);
assert.equal(calls, 3);
});
test('a batch still reports its warm points after its own fetches evict them', async () => {
const terrain = createTerrainHeights({
source: stubSource(),
maxCacheEntries: 2,
});
await terrain.resolveEllipsoidalGround([pointAt(0)]);
const results = await terrain.resolveEllipsoidalGround([
pointAt(0),
pointAt(1),
pointAt(2),
]);
results.forEach((result, i) => {
assert.equal(result.source, 'reearth', `point ${i} reported resolved`);
assert.equal(result.ellipsoid, heightAt(lonAt(i)));
});
});
test('an out-of-range cache bound falls back to the default instead of disabling the cache', async () => {
for (const maxCacheEntries of [0, -1, Number.NaN, undefined]) {
const terrain = createTerrainHeights({
+13 -1
View File
@@ -1,3 +1,4 @@
import { normalizeSquawk } from '../../data/aircraftEmergency.js';
import {
admitRecords,
cleanText,
@@ -30,6 +31,9 @@ export function normalizeOpenSkyAircraft(row) {
typeCode: null,
registration: null,
operator: null,
squawk: normalizeSquawk(row[14]),
// State vectors carry no ADS-B emergency field; the squawk stands alone.
emergency: undefined,
};
}
@@ -68,6 +72,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
typeCode: cleanText(row.t),
registration: cleanText(row.r),
operator: cleanText(row.ownOp),
squawk: normalizeSquawk(row.squawk),
emergency: cleanText(row.emergency).toLowerCase() || null,
};
}
@@ -82,7 +88,13 @@ export function openSkySnapshot(
) {
const admitted = admitRecords(
payload?.states,
normalizeOpenSkyAircraft,
(row) => {
const record = normalizeOpenSkyAircraft(row);
const statuses = payload?.emergencyByIcao;
if (record && statuses && Object.hasOwn(statuses, record.id))
record.emergency = cleanText(statuses[record.id]).toLowerCase() || null;
return record;
},
'OpenSky',
);
const observedAtMs = epoch(payload?.time, 1000);
+20
View File
@@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation'
name: 'AbortError',
});
});
test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => {
const squawking = [...aircraft, '7700'];
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergency, undefined);
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);
});
+2
View File
@@ -1,6 +1,7 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import { createAdsbGnssSource } from '../layers/gnss/source.js';
/** Construct the existing reference feeds independently of application setup. */
export function createReferenceSources() {
@@ -8,5 +9,6 @@ export function createReferenceSources() {
earthquakes: createUsgsEarthquakeSource(),
'fire-perimeters': createWfigsPerimeterSource(),
cables: createBundledCableSource(),
'gnss-integrity': createAdsbGnssSource(),
};
}
+3
View File
@@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s
'earthquakes',
'fire-perimeters',
'cables',
'gnss-integrity',
]);
assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first.cables, second.cables);
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
assert.equal(typeof first.cables.fetch, 'function');
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
assert.equal(requests, 0);
});
+4 -2
View File
@@ -29,11 +29,13 @@ export const snowShader = {
float depth = 0.5 + layer * 0.5;
float speed = 0.4 + layer * 0.3;
float size = mix(0.01, 0.025, layer);
float windForce = sin(time * 0.5 + layer * 3.14) * (0.05 + wind * 0.2);
float windForce = 0.05 + wind * 0.2;
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
snowUV.y += time * speed;
snowUV.x += time * windForce;
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
// so the sway stays bounded however long the app has been running.
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
vec2 cell = floor(snowUV);
vec2 f = fract(snowUV);
+2
View File
@@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs';
test('data providers have both hooks; credential editing stays development-only', () => {
const plugins = localProviderPlugins();
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
@@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
['/api/gnss-integrity?lat=invalid', 400],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/overpass/status', 200],
+3 -1
View File
@@ -24,7 +24,9 @@ export function capRows(rows, limit = DEFAULT_LIMIT) {
/** Format an epoch millisecond time as ISO 8601, or null. */
export function isoTime(ms) {
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
if (!Number.isFinite(ms)) return null;
const date = new Date(ms);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
/** "1 earthquake", "3 earthquakes". */
+14 -4
View File
@@ -106,10 +106,20 @@ export function validateValue(schema, value, path = 'arguments') {
fail('must be a string');
break;
}
if (schema.minLength != null && value.length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && value.length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
if (schema.minLength != null || schema.maxLength != null) {
// Code points, as JSON Schema counts. Counting stops once both bounds
// are decided, so a huge argument costs no more than its limit.
const limit = Math.max(
schema.minLength ?? 0,
schema.maxLength != null ? schema.maxLength + 1 : 0,
);
let length = 0;
for (const _ of value) if (++length >= limit) break;
if (schema.minLength != null && length < schema.minLength)
fail(`must be at least ${schema.minLength} characters`);
if (schema.maxLength != null && length > schema.maxLength)
fail(`must be at most ${schema.maxLength} characters`);
}
if (
schema.pattern !== undefined &&
!new RegExp(schema.pattern, 'u').test(value)
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateValue } from './schema.js';
test('tool string schemas count Unicode characters rather than UTF-16 units', () => {
const value = '𠮷野家😀';
assert.deepEqual(validateValue({ type: 'string', maxLength: 4 }, value), []);
assert.equal(
validateValue({ type: 'string', minLength: 5 }, value).length,
1,
);
assert.equal(
validateValue({ type: 'string', maxLength: 3 }, value).length,
1,
);
assert.deepEqual(
validateValue({ type: 'string', minLength: 4, maxLength: 4 }, 'abcd'),
[],
);
assert.equal(validateValue({ type: 'string', minLength: 2 }, '𠮷').length, 1);
});
test('tool string bounds hold at their edges and on huge arguments', () => {
const string = (bounds) => ({ type: 'string', ...bounds });
assert.deepEqual(validateValue(string({ maxLength: 3 }), '😀😀😀'), []);
assert.equal(validateValue(string({ maxLength: 3 }), '😀😀😀😀').length, 1);
assert.deepEqual(validateValue(string({ minLength: 3 }), '😀😀😀'), []);
assert.deepEqual(validateValue(string({}), 'x'.repeat(1e6)), []);
// Counting all of it takes roughly 0.7 s; stopping at the limit takes none.
const huge = 'x'.repeat(1e8);
const started = performance.now();
assert.equal(validateValue(string({ maxLength: 60 }), huge).length, 1);
assert.ok(performance.now() - started < 200, 'stops counting past the limit');
});
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isoTime } from './results.js';
import { getBikeShare } from './queries/mobility.js';
test('out-of-range finite timestamps are unknown rather than throwing', async () => {
for (const value of [1e30, -1e30, NaN, Infinity, null])
assert.equal(isoTime(value), null);
assert.equal(isoTime(0), '1970-01-01T00:00:00.000Z');
assert.equal(isoTime(8640000000000000), '+275760-09-13T00:00:00.000Z');
assert.equal(isoTime(-8640000000000000), '-271821-04-20T00:00:00.000Z');
for (const value of [8640000000000001, -8640000000000001, -Infinity])
assert.equal(isoTime(value), null);
assert.equal(isoTime(-1), '1969-12-31T23:59:59.999Z');
const station = {
station_id: 's1',
name: 'Fixture',
lat: 0,
lon: 0,
capacity: 4,
};
const result = await getBikeShare.run(
{ area: { bbox: [-1, -1, 1, 1] } },
{
services: {
bikeshare: {
systems: [
{
id: 'fixture',
city: 'Fixture',
centerLat: 0,
centerLon: 0,
loadRadiusKm: 30,
stationInformationUrl: 'information',
stationStatusUrl: 'status',
},
],
async getStations(url) {
return {
data: {
stations:
url === 'information'
? [station]
: [
{
station_id: 's1',
last_reported: 1e30,
num_bikes_available: 2,
},
],
},
};
},
},
},
},
);
assert.equal(result.data.returned, 1);
assert.equal(result.data.rows[0].last_reported, null);
});
+10 -1
View File
@@ -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 (
+7 -1
View File
@@ -49,7 +49,13 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
ids: [
'rocket-launches',
'earthquakes',
'local-firms',
'fire-perimeters',
'gnss-integrity',
],
},
{
label: 'Weather',
+45
View File
@@ -704,6 +704,51 @@ test('narrow-screen rails pin every hosted panel glow inside its panel box', ()
}
});
test('right layout ignores a CSS-hidden expanded panel when deciding exclusivity', () => {
const f = fixture('right');
const weather = element('weather-panel', { height: 300 });
weather.computed.display = 'none';
f.stack.children.push(weather);
weather.parentElement = f.stack;
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), false);
assert.equal(f.stack.dataset.expandedCount, '0');
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.second.getAttribute('aria-hidden'), undefined);
assert.equal(
weather.style.getPropertyValue('--right-panel-allocated-height'),
'',
);
});
test('right layout still restores panels that exclusive presentation hides', () => {
const f = fixture('right');
// Model layers.css: an exclusive rail hides its collapsed panels.
f.options.windowRef.getComputedStyle = (node) => ({
...node.computed,
display:
f.stack.classList.contains('layout-exclusive') &&
node.classList.contains('collapsed')
? 'none'
: node.computed.display,
});
f.expand(f.first, 250);
f.second.classList.add('layout-auto-collapsed');
f.stack.classList.add('layout-exclusive');
f.run();
assert.equal(f.stack.classList.contains('layout-exclusive'), true);
assert.equal(f.second.getAttribute('aria-hidden'), 'true');
f.options.hud.visible = false;
f.run();
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.second.classList.contains('layout-auto-collapsed'), false);
assert.deepEqual(f.collapsed, ['second']);
});
test('right layout ignores a hidden panel: no lane, no gap, no auto-collapse', () => {
const f = fixture('right');
const imagery = element('recent-imagery-panel', { height: 0 });
+9 -2
View File
@@ -69,10 +69,17 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A floating (lifted-out) panel is not laid out by the rail.
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
// that other CSS hides, such as an event view. Exclusive presentation hides
// collapsed panels itself; those stay in the rail so they can be restored.
const hiddenByRail = (panel) =>
stack.classList.contains('layout-exclusive') &&
panel.classList.contains('collapsed');
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
panel.matches('[data-panel-id]:not(.panel-floating)') &&
!panel.hidden &&
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
);
if (!hud.visible || hud.variant !== 'tactical') {
for (const panel of panels.filter((item) =>
+6
View File
@@ -43,6 +43,8 @@
#cockpit-hud {
--cockpit-accent: #00dcff;
/* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */
--cockpit-emergency: #ff4444;
--cockpit-side-panel-width: min(340px, 28vw);
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
@@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
font-size: 7px;
letter-spacing: 0.16em;
}
.cockpit-aircraft-meta.emergency {
color: var(--cockpit-emergency);
font-weight: 600;
}
.cockpit-mode-chip {
margin-top: 2px;
padding: 7px 11px;
+6
View File
@@ -2270,6 +2270,12 @@
color: rgba(205, 225, 226, 0.48);
}
:root[data-ui-theme='cyber']
body.cockpit-mode
.cockpit-aircraft-meta.emergency {
color: var(--cyber-red-bright);
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
margin-top: 0;
border-color: rgba(105, 203, 209, 0.38);
+51 -1
View File
@@ -1,14 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ALLOCATION_TEST_FILES,
UNIT_TEST_ROOTS,
allocationTestArgs,
assertNode24AllocationRuntime,
buildUnitTestPlan,
discoverUnitTestFiles,
isCalibratedAllocationRuntime,
} from '../scripts/run-unit-tests.mjs';
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url));
/** Every `*.test.mjs` a contributor could add, found without consulting the runner. */
function testFilesOnDisk(root) {
// Build outputs and dependencies are not places a contributor writes a test,
// and walking them would be slow enough to matter. Everything else counts.
const skip = new Set(['node_modules', 'dist', 'build', 'public']);
const found = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name.startsWith('.') || skip.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) visit(absolute);
else if (entry.isFile() && entry.name.endsWith('.test.mjs')) {
found.push(path.relative(root, absolute).split(path.sep).join('/'));
}
}
};
visit(root);
return found.sort();
}
test('unit runner serializes only GC-bracketed allocation microbenchmarks', () => {
const ordinary = [
'src/data/manager.test.mjs',
@@ -37,6 +63,30 @@ test('unit runner serializes only GC-bracketed allocation microbenchmarks', () =
);
});
test('the runner discovers every test file in the repository', () => {
// CI's unit-test job runs `npm test`, which runs exactly what this function
// returns. A `*.test.mjs` outside the searched roots therefore never runs
// there, while still reading — in review and to its author — like covered
// code. Compare the two sets rather than their sizes, so the failure names the
// files instead of only disagreeing about a count. The final equality also
// catches the reverse: files the runner finds that this walk skips.
const discovered = discoverUnitTestFiles(repositoryRoot);
const onDisk = testFilesOnDisk(repositoryRoot);
const undiscovered = onDisk.filter((file) => !discovered.includes(file));
assert.deepEqual(
undiscovered,
[],
`test files the runner never executes: ${undiscovered.join(', ')}`,
);
assert.deepEqual(discovered, onDisk);
for (const file of discovered) {
assert.ok(
UNIT_TEST_ROOTS.some((root) => file.startsWith(`${root}/`)),
`${file} should sit under one of ${UNIT_TEST_ROOTS.join(', ')}`,
);
}
});
test('allocation runtime calibration is explicit and pinned to Node 24', () => {
assert.equal(assertNode24AllocationRuntime('24.19.0'), '24.19.0');
assert.throws(
+2 -2
View File
@@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// consolidated tool wording (each policy stated once); model-facing tools
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
// requested radius and center authority in descriptions only.
// Street Level adds its toggle enum values and generated alias hint.
'9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f',
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
'9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b',
);
});
+6 -1
View File
@@ -4809,7 +4809,12 @@ function compactAnalystItem(r) {
if (!present(r[key]) || key in compact) continue;
if (spec.type === 'time') {
const ms = Number(r[key]);
if (Number.isFinite(ms)) compact.time = new Date(ms).toISOString();
// A finite value can still be past Date's range, where toISOString throws.
const time = new Date(ms);
if (Number.isFinite(time.getTime())) {
if (key === 'lastContactAtMs') compact.lastContactAtMs = ms;
else compact.time = time.toISOString();
}
} else compact[key] = r[key];
}
if (present(r.ageHours)) compact.ageHours = r.ageHours;
+44
View File
@@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm
}
});
test('GNSS integrity and interference common names toggle only the GNSS layer through the normal voice action', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } } };
const calls = [];
let enabled = false;
const dataManager = {
layers: new Map([['gnss-integrity', { module: {} }]]),
getAll: () => [{ id: 'gnss-integrity', name: 'GNSS Integrity' }],
isEnabled: () => enabled,
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
};
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
for (const alias of ['gnss-integrity', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) {
for (const value of [true, false]) {
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
assert.equal(result.ok, true);
assert.equal(result.layerId, 'gnss-integrity');
assert.deepEqual(calls.at(-1), ['gnss-integrity', value]);
}
}
});
test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
@@ -3459,6 +3483,26 @@ test('analyst_query refusals carry their code and the allowed values', async ()
assert.match(ok.items[0].time, /^\d{4}-\d\d-\d\dT/);
});
test('analyst_query keeps its answer when one record has an out-of-range time', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const now = Date.now();
const earthquakes = {
getStats: () => ({ count: 2, lastUpdate: now }),
getAnalystRecords: () => [
{ id: 'q', lat: 0, lon: 0, magnitude: 5, timeMs: now },
{ id: 'r', lat: 1, lon: 1, magnitude: 4, timeMs: 8.64e15 + 1 },
],
};
const { runner } = manifestRunner({ earthquakes }, { enabled: ['earthquakes'] });
const result = await runner('analyst_query', { layers: ['earthquakes'], scope: { kind: 'anywhere' } });
assert.equal(result.ok, true);
const q = result.items.find((item) => item.id === 'q');
assert.match(q.time, /^\d{4}-\d\d-\d\dT/);
const r = result.items.find((item) => item.id === 'r');
assert.ok(r, 'the record is still listed');
assert.equal(r.time, undefined, 'an unrepresentable time is omitted');
});
test('spoken layer names from the manifest reach the newer layers', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const stub = () => ({ getStats: () => ({ count: 0 }) });
+20
View File
@@ -34,9 +34,29 @@ const AIRCRAFT_FIELDS = {
aircraftClass: ['t'],
military: ['f'],
onGround: ['f'],
squawk: ['t'],
emergency: ['f'],
emergencyKind: ['t'],
emergencySource: ['t'],
emergencyBroadcast: ['t'],
lastContactAtMs: ['ms'],
};
const layers = [
{
id: 'gnss-integrity',
aliases: [
'gnss integrity',
'navigation integrity',
'low navigation accuracy',
'gnss interference',
'gps interference',
'gps jamming',
'gnss',
],
context: false,
noQuery: 'navigation-integrity cells; no countable analyst records',
},
{
id: 'flights',
track: true,
+7 -1
View File
@@ -13,7 +13,13 @@ function range01(value, min, max) {
* Missing weather fails clear instead of inventing conditions.
*/
export function deriveWeatherEffectProfile(weather) {
if (!weather || !Number.isFinite(Number(weather.weatherCode))) {
const reported = weather?.weatherCode;
// Number(null) and Number('') are 0 (clear), so a missing code is checked first.
if (
reported == null ||
reported === '' ||
!Number.isFinite(Number(reported))
) {
return {
available: false,
cloud: 0,
+9
View File
@@ -10,6 +10,15 @@ test('missing weather fails clear instead of inventing effects', () => {
assert.equal(profile.storm, 0);
});
test('a missing weather code is unavailable, not clear', () => {
for (const weatherCode of [null, undefined, '']) {
const profile = deriveWeatherEffectProfile({ weatherCode, cloudCoverPct: 90 });
assert.equal(profile.available, false, `weatherCode ${weatherCode}`);
assert.equal(profile.cloud, 0);
}
assert.equal(deriveWeatherEffectProfile({ weatherCode: 0 }).available, true);
});
test('clear weather follows observed cloud cover without precipitation', () => {
const profile = deriveWeatherEffectProfile({
weatherCode: 1,