mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Separate live observation sources from aircraft and vessel layers
This commit is contained in:
1 parent
b2090a5320
commit
e968053d2c
13 files changed
+616
-348
No files matched your search
+34
-37
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user