diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e55749f..5393b1370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate aircraft/vessel transport and normalization from layer rendering, preserving observation timestamps, altitude datums and optional history. +- Retain absent aircraft during partially admitted snapshots and bound source error messages. + - Drive share updates, Location feedback and Scene controls through immutable state snapshots and disposable subscriptions. - Keep stale lookup/load completions from publishing accepted results and retain shot rows during playback progress updates. - Export the existing Scene director with explicit playback and editing outcomes. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6230bd043..4b5db8213 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,23 @@ # God's Eye View Current State +## Browser live-source observations + +Flights, Military Flights and AIS Vessels obtain snapshots and optional history +through `gods-eye-view/sources/live`. The standalone adapters use the existing +same-origin routes. Aircraft observations distinguish barometric metres from +WGS84 ellipsoid metres and retain source position/contact epochs; vessel records +retain separate heading/course and sea-surface datum. History is a best-effort +addition to the locally accumulated trail, never a promise of complete coverage. + +Snapshot coverage, completeness and freshness are separate fields. A partially +admitted aircraft snapshot cannot evict absent contacts. An invalid nonempty +snapshot retains the previous display. Empty vessel refreshes retain the existing +first-connect grace and warm-data behavior. Known source failures have bounded +messages; arbitrary HTTP response bodies are not surfaced as diagnostics. +Sources receive cancellation signals and check them after body parsing. The +layer's existing lifecycle and selection guards continue to reject late work. + + ## State and action outcomes Share preferences, place lookups and Scene playback expose immutable snapshots diff --git a/package.json b/package.json index 9ff460106..9bcddd131 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "./ui/scenes": "./src/ui/scenes.js", "./ui/shell": "./src/ui/applicationShell.js", "./ui/styles": "./src/ui/styles.js", - "./scenes": "./src/scenes/director.js" + "./scenes": "./src/scenes/director.js", + "./sources/live": "./src/sources/live/index.js" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index db1021966..a5e091ff8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -565,5 +565,16 @@ "src/contextModePolicy.js" ], "external": ["cesium"] + }, + "live-observations": { + "exports": ["./sources/live"], + "modules": [ + "src/sources/live/aircraft.js", + "src/sources/live/contract.js", + "src/sources/live/index.js", + "src/sources/live/standalone.js", + "src/sources/live/vessels.js" + ], + "external": [] } } diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index d55678ca8..963e93615 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -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} */ -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, diff --git a/src/data/flights.js b/src/data/flights.js index d3c25b855..ca9e96713 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -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); } diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index 220747dcd..93ded128d 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -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} 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} */ 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, }; }, diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js new file mode 100644 index 000000000..0b7d4e609 --- /dev/null +++ b/src/sources/live/aircraft.js @@ -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, + }]; + }); +} diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js new file mode 100644 index 000000000..a37e9d670 --- /dev/null +++ b/src/sources/live/contract.js @@ -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, + }); +} diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs new file mode 100644 index 000000000..7ef8d7201 --- /dev/null +++ b/src/sources/live/contract.test.mjs @@ -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); +}); diff --git a/src/sources/live/index.js b/src/sources/live/index.js new file mode 100644 index 000000000..7047208e8 --- /dev/null +++ b/src/sources/live/index.js @@ -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'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js new file mode 100644 index 000000000..a2f72b154 --- /dev/null +++ b/src/sources/live/standalone.js @@ -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 }; + }, + }; +} diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js new file mode 100644 index 000000000..03c7a8b1d --- /dev/null +++ b/src/sources/live/vessels.js @@ -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' }]; + }); +}