Separate live observation sources from aircraft and vessel layers

This commit is contained in:
Sameh Khamis committed 2026-09-13 08:58:56 -07:00
1 parent b2090a5320
commit e968053d2c
13 files changed
+616 -348

No files matched your search

+34 -37
View File
@@ -1,4 +1,6 @@
import { createAisStreamSource } from '../sources/live/standalone.js';
import * as Cesium from 'cesium';
let _source = createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' });
import {
registerEntityContext,
selectEntityContext,
@@ -52,7 +54,6 @@ const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
let _lastCamPoseSig = '';
const _scratchFocusScreen = new Cesium.Cartesian2();
const DEFAULT_API_URL = '/api/ais-live';
const DEFAULT_RENDER_ROWS = 12000;
const DEFAULT_ACTIVE_LABELS = 900;
const REFRESH_MS = 60000;
@@ -343,6 +344,13 @@ const aisLiveVesselsLayer = {
updateInterval: REFRESH_MS,
statsRefreshInterval: 1000,
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (state.viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source;
},
init(viewer) {
state.viewer = viewer;
ensureCollections(viewer);
@@ -844,33 +852,21 @@ async function loadLivePositions(viewer) {
state.abort = requestController;
try {
const url = liveApiUrl();
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
// can't wedge the poll indefinitely (parity with the track fetch + flights).
const signal = typeof AbortSignal.any === 'function'
? AbortSignal.any([requestController.signal, AbortSignal.timeout(10000)])
: requestController.signal;
const response = await fetch(url, {
signal,
cache: 'no-store',
const snapshot = await _source.getSnapshot({ maxRows: renderRowLimit() }, { signal });
if (!ownsAisRequest(requestController, requestSessionId)) return;
aisLiveVesselsLayer.source = snapshot.source;
// Map observations into the existing display store; source fields stop here.
applyAisFeedSnapshot(viewer, {
rows: snapshot.records.map(vesselDisplayRow), status: snapshot.transportStatus,
lastMessageAt: snapshot.lastMessageAt, nextAttemptAt: snapshot.nextAttemptAt,
refreshing: snapshot.stale, newestPositionAt: snapshot.observedAtMs == null ? null : new Date(snapshot.observedAtMs).toISOString(),
silentForMs: snapshot.silentForMs, reconnectAttempt: snapshot.reconnectAttempt,
});
if (!ownsAisRequest(requestController, requestSessionId)) return;
if (!response.ok) {
// The 503 key-absent / 502 stream-error bodies still carry {status,error}.
// Prefer a clean surfaced reason over a cryptic "AIS live HTTP 503".
let reason = `AIS live HTTP ${response.status}`;
try {
const errPayload = await response.json();
if (!ownsAisRequest(requestController, requestSessionId)) return;
reason = deriveAisFeedError(errPayload, 0)
|| (typeof errPayload?.error === 'string' && errPayload.error.trim()) || reason;
} catch { /* non-JSON body — keep the HTTP status reason */ }
throw new Error(reason);
}
const payload = await response.json();
if (!ownsAisRequest(requestController, requestSessionId)) return;
applyAisFeedSnapshot(viewer, payload);
} catch (error) {
if (ownsAisRequest(requestController, requestSessionId) && error?.name !== 'AbortError') {
markAisUnavailable(error?.message || 'AIS live load failed');
@@ -942,11 +938,15 @@ function applyAisFeedSnapshot(viewer, payload) {
return { reconciled: true, ...snapshot };
}
function liveApiUrl() {
const base = import.meta.env?.VITE_AIS_LIVE_API_URL || DEFAULT_API_URL;
const url = new URL(base, window.location.origin);
url.searchParams.set('maxRows', String(renderRowLimit()));
return url.toString();
function vesselDisplayRow(record) {
return {
mmsi: record.id, reference: record.reference, lat: record.latitude, lon: record.longitude,
name: record.name, imo: record.imo, type: record.type, destination: record.destination,
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
course: record.courseDeg, heading: record.headingDeg,
last_position_epoch: record.observedAtMs == null ? null : record.observedAtMs / 1000,
last_position_UTC: record.observedAtMs == null ? '' : new Date(record.observedAtMs).toISOString(),
};
}
function renderRowLimit() {
@@ -1145,6 +1145,7 @@ function normalizeVessel(row) {
lon,
name: String(row.name || row.input_name || row.mmsi || row.input_identifier || 'VESSEL'),
mmsi: String(row.mmsi || row.input_identifier || '').trim(),
reference: row.reference ?? String(row.mmsi || row.input_identifier || '').trim(),
imo: String(row.imo || ''),
type: String(row.type_specific || row.type || ''),
destination: String(row.destination || ''),
@@ -1651,15 +1652,11 @@ function startSelectedVesselTrail(record) {
* @param {number} token - Backfill token captured at request time.
* @returns {Promise<void>}
*/
async function backfillVesselTrail(mmsi, token) {
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
let samples = null;
try {
const response = await fetch('/api/ais-live/track?mmsi=' + encodeURIComponent(mmsi), {
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return;
const payload = await response.json();
samples = Array.isArray(payload?.samples) ? payload.samples : null;
const track = await _source.getTrack?.(reference, { signal: AbortSignal.timeout(8000) });
samples = track?.records ?? null;
} catch {
return; // silent — keep the live-accumulated trail
}
@@ -1668,8 +1665,8 @@ async function backfillVesselTrail(mmsi, token) {
const older = [];
for (const sample of samples) {
const lat = Number(sample?.lat);
const lon = Number(sample?.lon);
const lat = Number(sample?.latitude);
const lon = Number(sample?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
// the live vertices so the spliced trail is height-continuous.
@@ -1735,7 +1732,7 @@ function registerSelectedContext(record) {
id: `ais-${record.mmsi}`,
layerId: 'ais-live-vessels',
layerName: 'Live AIS Vessels',
source: 'AISStream',
source: aisLiveVesselsLayer.source,
label: displayVesselName(record),
latitude: record.lat,
longitude: record.lon,
+48 -175
View File
@@ -1,3 +1,4 @@
import { createOpenSkySource } from '../sources/live/standalone.js';
/**
* @module flights
* @description Real-time flight tracking layer powered by the OpenSky Network API
@@ -20,6 +21,7 @@
* looking at (owner decision 2026-07-02).
*/
import * as Cesium from 'cesium';
let _source = createOpenSkySource();
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js';
import {
@@ -269,11 +271,6 @@ const _scratchModelBS = new Cesium.BoundingSphere(new Cesium.Cartesian3(), 1.0);
/** Last limb taper per billboard, retained across class/ground/cockpit repaints. */
const _billboardLimbScale = new WeakMap();
/** @constant {string} API_URL - Vite proxy endpoint for OpenSky /states/all */
const API_URL = '/api/opensky';
const SOURCE_STALE_MS = 120_000;
/** @constant {number} BACKOFF_INTERVAL - Cooldown (ms) after 429 / auth errors */
const BACKOFF_INTERVAL = 45000; // 45s on rate limit
/** @constant {number} ERROR_BACKOFF_INTERVAL - Cooldown (ms) after transient errors */
const ERROR_BACKOFF_INTERVAL = 20000; // transient error retry
/** @constant {number} POSITION_HISTORY_LIMIT - Max position samples kept per aircraft for dead reckoning */
@@ -322,17 +319,12 @@ let _lastSource = 'OpenSky Network';
/** @type {string} Completeness boundary for the latest successful snapshot. */
let _lastCoverage = 'worldwide upstream snapshot';
function _flightApiUrl(viewer) {
function _flightQuery(viewer) {
const cartographic = viewer?.camera?.positionCartographic;
if (!cartographic) return API_URL;
const latitude = Cesium.Math.toDegrees(cartographic.latitude);
const longitude = Cesium.Math.toDegrees(cartographic.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return API_URL;
const params = new URLSearchParams({
lat: latitude.toFixed(4),
lon: longitude.toFixed(4),
});
return `${API_URL}?${params}`;
return cartographic ? {
latitude: Cesium.Math.toDegrees(cartographic.latitude),
longitude: Cesium.Math.toDegrees(cartographic.longitude),
} : {};
}
// ---------------------------------------------------------------------------
@@ -785,10 +777,10 @@ let _enrichDripTimer = null;
const _enrichQueue = [];
const _enrichSeen = new Set();
function _enqueueEnrich(key, url, onData, priority = false) {
function _enqueueEnrich(key, query, onData, priority = false) {
if (_enrichSeen.has(key)) return;
_enrichSeen.add(key);
const job = { url, onData };
const job = { query, onData };
// Priority (tracked / model-eligible) goes to the FRONT so a deep ambient
// backlog can never delay the plane the user just clicked or zoomed into.
if (priority) _enrichQueue.unshift(job); else _enrichQueue.push(job);
@@ -810,8 +802,7 @@ function _drainEnrich() {
_enrichLastDispatchMs = Date.now();
const job = _enrichQueue.shift();
_enrichActive += 1;
fetch(job.url)
.then((r) => (r.ok ? r.json() : null))
Promise.resolve(_source.getEnrichment?.(job.query))
.then((data) => { if (data && data.found) job.onData(data); })
.catch(() => { /* enrichment never surfaces errors */ })
.finally(() => { _enrichActive -= 1; _drainEnrich(); });
@@ -820,7 +811,7 @@ function _drainEnrich() {
function _requestTypeEnrichment(icao24, priority = false) {
if (!/^[0-9a-f]{6}$/i.test(icao24)) return;
_enqueueEnrich(`t:${icao24}`, `/api/adsbdb/type/${icao24.toLowerCase()}`, (data) => {
_enqueueEnrich(`t:${icao24}`, { kind: 'type', id: icao24.toLowerCase() }, (data) => {
const meta = _flightData.get(icao24);
if (!meta) return; // evicted while the lookup was in flight
meta.typeCode = data.typeCode || meta.typeCode;
@@ -844,7 +835,7 @@ function _requestTypeEnrichment(icao24, priority = false) {
function _requestRouteEnrichment(icao24) {
const cs = String(_flightData.get(icao24)?.callsign || '').trim().toUpperCase();
if (!/^[A-Z]{3}\d/.test(cs)) return; // airline-style callsigns only (LLL + digit); GA tails won't resolve
_enqueueEnrich(`r:${cs}`, `/api/adsbdb/route/${encodeURIComponent(cs)}`, (data) => {
_enqueueEnrich(`r:${cs}`, { kind: 'route', id: cs }, (data) => {
const meta = _flightData.get(icao24);
if (!meta) return;
meta.airline = data.airline || meta.airline;
@@ -1010,9 +1001,7 @@ let _drReconcileIcao = null;
* @param {*} value - Any value (typically a header string or null).
* @returns {string} Lowercase trimmed string, or '' if falsy.
*/
function _toLowerText(value) {
return String(value || '').trim().toLowerCase();
}
/**
* Normalize a value to a trimmed string. A whitespace-only field (" ") is
@@ -1044,45 +1033,6 @@ function _contactLabel(icao24, info) {
return _toCleanText(info?.callsign) || _toCleanText(info?.registration) || icao24;
}
/**
* Map OpenSky proxy response headers into a human-readable auth error string.
* The Vite proxy forwards `x-opensky-auth-mode-used` and `x-opensky-auth-reason`
* headers so the client can display a meaningful diagnostic.
* @param {object} params
* @param {string} params.detail - Error body text from the proxy, if any.
* @param {string} params.authMode - Normalized auth mode header value.
* @param {string} params.authReason - Normalized auth reason header value.
* @returns {string} Concise error description for UI display.
*/
function _deriveOpenSkyAuthError({ detail, authMode, authReason }) {
const reason = _toLowerText(authReason);
const mode = _toLowerText(authMode);
if (reason === 'oauth_invalid_or_missing') {
return 'OpenSky OAuth client missing/invalid';
}
if (reason === 'oauth_invalid_credentials') {
return 'OpenSky OAuth rejected credentials';
}
if (reason === 'basic_invalid_credentials') {
return 'OpenSky username/password rejected';
}
if (reason === 'missing_basic_creds' || reason === 'missing_oauth_and_basic_creds') {
return 'OpenSky auth missing';
}
if (reason === 'auth_required') {
return 'OpenSky auth required';
}
if (reason.startsWith('oauth_') || reason.startsWith('basic_')) {
return 'OpenSky auth invalid';
}
if (reason === 'forced_anonymous' || mode === 'anon') {
return 'OpenSky auth required';
}
if (detail) return detail;
return 'OpenSky auth failed';
}
/**
* Dead-reckon an aircraft's current position using ENU (East-North-Up) frame math.
*
@@ -3041,12 +2991,8 @@ function _startTrail(icao24) {
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let path = null;
try {
const response = await fetch('/api/opensky-track?icao24=' + encodeURIComponent(icao24), {
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return;
const data = await response.json();
path = Array.isArray(data?.path) ? data.path : null;
const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) });
path = track?.records ?? null;
} catch {
return; // silent fallback to the accumulated trail
}
@@ -3061,8 +3007,8 @@ async function _backfillTrail(icao24, token, oldestFixEpochSec) {
await ensureGeoidReady();
const parsed = [];
for (const waypoint of path) {
if (!Array.isArray(waypoint)) continue;
const [time, lat, lon, baroAlt] = waypoint;
const { observedAtMs, latitude: lat, longitude: lon, baroAltitudeM: baroAlt } = waypoint;
const time = observedAtMs / 1000;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, baroAlt });
@@ -3213,12 +3159,6 @@ function _normalizeTrackedIcao(candidate) {
return normalized || null;
}
function _isUsableOpenSkyState(state) {
if (!Array.isArray(state) || typeof state[0] !== 'string' || !_normalizeTrackedIcao(state[0])) {
return false;
}
return Number.isFinite(state[5]) && Number.isFinite(state[6]);
}
/**
* Whether the Military layer suppresses this civil duplicate right now.
@@ -3895,6 +3835,13 @@ const flightsLayer = {
* click-to-track handler on the scene canvas.
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
*/
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (_viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source;
},
init(viewer) {
clearFocusTarget('flights');
_focusEvidenceIds.clear();
@@ -4076,90 +4023,22 @@ const flightsLayer = {
: resourceController.signal;
try {
updateSignal.throwIfAborted();
const response = await fetch(_flightApiUrl(viewer || _viewer), { signal: updateSignal });
_lastStatus = response.status;
const responseSource = response.headers.get('x-flight-source');
const responseCoverage = response.headers.get('x-flight-coverage');
const authMode = _toLowerText(
response.headers.get('x-opensky-auth-mode-used') || response.headers.get('x-opensky-auth')
);
const authReason = _toLowerText(response.headers.get('x-opensky-auth-reason'));
if (response.status === 429) {
console.warn('[Data:Flights] Rate limited, backing off to 30s');
_backoff = true;
_retryAt = nowMs + BACKOFF_INTERVAL;
_lastError = authMode && authMode !== 'anon'
? 'OpenSky rate limited'
: 'OpenSky rate limited (anonymous)';
return;
}
if (response.status === 401 || response.status === 403) {
console.warn(`[Data:Flights] OpenSky unavailable (${response.status}), backing off`);
_backoff = true;
_retryAt = nowMs + BACKOFF_INTERVAL;
let detail = '';
try {
const body = await response.json();
updateSignal.throwIfAborted();
detail = typeof body?.error === 'string' ? body.error.trim() : '';
} catch {
detail = '';
}
_lastError = _deriveOpenSkyAuthError({
detail,
authMode,
authReason,
});
return;
}
if (!response.ok) {
console.warn(`[Data:Flights] API returned ${response.status}`);
_backoff = true;
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
let detail = '';
try {
const body = await response.json();
updateSignal.throwIfAborted();
detail = typeof body?.error === 'string' ? body.error.trim() : '';
} catch {
detail = '';
}
_lastError = detail || `OpenSky HTTP ${response.status}`;
return;
}
const data = await response.json();
const snapshot = await _source.getSnapshot(_flightQuery(viewer || _viewer), { signal: updateSignal });
updateSignal.throwIfAborted();
if (!data || !Array.isArray(data.states)) {
_backoff = true;
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
_lastError = 'Malformed OpenSky response';
return;
}
const usableStates = data.states.filter(_isUsableOpenSkyState);
if (data.states.length > 0 && usableStates.length === 0) {
_backoff = true;
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
_lastError = 'Malformed OpenSky aircraft rows';
return;
}
const sourceEpochMs = Number.isFinite(Number(data.time)) && Number(data.time) > 0
? Number(data.time) * 1000
: null;
const sourceAgeMs = sourceEpochMs == null ? 0 : Math.max(0, Date.now() - sourceEpochMs);
const sourceStale = sourceAgeMs > SOURCE_STALE_MS;
_lastStatus = snapshot.status ?? 200;
const usableStates = snapshot.records;
const sourceEpochMs = snapshot.observedAtMs;
const sourceAgeMs = snapshot.ageMs;
const sourceStale = snapshot.stale;
_backoff = sourceStale;
_retryAt = 0;
_lastError = sourceStale
? `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
? sourceAgeMs == null ? 'Source snapshot time unavailable'
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
: null;
_lastSource = responseSource || 'OpenSky Network';
_lastCoverage = responseCoverage || 'worldwide upstream snapshot';
_lastSource = snapshot.source;
_lastCoverage = snapshot.coverage;
flightsLayer.source = _lastSource;
const currentIcaos = new Set();
const acceptedSnapshotIcaos = new Set();
const now = Cesium.JulianDate.now();
@@ -4171,20 +4050,13 @@ const flightsLayer = {
const viewerLonDeg = viewerCarto ? Cesium.Math.toDegrees(viewerCarto.longitude) : null;
const floorWarmPoints = [];
// Destructure OpenSky state vector array (indices per API spec):
// [0] icao24, [1] callsign, [2] origin_country, [3] time_position,
// [4] last_contact, [5] longitude, [6] latitude, [7] baro_altitude,
// [8] on_ground, [9] velocity, [10] true_track, [11] vertical_rate,
// [12] sensors, [13] geo_altitude (WGS84 ellipsoidal — the CORRECT
// globe-render height when present; height-datum fix Task 6).
// Keep military classification fresh while the military layer is off
// Classification and display policy consume source-independent observations.
refreshMilitaryRegistryIfStale();
for (const state of usableStates) {
const [rawIcao24, callsign, origin_country, time_position, last_contact, lon, lat, baro_alt, on_ground, velocity, true_track, , , geo_alt] = state;
const icao24 = _normalizeTrackedIcao(rawIcao24);
const category = Number.isFinite(state[17]) ? state[17] : null; // extended=1 emitter category
const vertical_rate = Number.isFinite(state[11]) ? state[11] : null; // m/s, + = climbing
for (const observation of usableStates) {
const { id: icao24, callsign, originCountry: origin_country,
longitude: lon, latitude: lat, baroAltitudeM: baro_alt, onGround: on_ground,
speedMps: velocity, courseDeg: true_track, ellipsoidAltitudeM: geo_alt,
category, verticalRateMps: vertical_rate } = observation;
acceptedSnapshotIcaos.add(icao24);
const onGround = on_ground === true;
@@ -4378,7 +4250,7 @@ const flightsLayer = {
// contact time so a temporarily old position does not hard-freeze
// while fresh velocity/track messages are still arriving.
lastContactEpochMs: stickyNumber(
Number.isFinite(last_contact) ? last_contact * 1000 : null,
observation.contactTimeMs,
prevMeta?.lastContactEpochMs,
null,
),
@@ -4404,8 +4276,8 @@ const flightsLayer = {
// 5-15s stale and receipt-time stamping is what caused the
// back/forward oscillation. Only append when the fix actually
// advances, so stale repeats don't create zero-dt segments.
const fixEpochMs = Number.isFinite(time_position) && time_position > 0
? time_position * 1000
const fixEpochMs = Number.isFinite(observation.positionTimeMs) && observation.positionTimeMs > 0
? observation.positionTimeMs
: Date.now();
const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs));
if (!_positionHistory.has(icao24)) {
@@ -4526,7 +4398,7 @@ const flightsLayer = {
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
// feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of _billboards) {
if (currentIcaos.has(icao24)) continue;
if (currentIcaos.has(icao24) || !snapshot.complete) continue;
const misses = (_missingPolls.get(icao24) || 0) + 1;
const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
@@ -4622,8 +4494,9 @@ const flightsLayer = {
}
console.warn('[Data:Flights] Fetch error:', e);
_backoff = true;
_retryAt = Date.now() + ERROR_BACKOFF_INTERVAL;
_lastError = 'OpenSky network error';
_retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
_lastStatus = e?.status ?? null;
_lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
} finally {
_activeUpdateControllers.delete(resourceController);
}
+56 -135
View File
@@ -1,4 +1,6 @@
import { createAdsbLolSource } from '../sources/live/standalone.js';
import * as Cesium from 'cesium';
let _source = createAdsbLolSource();
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js';
import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js';
@@ -79,12 +81,8 @@ import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor
* rotation (camera.heading - track) with hysteresis.
*/
/** @constant {string} API endpoint proxied to adsb.lol military feed */
const API_URL = '/api/adsblol/mil';
/** @constant {number} Milliseconds to wait before retrying after a transient error */
const ERROR_BACKOFF_INTERVAL = 20000;
/** @constant {number} Longer cooldown (ms) after a 429 rate-limit, mirroring flights.js */
const BACKOFF_INTERVAL = 45000;
/** @constant {number} Max position samples retained per aircraft for dead reckoning */
const POSITION_HISTORY_LIMIT = 5;
/** @constant {number} Base billboard display scale */
@@ -269,6 +267,7 @@ function _abortActiveUpdates() {
}
/** @type {number|null} HTTP status code from the last API response */
let _lastStatus = null;
let _lastSource = 'adsb.lol';
/** @type {boolean} True once ensureGeoidReady() has resolved (awaited once at enable()). Mirror of flights.js. */
let _geoidReady = false;
/** @type {Map<string, number>} icao24 -> geoid undulation N (m), cached (negligible drift per-aircraft). */
@@ -284,7 +283,7 @@ let _lastTrackingRefreshOutcome = {
epoch: 0,
status: 'unavailable',
ids: new Set(),
source: 'adsb.lol',
source: _lastSource,
};
/** @type {Cesium.Entity|null} Entity created for the tracked aircraft (camera follows this) */
let _trackedEntity = null;
@@ -346,7 +345,7 @@ function _contextSubjectMetadata(icao24) {
id: icao24,
layerId: 'military',
layerName: 'Military Flights',
source: 'adsb.lol',
source: _lastSource,
label,
latitude: described.latitude,
longitude: described.longitude,
@@ -841,7 +840,7 @@ export function _setTrackedMilitaryRefreshStateForTest({
export function _setMilitaryTrackingRefreshOutcomeForTest({
status = 'accepted',
ids = [],
source = 'adsb.lol',
source = _lastSource,
} = {}) {
const epoch = ++_trackingRefreshEpoch;
_lastTrackingRefreshOutcome = {
@@ -2194,20 +2193,14 @@ function _startTrail(icao24) {
* @returns {Promise<void>}
*/
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let baseEpochSec = null;
let trace = null;
try {
const response = await fetch('/api/adsblol/trace?hex=' + encodeURIComponent(icao24), {
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return;
const data = await response.json();
baseEpochSec = Number(data?.timestamp);
trace = Array.isArray(data?.trace) ? data.trace : null;
const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) });
trace = track?.records ?? null;
} catch {
return; // silent fallback to the accumulated trail
}
if (!trace || !Number.isFinite(baseEpochSec)) return;
if (!trace) return;
if (token !== _trailBackfillToken || icao24 !== _trackedIcao) return;
// Height-datum fix (Task 7, mirror of flights.js Task 6 _backfillTrail): a
@@ -2220,13 +2213,11 @@ async function _backfillTrail(icao24, token, oldestFixEpochSec) {
// readsb trace points: [secondsAfterTimestamp, lat, lon, alt_ft|'ground'|null, gs_kt, track, flags, ...]
const parsed = [];
for (const point of trace) {
if (!Array.isArray(point)) continue;
const t = baseEpochSec + Number(point[0]);
const lat = Number(point[1]);
const lon = Number(point[2]);
const t = point.observedAtMs / 1000;
const lat = point.latitude, lon = point.longitude;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
parsed.push({ lat, lon, altFt: point[3] });
parsed.push({ lat, lon, baroAltitudeM: point.baroAltitudeM });
}
if (!parsed.length) return;
@@ -2247,10 +2238,8 @@ async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let older = [];
let lastAltM = null; // carry-forward for ground points whose cell isn't warm yet
for (const { lat, lon, altFt } of parsed) {
const baroM = (altFt === 'ground' || altFt == null || !Number.isFinite(Number(altFt)))
? null
: Number(altFt) * 0.3048 + geoidHeight(lat, lon);
for (const { lat, lon, baroAltitudeM } of parsed) {
const baroM = baroAltitudeM == null ? null : baroAltitudeM + geoidHeight(lat, lon);
let altM = floorAltitudeM(baroM, cachedGroundFloor(lat, lon));
// Ground/no-alt point with an unresolved floor: hold the previous
// waypoint's altitude (continuity — never a dive to a made-up depth).
@@ -2386,12 +2375,7 @@ function _normalizeTrackedIcao(candidate) {
return normalized || null;
}
function _isUsableMilitaryAircraft(aircraft) {
if (!aircraft || Array.isArray(aircraft) || typeof aircraft !== 'object') return false;
if (typeof aircraft.hex !== 'string' || !_normalizeTrackedIcao(aircraft.hex)) return false;
return Number.isFinite(_toFiniteNumber(aircraft.lon))
&& Number.isFinite(_toFiniteNumber(aircraft.lat));
}
function _applyPendingTrackingRestore() {
const pending = _pendingTrackingRestore;
@@ -2603,7 +2587,7 @@ const militaryFlightsLayer = {
id: 'military',
name: 'Military Flights',
icon: '🎖️',
source: 'adsb.lol',
source: _lastSource,
/** @type {number} Polling interval in ms between API fetches */
updateInterval: 15000,
@@ -2612,6 +2596,13 @@ const militaryFlightsLayer = {
* and install the click-to-track handler.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
*/
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (_viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source;
},
init(viewer) {
clearFocusTarget('militaryFlights');
_viewer = viewer;
@@ -2771,7 +2762,7 @@ const militaryFlightsLayer = {
epoch: trackingRefreshEpoch,
status: 'source-unavailable',
ids: new Set(),
source: 'adsb.lol',
source: _lastSource,
};
if (_retryAt && nowMs < _retryAt) {
_backoff = true;
@@ -2785,73 +2776,24 @@ const militaryFlightsLayer = {
: resourceController.signal;
try {
updateSignal.throwIfAborted();
const response = await fetch(API_URL, { signal: updateSignal });
_lastStatus = response.status;
if (!response.ok) {
_backoff = true;
// Parity with flights.js: a 429 gets the LONGER cooldown + a friendly
// rate-limit label instead of the generic transient backoff, so we don't
// hammer the upstream and the UI reads honestly.
if (response.status === 429) {
_retryAt = nowMs + BACKOFF_INTERVAL;
_lastError = 'adsb.lol rate limited';
return;
}
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
let detail = '';
try {
const body = await response.json();
updateSignal.throwIfAborted();
detail = _toCleanText(body?.error || body?.message);
} catch {
detail = '';
}
_lastError = detail || `adsb.lol HTTP ${response.status}`;
return;
}
// adsb.lol returns { ac: [...aircraft], msg: "...", ... }
const data = await response.json();
const snapshot = await _source.getSnapshot({}, { signal: updateSignal });
updateSignal.throwIfAborted();
if (!data || !Array.isArray(data.ac)) {
_backoff = true;
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
_lastError = 'Malformed adsb.lol response';
return;
}
const usableAircraft = data.ac.filter(_isUsableMilitaryAircraft);
if (data.ac.length > 0 && usableAircraft.length === 0) {
_backoff = true;
_retryAt = nowMs + ERROR_BACKOFF_INTERVAL;
_lastError = 'Malformed adsb.lol aircraft rows';
return;
}
_backoff = false;
_lastStatus = snapshot.status ?? 200;
_lastSource = snapshot.source;
militaryFlightsLayer.source = _lastSource;
const usableAircraft = snapshot.records;
_backoff = snapshot.stale;
_retryAt = 0;
_lastError = null;
_lastError = snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null;
const currentIcaos = new Set();
const cacheAgeMs = Number(response.headers?.get?.('x-ads-b-cache-age-ms'));
const receiptNowMs = Date.now() - (Number.isFinite(cacheAgeMs) && cacheAgeMs > 0 ? cacheAgeMs : 0);
_backoff = response.headers?.get?.('x-ads-b-cache') === 'STALE';
const receiptNowMs = snapshot.observedAtMs;
// Field-test fix (RS46): coarse floor cells to warm for the below-ground
// clamp — collected during the loop (low airborne contacts only),
// batch-resolved once after it. Never a fetch inside the loop.
const _floorWarmPoints = [];
// -- Parse each aircraft record from the adsb.lol response --
// Fields used: hex (ICAO), lon, lat, alt_baro (ft, barometric/MSL),
// alt_geom (ft, geometric/WGS84 ellipsoidal — probed live 2026-07-05:
// present on ~45% of records; readsb omits it when the aircraft hasn't
// reported a geometric altitude this cycle), track (deg true), gs
// (ground speed in knots), seen_pos (age of last position, seconds),
// flight (callsign), t (type), r (registration), ownOp (operator).
for (const aircraft of usableAircraft) {
const icao24 = _toCleanText(aircraft?.hex).toLowerCase();
const lon = _toFiniteNumber(aircraft?.lon);
const lat = _toFiniteNumber(aircraft?.lat);
const { id: icao24, longitude: lon, latitude: lat } = aircraft;
currentIcaos.add(icao24);
_missingPolls.delete(icao24);
@@ -2861,40 +2803,19 @@ const militaryFlightsLayer = {
// separate boolean). Grounded planes fall back to their last known
// altitude (field elevation is unknowable here), else 0 m — never the
// 3 km airborne default (a parked plane must not float).
const rawAltBaro = aircraft?.alt_baro;
const onGround = typeof rawAltBaro === 'string' && rawAltBaro.trim().toLowerCase() === 'ground';
const altitudeFt = _toFiniteNumber(rawAltBaro);
const altitudeM = Number.isFinite(altitudeFt)
? altitudeFt * 0.3048
: (onGround
? (Number.isFinite(prevMeta?.altitudeFt) ? prevMeta.altitudeFt * 0.3048 : 0)
: 3048);
const track = _toFiniteNumber(aircraft?.track) || 0;
// Convert ground speed from knots to meters/sec for dead reckoning
const speedKt = _toFiniteNumber(aircraft?.gs);
const speedMps = Number.isFinite(speedKt) ? speedKt * 0.514444 : 0;
// Analyst seam (additive): readsb baro_rate is ft/min; keep m/s.
const baroRateFtMin = _toFiniteNumber(aircraft?.baro_rate);
const verticalRateMps = Number.isFinite(baroRateFtMin) ? baroRateFtMin * 0.00508 : null;
const callsign = _toCleanText(aircraft?.flight);
const type = _toCleanText(aircraft?.t);
const registration = _toCleanText(aircraft?.r);
const operator = _toCleanText(aircraft?.ownOp);
const seenSec = _toFiniteNumber(aircraft?.seen);
// Height-datum fix (Task 7, mirror of flights.js Task 6): `altitudeM`
// (above) stays the AVIATION field — the sticky barometric/MSL altitude
// read by labels (FL/altitude readout) and the landed-fast-cull
// heuristic. It is NEVER overwritten or renamed. Where the aircraft
// actually RENDERS on the ellipsoidal globe (billboard path only — a
// ground-snapped 3D MODEL uses groundSnap.js's own tileset sample and
// ignores this value entirely) is a SEPARATE value, renderAltitudeM:
// alt_geom when readsb reports it (already WGS84 ellipsoidal), else
// alt_baro+geoid as a visual fallback, else ground surface when parked.
const altGeomFt = _toFiniteNumber(aircraft?.alt_geom);
const geoAltitudeM = Number.isFinite(altGeomFt) ? altGeomFt * 0.3048 : null;
const baroAltitudeM = Number.isFinite(altitudeFt) ? altitudeFt * 0.3048 : null;
const onGround = aircraft.onGround;
const altitudeFt = aircraft.baroAltitudeM == null ? null : aircraft.baroAltitudeM / 0.3048;
const altitudeM = aircraft.baroAltitudeM
?? (onGround ? (Number.isFinite(prevMeta?.altitudeFt) ? prevMeta.altitudeFt * 0.3048 : 0) : 3048);
const track = aircraft.courseDeg || 0;
const speedMps = aircraft.speedMps ?? 0;
const verticalRateMps = aircraft.verticalRateMps;
const callsign = aircraft.callsign;
const type = aircraft.typeCode;
const registration = aircraft.registration;
const operator = aircraft.operator;
const geoAltitudeM = aircraft.ellipsoidAltitudeM;
const baroAltitudeM = aircraft.baroAltitudeM;
// geoid undulation N: cached per-aircraft (negligible drift — see
// task brief) once the geoid grid has loaded; unavailable pre-load
@@ -2997,7 +2918,7 @@ const militaryFlightsLayer = {
// Analyst seam (additive): sticky like the other kinematics.
verticalRateMps: stickyNumber(verticalRateMps, prevMeta?.verticalRateMps, null),
lastContactEpochMs: stickyNumber(
Number.isFinite(seenSec) ? receiptNowMs - seenSec * 1000 : null,
aircraft.contactTimeMs,
prevMeta?.lastContactEpochMs,
null,
),
@@ -3022,8 +2943,7 @@ const militaryFlightsLayer = {
// report, so the fix epoch is receipt time minus that age. Only
// append when the fix actually advances, so stale repeats don't
// create zero-dt segments.
const seenPos = _toFiniteNumber(aircraft?.seen_pos);
const fixEpochMs = receiptNowMs - (Number.isFinite(seenPos) ? seenPos * 1000 : 0);
const fixEpochMs = aircraft.positionTimeMs ?? receiptNowMs;
const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs));
if (!_positionHistory.has(icao24)) {
_positionHistory.set(icao24, []);
@@ -3174,7 +3094,7 @@ const militaryFlightsLayer = {
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
// feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of _billboards) {
if (currentIcaos.has(icao24)) continue;
if (currentIcaos.has(icao24) || !snapshot.complete) continue;
const misses = (_missingPolls.get(icao24) || 0) + 1;
const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) {
@@ -3222,7 +3142,7 @@ const militaryFlightsLayer = {
epoch: trackingRefreshEpoch,
status: 'accepted',
ids: currentIcaos,
source: 'adsb.lol',
source: _lastSource,
};
console.log(`[Data:Military] Updated: ${_count} aircraft`);
_applyPendingTrackingRestore();
@@ -3233,8 +3153,9 @@ const militaryFlightsLayer = {
}
console.warn('[Data:Military] Fetch error:', e);
_backoff = true;
_retryAt = Date.now() + ERROR_BACKOFF_INTERVAL;
_lastError = 'adsb.lol network error';
_retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
_lastStatus = e?.status ?? null;
_lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
} finally {
_activeUpdateControllers.delete(resourceController);
}
@@ -3308,7 +3229,7 @@ const militaryFlightsLayer = {
epoch: _trackingRefreshEpoch,
status: 'destroyed',
ids: new Set(),
source: 'adsb.lol',
source: _lastSource,
};
_resetTrackedSelectionState(); // next lifecycle re-evaluates against the ENTER ceiling
_viewer = null;
@@ -3670,7 +3591,7 @@ const militaryFlightsLayer = {
if (outcome.status !== 'accepted') {
return {
status: 'source-unavailable',
reason: 'adsb.lol snapshot unavailable',
reason: `${_lastSource} snapshot unavailable`,
refreshEpoch: outcome.epoch,
source: outcome.source,
};
@@ -3772,7 +3693,7 @@ const militaryFlightsLayer = {
error: _lastError,
status: _lastStatus,
retryInSec,
source: 'adsb.lol',
source: _lastSource,
fallback: false,
};
},
+76
View File
@@ -0,0 +1,76 @@
import { admitRecords, cleanText, coordinates, epoch, finite } from './contract.js';
/** Normalize the OpenSky state-vector route, including its regional fallback. */
export function normalizeOpenSkyAircraft(row) {
if (!Array.isArray(row)) return null;
const id = cleanText(row[0]).toLowerCase();
if (!id || !coordinates(row[6], row[5])) return null;
return {
id, reference: id, latitude: row[6], longitude: row[5],
callsign: cleanText(row[1]), originCountry: cleanText(row[2]),
positionTimeMs: epoch(row[3], 1000), contactTimeMs: epoch(row[4], 1000),
baroAltitudeM: finite(row[7]), ellipsoidAltitudeM: finite(row[13]),
onGround: row[8] === true, speedMps: finite(row[9]), courseDeg: finite(row[10]),
verticalRateMps: finite(row[11]), category: finite(row[17]),
typeCode: null, registration: null, operator: null,
};
}
/** Normalize readsb observations. The caller supplies the source snapshot epoch. */
export function normalizeReadsbAircraft(row, snapshotTimeMs) {
const id = cleanText(row?.hex).toLowerCase();
const latitude = finite(row?.lat), longitude = finite(row?.lon);
if (!id || !coordinates(latitude, longitude)) return null;
const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom);
const speedKts = finite(row.gs), rateFtMin = finite(row.baro_rate);
const seen = finite(row.seen), seenPos = finite(row.seen_pos);
return {
id, reference: id, latitude, longitude,
callsign: cleanText(row.flight), originCountry: null,
positionTimeMs: snapshotTimeMs == null ? null : snapshotTimeMs - (seenPos ?? 0) * 1000,
contactTimeMs: snapshotTimeMs == null || seen == null ? null : snapshotTimeMs - seen * 1000,
baroAltitudeM: baroFt == null ? null : baroFt * 0.3048,
ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048,
onGround: cleanText(row.alt_baro).toLowerCase() === 'ground',
speedMps: speedKts == null ? null : speedKts * 0.514444,
courseDeg: finite(row.track), verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508,
category: row.category ?? null, typeCode: cleanText(row.t),
registration: cleanText(row.r), operator: cleanText(row.ownOp),
};
}
export function openSkySnapshot(payload, { source = 'OpenSky Network', coverage = 'worldwide upstream snapshot', now = Date.now(), stale = false } = {}) {
const admitted = admitRecords(payload?.states, normalizeOpenSkyAircraft, 'OpenSky');
const observedAtMs = epoch(payload?.time, 1000);
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
return { ...admitted, source, coverage, observedAtMs, ageMs,
stale: stale || (ageMs != null && ageMs > 120000),
freshness: observedAtMs == null ? 'unknown' : (stale || ageMs > 120000) ? 'stale' : 'current',
};
}
export function readsbSnapshot(payload, { observedAtMs, source = 'adsb.lol', coverage = 'military upstream snapshot', now = Date.now(), stale = false } = {}) {
const admitted = admitRecords(payload?.ac, row => normalizeReadsbAircraft(row, observedAtMs), 'adsb.lol');
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
return { ...admitted, source, coverage, observedAtMs, ageMs, stale,
freshness: observedAtMs == null ? 'unknown' : stale ? 'stale' : 'current',
};
}
/** Track altitude remains barometric; renderers choose a visual ground fallback. */
export function normalizeAircraftTrack(rows, { baseTimeMs = 0, readsb = false } = {}) {
if (!Array.isArray(rows)) return [];
return rows.flatMap(row => {
if (!Array.isArray(row)) return [];
const latitude = finite(row[1]), longitude = finite(row[2]);
const offset = finite(row[0]);
const observedAtMs = offset == null ? null : baseTimeMs + offset * 1000;
if (!coordinates(latitude, longitude) || observedAtMs == null || observedAtMs <= 0) return [];
const altitude = finite(row[3]);
return [{ latitude, longitude, observedAtMs,
baroAltitudeM: altitude == null ? null : altitude * (readsb ? 0.3048 : 1),
ellipsoidAltitudeM: null,
onGround: readsb ? cleanText(row[3]).toLowerCase() === 'ground' : row[5] === true,
}];
});
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Browser live-source contract. Coordinates are WGS84 degrees; lengths and
* velocities are metres and metres/second; times are Unix milliseconds.
* Barometric altitude and ellipsoid altitude are separate, nullable observations.
* A missing timestamp is unknown, never the time the response was received.
*
* Sources expose getSnapshot(query, { signal }) and optionally
* getTrack(reference, { signal }). References are opaque to consumers.
* Snapshots describe coverage and completeness independently of freshness.
* Records contain observation data only, with no scene objects or transport data.
*/
export class LiveSourceError extends Error {
constructor(code, message, { status = null, retryAfterMs = 20000, source = null } = {}) {
super(message);
this.name = 'LiveSourceError';
this.code = code;
this.status = status;
this.retryAfterMs = retryAfterMs;
this.source = source;
}
}
export function finite(value) {
if (value == null || value === '' || typeof value === 'boolean') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
export function epoch(value, scale = 1) {
const number = finite(value);
return number != null && number > 0 ? number * scale : null;
}
export function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
export function coordinates(latitude, longitude) {
return Number.isFinite(latitude) && Math.abs(latitude) <= 90
&& Number.isFinite(longitude) && Math.abs(longitude) <= 180;
}
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
export function admitRecords(rows, normalize, label) {
if (!Array.isArray(rows)) throw new LiveSourceError('malformed', `Malformed ${label} response`);
const records = [];
const ids = new Set();
for (const row of rows) {
const record = normalize(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
}
}
if (rows.length && !records.length) {
throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`);
}
return { records, complete: records.length === rows.length, rejectedCount: rows.length - records.length };
}
/** Cancellation is checked after body parsing even when a transport ignores it. */
export async function readResponse(fetchImpl, url, { signal, ...init } = {}, source = 'Live source') {
signal?.throwIfAborted();
let response;
try {
response = await fetchImpl(url, { ...init, signal });
signal?.throwIfAborted();
} catch (error) {
signal?.throwIfAborted();
if (error?.name === 'AbortError') throw error;
throw new LiveSourceError('unavailable', `${source} network error`, { source });
}
let payload = null;
try { payload = await response.json(); } catch { /* classified below */ }
signal?.throwIfAborted();
return { response, payload };
}
export function httpError(response, source) {
const status = response.status;
const code = status === 429 ? 'limited' : status === 401 || status === 403 ? 'denied' : 'unavailable';
return new LiveSourceError(code, status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`, {
status, source, retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000,
});
}
+132
View File
@@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOpenSkySource, createAdsbLolSource, createAisStreamSource, normalizeReadsbAircraft, normalizeVesselObservation, openSkySnapshot, readsbSnapshot } from './index.js';
const now = 1800000000000;
const aircraft = ['AbC123', 'TEST', 'Example', now / 1000 - 15, now / 1000 - 2, -97, 30, 1000, false, 100, 250, -2, null, 1040];
const response = (payload, headers = {}, status = 200) => Response.json(payload, { headers, status });
test('civil observations retain identity, separate altitude datums and source epochs', () => {
const snapshot = openSkySnapshot({ time: now / 1000 - 130, states: [aircraft] }, { now, source: 'Test source', coverage: 'regional' });
const row = snapshot.records[0];
assert.equal(row.id, 'abc123');
assert.equal(row.baroAltitudeM, 1000);
assert.equal(row.ellipsoidAltitudeM, 1040);
assert.equal(row.positionTimeMs, now - 15000);
assert.equal(row.contactTimeMs, now - 2000);
assert.equal(snapshot.ageMs, 130000);
assert.equal(snapshot.freshness, 'stale');
assert.equal(snapshot.coverage, 'regional');
const missing = openSkySnapshot({ states: [[...aircraft.slice(0, 3), null, null, ...aircraft.slice(5)]] }, { now });
assert.equal(missing.observedAtMs, null);
assert.equal(missing.freshness, 'unknown');
assert.equal(missing.records[0].positionTimeMs, null);
});
test('mixed-invalid and duplicate admissions are explicitly incomplete; all-invalid rejects', () => {
const invalid = [...aircraft]; invalid[5] = 181;
const partial = openSkySnapshot({ states: [aircraft, invalid, aircraft] });
assert.equal(partial.records.length, 1);
assert.equal(partial.complete, false);
assert.equal(partial.rejectedCount, 2);
assert.throws(() => openSkySnapshot({ states: [invalid] }), { code: 'malformed' });
assert.throws(() => openSkySnapshot({}), { code: 'malformed' });
assert.equal(openSkySnapshot({ states: [] }).complete, true);
});
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
const row = normalizeReadsbAircraft({ hex: 'abc123', lat: '30', lon: '-97', alt_baro: 10000, alt_geom: 10100, gs: 120, baro_rate: -600, seen: 2, seen_pos: 12 }, now);
assert.equal(row.baroAltitudeM, 3048);
assert.equal(row.ellipsoidAltitudeM, 3078.48);
assert.equal(row.speedMps, 120 * 0.514444);
assert.equal(row.verticalRateMps, -600 * 0.00508);
assert.equal(row.positionTimeMs, now - 12000);
assert.equal(row.contactTimeMs, now - 2000);
const ground = normalizeReadsbAircraft({ hex: 'abc123', lat: 30, lon: -97, alt_baro: 'ground' }, null);
assert.equal(ground.onGround, true);
assert.equal(ground.baroAltitudeM, null);
assert.equal(ground.positionTimeMs, null);
assert.equal(readsbSnapshot({ ac: [] }, { observedAtMs: null }).freshness, 'unknown');
});
test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => {
const row = normalizeVesselObservation({ mmsi: '123456789', lat: 30, lon: -97, speed: 10, heading: 45, course: 60, last_position_epoch: now / 1000, extra: 'discard' }, 'opaque-reference');
assert.equal(row.id, '123456789');
assert.equal(row.reference, 'opaque-reference');
assert.equal(row.headingDeg, 45);
assert.equal(row.courseDeg, 60);
assert.equal(row.speedMps, 10 * 0.514444);
assert.equal(row.altitudeDatum, 'sea-surface');
assert.equal(row.observedAtMs, now);
assert.equal('extra' in row, false);
assert.equal(normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), null);
});
test('construction is inert; adapters preserve routes, viewport query, cache epoch and history datum', async () => {
const requests = [];
const fetchImpl = async (url, init) => {
requests.push({ url, init });
if (url.startsWith('/api/opensky?')) return response({ time: now / 1000, states: [aircraft] }, { 'x-flight-source': 'adsb.lol', 'x-flight-coverage': '250 nm regional' });
if (url.startsWith('/api/opensky-track')) return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil') return response({ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, { 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' });
return response({ timestamp: now / 1000 - 60, trace: [[10, 30, -97, 1000]] });
};
const civil = createOpenSkySource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0);
const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot({ latitude: 30.123456, longitude: -97 }, { signal });
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional');
assert.equal((await civil.getTrack('abc123')).records[0].baroAltitudeM, 1000);
const mil = await military.getSnapshot();
assert.equal(mil.observedAtMs, now - 60000);
assert.equal(mil.records[0].positionTimeMs, now - 75000);
assert.equal(mil.stale, true);
const track = await military.getTrack('abc123');
assert.equal(track.complete, false);
assert.equal(track.records[0].observedAtMs, now - 50000);
assert.equal(track.records[0].baroAltitudeM, 304.8);
assert.equal(track.records[0].ellipsoidAltitudeM, null);
});
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release;
const controller = new AbortController();
const source = createOpenSkySource({ fetchImpl: async () => ({ ok: true, json: () => new Promise(resolve => { release = resolve; }) }) });
const pending = source.getSnapshot({}, { signal: controller.signal });
await new Promise(resolve => setImmediate(resolve));
controller.abort();
release({ states: [aircraft] });
await assert.rejects(pending, { name: 'AbortError' });
});
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) {
let calls = 0;
const source = createOpenSkySource({ fetchImpl: async () => { calls++; return response({ error: 'sensitive upstream detail' }, {}, status); } });
await assert.rejects(source.getSnapshot(), error => {
assert.equal(error.status, status);
assert.equal(error.message.includes('sensitive'), false);
assert.ok(error.retryAfterMs >= 20000);
return true;
});
assert.equal(calls, 1);
}
});
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({ origin: () => 'http://example.test', fetchImpl: async url => {
if (url.includes('/track?')) return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
return response({ status: 'reconnecting', refreshing: true, rows: [{ mmsi: '123456789', lat: 30, lon: -97, last_position_UTC: new Date(now).toISOString() }], newestPositionAt: new Date(now).toISOString() });
} });
const snapshot = await source.getSnapshot({ maxRows: 500 });
assert.equal(snapshot.complete, false);
assert.equal(snapshot.stale, true);
assert.equal(snapshot.observedAtMs, now);
assert.equal(snapshot.transportStatus, 'reconnecting');
assert.equal((await source.getTrack('123456789')).records[0].observedAtMs, now);
});
+4
View File
@@ -0,0 +1,4 @@
export { LiveSourceError } from './contract.js';
export { normalizeOpenSkyAircraft, normalizeReadsbAircraft, normalizeAircraftTrack, openSkySnapshot, readsbSnapshot } from './aircraft.js';
export { normalizeVesselObservation, normalizeVesselTrack, vesselSnapshot } from './vessels.js';
export { createOpenSkySource, createAdsbLolSource, createAisStreamSource } from './standalone.js';
+96
View File
@@ -0,0 +1,96 @@
import { epoch, finite, httpError, LiveSourceError, readResponse } from './contract.js';
import { normalizeAircraftTrack, openSkySnapshot, readsbSnapshot } from './aircraft.js';
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) {
const error = httpError(response, 'OpenSky');
const mode = String(header(response, 'x-opensky-auth-mode-used') || header(response, 'x-opensky-auth') || '').toLowerCase();
const reason = String(header(response, 'x-opensky-auth-reason') || '').toLowerCase();
if (response.status === 429 && (!mode || mode === 'anon')) error.message = 'OpenSky rate limited (anonymous)';
if (response.status === 401 || response.status === 403) {
const reasons = {
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
oauth_invalid_credentials: 'OpenSky OAuth rejected credentials',
basic_invalid_credentials: 'OpenSky username/password rejected',
missing_basic_creds: 'OpenSky auth missing',
missing_oauth_and_basic_creds: 'OpenSky auth missing',
auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required',
};
error.message = reasons[reason] || (/^(oauth_|basic_)/.test(reason) ? 'OpenSky auth invalid' : mode === 'anon' ? 'OpenSky auth required' : 'OpenSky auth failed');
}
return error;
}
/** Existing same-origin aircraft routes; no request starts during construction. */
export function createOpenSkySource({ fetchImpl = defaultFetch, now = () => Date.now() } = {}) {
return {
async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
params.set('lat', query.latitude.toFixed(4)); params.set('lon', query.longitude.toFixed(4));
}
const { response, payload } = await readResponse(fetchImpl, `/api/opensky${params.size ? '?' + params : ''}`, { signal }, 'OpenSky');
if (!response.ok) throw openSkyError(response);
return { ...openSkySnapshot(payload, {
source: header(response, 'x-flight-source') || 'OpenSky Network',
coverage: header(response, 'x-flight-coverage') || 'worldwide upstream snapshot', now: now(),
}), status: response.status };
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(fetchImpl, '/api/opensky-track?icao24=' + encodeURIComponent(reference), { signal }, 'OpenSky');
if (!response.ok) throw httpError(response, 'OpenSky');
return { records: normalizeAircraftTrack(payload?.path), complete: false };
},
async getEnrichment(query, { signal } = {}) {
if (!['type', 'route'].includes(query.kind)) throw new LiveSourceError('unsupported', 'Enrichment unavailable');
const { response, payload } = await readResponse(fetchImpl, `/api/adsbdb/${query.kind}/${encodeURIComponent(query.id)}`, { signal }, 'adsbdb');
if (!response.ok) throw httpError(response, 'adsbdb');
return payload;
},
};
}
export function createAdsbLolSource({ fetchImpl = defaultFetch, now = () => Date.now() } = {}) {
return {
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(fetchImpl, '/api/adsblol/mil', { signal }, 'adsb.lol');
if (!response.ok) throw httpError(response, 'adsb.lol');
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
return { ...readsbSnapshot(payload, {
observedAtMs: now() - (age != null && age > 0 ? age : 0), now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE',
}), status: response.status };
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(fetchImpl, '/api/adsblol/trace?hex=' + encodeURIComponent(reference), { signal }, 'adsb.lol');
if (!response.ok) throw httpError(response, 'adsb.lol');
const baseTimeMs = epoch(payload?.timestamp, 1000);
return { records: baseTimeMs == null ? [] : normalizeAircraftTrack(payload?.trace, { baseTimeMs, readsb: true }), complete: false };
},
};
}
export function createAisStreamSource({ fetchImpl = defaultFetch, apiUrl = '/api/ais-live', origin = () => globalThis.location?.origin || 'http://localhost' } = {}) {
return {
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows));
const { response, payload } = await readResponse(fetchImpl, url.toString(), { signal, cache: 'no-store' }, 'AIS live');
if (!response.ok) {
const error = httpError(response, 'AIS live');
const reasons = { 'missing-key': 'AISSTREAM_API_KEY not set', 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', unsupported: 'live feed unsupported', error: 'feed down', closed: 'feed disconnected' };
error.message = reasons[payload?.status] || error.message;
throw error;
}
return { ...vesselSnapshot(payload), status: response.status };
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(fetchImpl, '/api/ais-live/track?mmsi=' + encodeURIComponent(reference), { signal }, 'AIS live');
if (!response.ok) throw httpError(response, 'AIS live');
return { records: normalizeVesselTrack(payload?.samples), complete: false };
},
};
}
+51
View File
@@ -0,0 +1,51 @@
import { coordinates, epoch, finite, LiveSourceError } from './contract.js';
/** AIS positions are sea-surface observations; heading and course are distinct. */
export function normalizeVesselObservation(row, reference = null) {
const id = String(row?.mmsi || row?.input_identifier || '').trim();
const latitude = finite(row?.lat), longitude = finite(row?.lon);
if (!id || !coordinates(latitude, longitude)) return null;
const speedKts = finite(row.speed);
return {
id, reference: reference ?? id, latitude, longitude,
name: String(row.name || row.input_name || id),
imo: String(row.imo || ''), type: String(row.type_specific || row.type || ''),
destination: String(row.destination || ''),
speedMps: speedKts == null ? null : speedKts * 0.514444,
courseDeg: finite(row.course), headingDeg: finite(row.heading),
observedAtMs: epoch(row.last_position_epoch, 1000)
?? epoch(Date.parse(row.last_position_UTC)),
altitudeDatum: 'sea-surface',
};
}
export function vesselSnapshot(payload, { source = 'AISStream', coverage = 'received AIS positions', referenceFor = row => String(row.mmsi || row.input_identifier || '') } = {}) {
if (!Array.isArray(payload?.rows)) throw new LiveSourceError('malformed', 'Malformed vessel response');
const rows = payload.rows;
const records = [], ids = new Set();
for (const row of rows) {
const record = normalizeVesselObservation(row, referenceFor(row));
if (record && !ids.has(record.id)) { records.push(record); ids.add(record.id); }
}
const observedAtMs = epoch(payload?.newestPositionAt) ?? epoch(Date.parse(payload?.newestPositionAt))
?? records.reduce((newest, row) => Math.max(newest || 0, row.observedAtMs || 0) || null, null);
return {
records, source, coverage, complete: false, rejectedCount: rows.length - records.length,
observedAtMs, freshness: payload?.refreshing ? 'stale' : observedAtMs == null ? 'unknown' : 'current',
stale: Boolean(payload?.refreshing),
// Connection state is distinct from receipt of a usable position.
transportStatus: typeof payload?.status === 'string' ? payload.status : null,
lastMessageAt: payload?.lastMessageAt ?? null,
nextAttemptAt: finite(payload?.nextAttemptAt), silentForMs: finite(payload?.silentForMs),
reconnectAttempt: finite(payload?.reconnectAttempt), rawRowCount: rows.length,
};
}
export function normalizeVesselTrack(samples) {
if (!Array.isArray(samples)) return [];
return samples.flatMap(sample => {
const latitude = finite(sample?.lat), longitude = finite(sample?.lon);
if (!coordinates(latitude, longitude)) return [];
return [{ latitude, longitude, observedAtMs: epoch(sample?.t, 1000), altitudeDatum: 'sea-surface' }];
});
}