diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b5db8213..b46bd0e07 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,7 +10,8 @@ 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 +admitted aircraft snapshot retains absent contacts for up to five minutes before +the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. 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. diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 963e93615..93dd21cf5 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -1,6 +1,5 @@ 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, @@ -48,6 +47,8 @@ import { import { requestWorldFocus } from '../worldFocus.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +let _source = createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' }); + const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; /** Camera pose signature at the last vessel rotation pass. */ @@ -349,6 +350,7 @@ const aisLiveVesselsLayer = { 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; + this.source = source.label || this.source; }, init(viewer) { @@ -1639,7 +1641,7 @@ function startSelectedVesselTrail(record) { state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 }); } if (state.trail) state.trail.setPositions(state.trailPositions); - backfillVesselTrail(record.mmsi, state.trailBackfillToken); + backfillVesselTrail(record.mmsi, state.trailBackfillToken, record.reference); } /** diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index a9722b9c6..d714f486e 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1641,3 +1641,28 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => { assert.equal(nameless.id, '366999124'); assert.equal(nameless.mmsi, '366999124'); }); + +test('vessel selection passes the opaque source reference to optional history', async () => { + const { createAisStreamSource } = await import('../sources/live/standalone.js'); + _setVesselStateForTest({ enabled: false }); + const requests = []; + aisLiveVesselsLayer.setSource({ + label: 'Test vessel source', + getSnapshot: async () => ({ records: [] }), + getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; }, + }); + const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null }); + try { + harness.record.reference = 'opaque:test-reference'; + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(requests.length, 1); + assert.equal(requests[0].reference, 'opaque:test-reference'); + assert.ok(requests[0].options.signal instanceof AbortSignal); + assert.equal(aisLiveVesselsLayer.source, 'Test vessel source'); + } finally { + harness.cleanup(); + _setVesselStateForTest({ enabled: false }); + aisLiveVesselsLayer.setSource(createAisStreamSource()); + } +}); diff --git a/src/data/flights.js b/src/data/flights.js index ca9e96713..bb8e1e281 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -21,7 +21,6 @@ import { createOpenSkySource } from '../sources/live/standalone.js'; * 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 { @@ -97,6 +96,8 @@ import { import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +let _source = createOpenSkySource(); + const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; /** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ @@ -340,7 +341,7 @@ let _lastTrackingRefreshOutcome = { epoch: 0, status: 'unavailable', ids: new Set(), - source: 'OpenSky Network', + source: _lastSource, coverage: null, }; /** @type {Cesium.Entity|null} Entity used for camera tracking */ @@ -412,7 +413,7 @@ function _contextSubjectMetadata(icao24) { id: icao24, layerId: 'flights', layerName: 'Live Flights', - source: 'OpenSky Network', + source: _lastSource, label: _contactLabel(icao24, _flightData.get(icao24)), latitude: described.latitude, longitude: described.longitude, @@ -996,13 +997,6 @@ let _drReconcileValid = false; /** @type {string|null} icao the reconciliation state currently belongs to */ let _drReconcileIcao = null; -/** - * Normalize a value to a trimmed lowercase string. - * @param {*} value - Any value (typically a header string or null). - * @returns {string} Lowercase trimmed string, or '' if falsy. - */ - - /** * Normalize a value to a trimmed string. A whitespace-only field (" ") is * truthy, so every label chain must trim FIRST and then fall through. @@ -2991,7 +2985,7 @@ function _startTrail(icao24) { async function _backfillTrail(icao24, token, oldestFixEpochSec) { let path = null; try { - const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) }); + const track = await _source.getTrack?.(_flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.timeout(8000) }); path = track?.records ?? null; } catch { return; // silent fallback to the accumulated trail @@ -3822,26 +3816,28 @@ const flightsLayer = { id: 'flights', name: 'Live Flights', icon: '✈️', - source: 'OpenSky Network', + source: _lastSource, // Browser-harness seam: isolates synthetic display-floor scenarios without // changing any production lifecycle or cache policy. _clearDisplayFloorStateForTest, /** @type {number} Polling interval (ms) between update() calls */ updateInterval: 30000, + /** 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; + _lastSource = source.label || _lastSource; + this.source = _lastSource; + }, + /** * Initialize the flights layer. * Creates the BillboardCollection, resets all state, and installs the * 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(); @@ -3875,7 +3871,7 @@ const flightsLayer = { _retryAt = 0; _lastError = null; _lastStatus = null; - _lastSource = 'OpenSky Network'; + _lastSource = _source.label || 'OpenSky Network'; _lastCoverage = 'worldwide upstream snapshot'; _trackedIcao = null; _resetTrackedSelectionState(); @@ -4029,7 +4025,7 @@ const flightsLayer = { const usableStates = snapshot.records; const sourceEpochMs = snapshot.observedAtMs; const sourceAgeMs = snapshot.ageMs; - const sourceStale = snapshot.stale; + const sourceStale = snapshot.stale || snapshot.freshness === 'unknown'; _backoff = sourceStale; _retryAt = 0; _lastError = sourceStale @@ -4219,6 +4215,8 @@ const flightsLayer = { // Store flight metadata for click-to-track labels const cat = stickyNumber(category, prevMeta?.category, null); const meta = { + sourceReference: observation.reference, + observedReceiptMs: Date.now(), callsign: stickyText(callsign, prevMeta?.callsign), altitude: alt, // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the @@ -4398,7 +4396,9 @@ 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) || !snapshot.complete) continue; + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if (!snapshot.complete && Date.now() - (_flightData.get(icao24)?.observedReceiptMs ?? 0) < 300000) continue; const misses = (_missingPolls.get(icao24) || 0) + 1; const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; if (misses < limit) { @@ -4477,7 +4477,7 @@ const flightsLayer = { _count = _billboards.size; // Freshness belongs to the source snapshot, not the moment this browser // received a cached 200 response. - _lastUpdate = sourceEpochMs ?? Date.now(); + _lastUpdate = sourceEpochMs; _lastTrackingRefreshOutcome = { epoch: trackingRefreshEpoch, status: 'accepted', @@ -4496,6 +4496,7 @@ const flightsLayer = { _backoff = true; _retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); _lastStatus = e?.status ?? null; + if (e?.source) { _lastSource = e.source; this.source = _lastSource; } _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 93ded128d..8160f3eda 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -1,6 +1,5 @@ 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'; @@ -64,6 +63,8 @@ import { import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; +let _source = createAdsbLolSource(); + /** * @module militaryFlights * @description Real-time military flight tracking layer powered by the adsb.lol API. @@ -2195,7 +2196,7 @@ function _startTrail(icao24) { async function _backfillTrail(icao24, token, oldestFixEpochSec) { let trace = null; try { - const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) }); + const track = await _source.getTrack?.(_flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.timeout(8000) }); trace = track?.records ?? null; } catch { return; // silent fallback to the accumulated trail @@ -2591,18 +2592,20 @@ const militaryFlightsLayer = { /** @type {number} Polling interval in ms between API fetches */ updateInterval: 15000, - /** - * Initialize the layer: create the billboard collection, reset all state, - * 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; + _lastSource = source.label || _lastSource; + this.source = _lastSource; }, + /** + * Initialize the layer: create the billboard collection, reset all state, + * and install the click-to-track handler. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance + */ init(viewer) { clearFocusTarget('militaryFlights'); _viewer = viewer; @@ -2782,7 +2785,7 @@ const militaryFlightsLayer = { _lastSource = snapshot.source; militaryFlightsLayer.source = _lastSource; const usableAircraft = snapshot.records; - _backoff = snapshot.stale; + _backoff = snapshot.stale || snapshot.freshness === 'unknown'; _retryAt = 0; _lastError = snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null; const currentIcaos = new Set(); @@ -2900,6 +2903,8 @@ const militaryFlightsLayer = { // last-known-good (bounded by the layer's eviction, which deletes the entry). const stickyType = stickyText(type, prevMeta?.type); const meta = { + sourceReference: aircraft.reference, + observedReceiptMs: Date.now(), callsign: stickyText(callsign, prevMeta?.callsign), type: stickyType, // Type outranks category automatically inside classifyAircraft. @@ -3094,7 +3099,9 @@ 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) || !snapshot.complete) continue; + if (currentIcaos.has(icao24)) continue; + // Partial admissions do not prove absence, but stale retention is bounded. + if (!snapshot.complete && Date.now() - (_flightData.get(icao24)?.observedReceiptMs ?? 0) < 300000) continue; const misses = (_missingPolls.get(icao24) || 0) + 1; const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; if (misses < limit) { @@ -3155,6 +3162,7 @@ const militaryFlightsLayer = { _backoff = true; _retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); _lastStatus = e?.status ?? null; + if (e?.source) { _lastSource = e.source; this.source = _lastSource; } _lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; } finally { _activeUpdateControllers.delete(resourceController); diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 461b1d6c6..6ddf51a5d 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -247,3 +247,17 @@ test('AIS reports limited received coverage and keeps connection state separate now, ); }); + +test('a malformed vessel row cannot prevent admission of valid positions', async () => { + const source = createAisStreamSource({ + fetchImpl: async () => + response({ + status: 'live', + rows: [null, { mmsi: '123456789', lat: 30, lon: -97 }], + }), + }); + const snapshot = await source.getSnapshot(); + assert.equal(snapshot.records.length, 1); + assert.equal(snapshot.rejectedCount, 1); + assert.equal(snapshot.complete, false); +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 32885d466..5e407f447 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -54,6 +54,7 @@ export function createOpenSkySource({ now = () => Date.now(), } = {}) { return { + label: 'OpenSky Network', async getSnapshot(query = {}, { signal } = {}) { const params = new URLSearchParams(); if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { @@ -111,6 +112,7 @@ export function createAdsbLolSource({ now = () => Date.now(), } = {}) { return { + label: 'adsb.lol', async getSnapshot(_query = {}, { signal } = {}) { const { response, payload } = await readResponse( fetchImpl, @@ -158,6 +160,7 @@ export function createAisStreamSource({ origin = () => globalThis.location?.origin || 'http://localhost', } = {}) { return { + label: 'AISStream', async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { const url = new URL(apiUrl, origin()); url.searchParams.set('maxRows', String(maxRows)); diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js index 352e9fac0..affdcce7a 100644 --- a/src/sources/live/vessels.js +++ b/src/sources/live/vessels.js @@ -40,7 +40,8 @@ export function vesselSnapshot( const records = [], ids = new Set(); for (const row of rows) { - const record = normalizeVesselObservation(row, referenceFor(row)); + const record = normalizeVesselObservation(row); + if (record) record.reference = referenceFor(row); if (record && !ids.has(record.id)) { records.push(record); ids.add(record.id); @@ -57,7 +58,7 @@ export function vesselSnapshot( records, source, coverage, - complete: false, + complete: records.length === rows.length && !payload?.refreshing, rejectedCount: rows.length - records.length, observedAtMs, freshness: payload?.refreshing