mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
feat(live): flights, military and vessels follow the world clock when their source declares a timeline
A live source may now declare a timeline: it can answer a snapshot at a
given time (source.timeline = { coverage(), stepMs, subscribe? }, read by
readSnapshotTimeline). While such a layer is on it follows the world clock
and registers the coverage. At the live edge it polls exactly as before;
away from it the layer asks for getSnapshot({ ...query, atMs }) at the step
at or before the clock time and shows that snapshot instead of polling.
src/time/snapshotTimeline.js owns the following: a seek, time going
backwards or leaving live replaces every contact with the snapshot at the
new time (play waits on it, for at most 8 s); playing on keeps one
snapshot ahead and gates play on it, applying those snapshots as polls so
contacts move smoothly; outside coverage the layer is empty; back at live
it is cleared and polls at once. One load at a time, and a load the
source drops for a newer one is not a failure.
While following, aircraft are drawn at the clock time with no render
delay, records and absences are judged at the clock, track requests carry
atMs, and vessels move between fixes along the great circle. The world
clock counts jumps (seeks and returns to live) in getState().
A source with no timeline never follows the clock, and every layer stays
live, unchanged.
This commit is contained in:
1 parent
962f7293c6
commit
57ff81f723
49 files changed
+2185
-239
No files matched your search
@@ -0,0 +1,7 @@
|
||||
- Flights, Military Flights and AIS Vessels follow the world clock when their
|
||||
source declares a timeline, that is, can answer a snapshot at a given time
|
||||
(`source.timeline`, read by `readSnapshotTimeline` from
|
||||
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
|
||||
clock's time and move on with it as it plays; back at live they poll as
|
||||
before. A source with no timeline keeps them live, unchanged (Sameh Khamis,
|
||||
@samehkhamis, #PR).
|
||||
@@ -1589,6 +1589,47 @@ 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.
|
||||
|
||||
### Following the world clock
|
||||
|
||||
A source may declare a timeline: it can answer a snapshot at a given time.
|
||||
`source.timeline` is `{ coverage(), stepMs, subscribe? }`, validated by
|
||||
`readSnapshotTimeline` (`src/sources/live/contract.js`): `coverage()` returns
|
||||
the span `{ start, end }` in epoch ms it can answer, or null while it does not
|
||||
know yet; `stepMs` is the spacing of its snapshots; the optional
|
||||
`subscribe(listener)` tells of a change in coverage. Flights, Military Flights
|
||||
and AIS Vessels follow the world clock only while their source declares one
|
||||
(`src/time/snapshotTimeline.js`); with none they never follow it, and every
|
||||
request, timing and display stays as it is live.
|
||||
|
||||
While such a layer is on it follows the clock and registers the source's
|
||||
coverage on the timeline. At the live edge it polls as it always has. Away
|
||||
from it the layer stops polling and asks for `getSnapshot({ ...query, atMs })`
|
||||
at the step at or before the clock's time; a request at the live edge carries
|
||||
no `atMs`. A seek, the time going backwards or leaving live is a jump (the
|
||||
clock's `getState().jumps` counts seeks and returns to live, not play): what
|
||||
loads is dropped, play holds while the snapshot loads, and the snapshot then
|
||||
stands in for every contact. A contact it lacks is gone at once; a tracked or
|
||||
selected contact it lacks is released, and one it has starts its trail afresh,
|
||||
whose track request carries `atMs` and draws only points at or before it.
|
||||
Playing, the layer stays a snapshot ahead of the time, and play does not pass a
|
||||
snapshot still loading for more than 8 seconds. Those snapshots apply as polls
|
||||
do, so contacts move on smoothly: aircraft are drawn at the clock's time
|
||||
rather than behind it, and vessels between two fixes move along the great
|
||||
circle, never past the newer one. Records, absences and the Layers panel's age
|
||||
are judged at the clock's time. Outside the coverage the layer shows no
|
||||
contacts and loads nothing; while the coverage is unknown it loads nothing and
|
||||
clears nothing. A snapshot that fails clears the time and is asked again after
|
||||
5 seconds; one the source drops for a newer request (an `AbortError`) is no
|
||||
failure, and nothing is cleared. A layer has one snapshot loading at a time:
|
||||
the older is aborted before a newer starts. Moving the view while vessels follow the clock asks for the new
|
||||
area at the same time. Back at live the contacts are cleared and the layer
|
||||
polls at once.
|
||||
|
||||
A snapshot at `atMs` describes the world as it stood then: `observedAtMs` is
|
||||
at or before `atMs`, no record is observed later than `atMs`, freshness and
|
||||
staleness are judged against `atMs`, `atMs` is echoed back, and a vessel
|
||||
snapshot reports `transportStatus: 'live'`.
|
||||
|
||||
## State and action outcomes
|
||||
|
||||
Share preferences, place lookups and Scene playback expose immutable snapshots
|
||||
|
||||
@@ -1505,6 +1505,9 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
@@ -1560,6 +1563,9 @@
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/policy.js",
|
||||
@@ -1634,6 +1640,10 @@
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
@@ -2782,6 +2792,8 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -2823,6 +2835,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -2905,6 +2918,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -3792,6 +3806,9 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -3833,6 +3850,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -3914,6 +3932,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses, floors and the tracked
|
||||
* trail belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Flights follow the world clock only while their source declares a timeline
|
||||
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
const following = { timeline: { active: () => true } };
|
||||
const live = { timeline: { active: () => false } };
|
||||
|
||||
function records(now = () => T) {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
const observation = (fields = {}) => ({
|
||||
id: 'abc123',
|
||||
reference: 'abc123',
|
||||
longitude: -77,
|
||||
latitude: 39,
|
||||
callsign: 'TEST1',
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
speedMps: 120,
|
||||
courseDeg: 90,
|
||||
positionTimeMs: T - 5_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(following), 0);
|
||||
assert.equal(followsClock({}), false);
|
||||
assert.equal(followsClock(following), true);
|
||||
});
|
||||
|
||||
test('civil records judge absence and receipt at the layer clock', () => {
|
||||
let now = T;
|
||||
const store = records(() => now);
|
||||
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', {
|
||||
complete: true,
|
||||
tracked: true,
|
||||
nowMs: T + 11 * 60_000,
|
||||
}),
|
||||
'remove',
|
||||
'a given time judges retention',
|
||||
);
|
||||
store.receive(observation(), view());
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
'the clock judges retention when no time is given',
|
||||
);
|
||||
});
|
||||
|
||||
test('a records reset forgets where airframes were and keeps what they are', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation(), view());
|
||||
first.meta.typeCode = 'B738';
|
||||
first.meta.registration = 'N123';
|
||||
first.meta.photo = { src: 'photo.jpg' };
|
||||
store.absence('abc123', { complete: true });
|
||||
store.beginSnapshot(T);
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
const again = store.receive(
|
||||
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
|
||||
view(),
|
||||
);
|
||||
assert.equal(again.prevMeta, undefined, 'no motion carries across');
|
||||
assert.equal(again.groundFlipped, false);
|
||||
assert.equal(again.meta.typeCode, 'B738');
|
||||
assert.equal(again.meta.registration, 'N123');
|
||||
assert.equal(again.meta.photo.src, 'photo.jpg');
|
||||
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
|
||||
store.forget('abc123');
|
||||
assert.equal(store.airframes.size, 0);
|
||||
});
|
||||
|
||||
function ingestion(source, { follows = false } = {}) {
|
||||
const feed = {
|
||||
_source: source,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_trackingRefreshEpoch: 0,
|
||||
_lastSource: 'Fixture',
|
||||
_retryAt: 0,
|
||||
};
|
||||
const applied = [];
|
||||
const state = { follows };
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
getQuery: () => ({ latitude: 39 }),
|
||||
applySnapshot: (snapshot, _viewer, options) => {
|
||||
applied.push({ snapshot, options });
|
||||
return { count: 1, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => state.follows,
|
||||
});
|
||||
return { feed, applied, state, methods };
|
||||
}
|
||||
|
||||
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
await probe.methods.update(null);
|
||||
assert.deepEqual(queries[0], { latitude: 39 });
|
||||
assert.equal(probe.applied[0].options, undefined);
|
||||
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
|
||||
assert.deepEqual(probe.applied[1].options, { replace: true });
|
||||
assert.equal(probe.feed._lastUpdate, T - 60_000);
|
||||
});
|
||||
|
||||
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
|
||||
let release;
|
||||
const probe = ingestion({
|
||||
getSnapshot: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
const out = probe.methods.update(null);
|
||||
probe.state.follows = true;
|
||||
release({ records: [], observedAtMs: T, freshness: 'current' });
|
||||
await out;
|
||||
assert.equal(probe.applied.length, 0);
|
||||
await probe.methods.update(null);
|
||||
assert.equal(probe.applied.length, 0, 'no poll while following');
|
||||
});
|
||||
|
||||
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
|
||||
},
|
||||
});
|
||||
assert.equal(await probe.methods.loadAt(T), false);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
assert.equal(probe.feed._lastError, 'Live data unavailable');
|
||||
});
|
||||
|
||||
test('a jump forgets how contacts moved; clearing drops every contact', () => {
|
||||
const calls = [];
|
||||
const flightState = {
|
||||
records: records(),
|
||||
_positionHistory: new Map([['abc123', []]]),
|
||||
_displayCourse: new Map([['abc123', 90]]),
|
||||
_groundSnap: { clear: () => calls.push('ground') },
|
||||
_displayFloorState: new Map([['abc123', {}]]),
|
||||
_drReconcileValid: true,
|
||||
_cachedDRFrame: 7,
|
||||
_billboards: new Map([['abc123', { id: 'abc123' }]]),
|
||||
_billboardCollection: { remove: () => calls.push('remove') },
|
||||
_cullPositions: new Map([['abc123', null]]),
|
||||
_detectionObjects: new Map([['abc123', {}]]),
|
||||
_trackedIcao: 'abc123',
|
||||
feed: { _count: 1, _lastUpdate: T },
|
||||
};
|
||||
const parts = {
|
||||
tracking: {
|
||||
_clearTrail: () => calls.push('trail'),
|
||||
_clearTracking: (_skip, { evicted }) => {
|
||||
calls.push(`untrack:${evicted}`);
|
||||
flightState._trackedIcao = null;
|
||||
},
|
||||
},
|
||||
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
|
||||
};
|
||||
const follow = createClockFollow({ flightState, parts });
|
||||
flightState.records.receive(observation(), view());
|
||||
follow.resetMotion();
|
||||
assert.equal(flightState.records.data.size, 0);
|
||||
assert.equal(flightState._positionHistory.size, 0);
|
||||
assert.equal(flightState._displayCourse.size, 0);
|
||||
assert.equal(flightState._displayFloorState.size, 0);
|
||||
assert.equal(flightState._drReconcileValid, false);
|
||||
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
|
||||
follow.clear();
|
||||
assert.equal(flightState._billboards.size, 0);
|
||||
assert.equal(flightState._detectionObjects.size, 0);
|
||||
assert.equal(flightState.feed._count, 0);
|
||||
assert.equal(flightState.feed._lastUpdate, null);
|
||||
assert.ok(calls.includes('untrack:true'));
|
||||
assert.ok(calls.includes('model:abc123'));
|
||||
});
|
||||
|
||||
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
|
||||
assert.equal(probe.feed._lastError, undefined);
|
||||
assert.equal(probe.feed._backoff, undefined);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ import { createLifecycle } from './lifecycle.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one civil-flight layer with application-owned scene services. */
|
||||
export function createCivilFlightLayer({
|
||||
source,
|
||||
@@ -29,6 +32,7 @@ export function createCivilFlightLayer({
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -45,7 +49,11 @@ export function createCivilFlightLayer({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -53,6 +61,19 @@ export function createCivilFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
+101
-67
@@ -11,85 +11,119 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const query = timed ? { ...getQuery(viewer), atMs } : getQuery(viewer);
|
||||
const snapshot = await feed._source.getSnapshot(query, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
},
|
||||
};
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrderOnEnable('flights', viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -198,6 +199,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -240,6 +242,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
flightState._enrichActive = 0;
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
RENDER_DELAY_SEC,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
DISPLAY_CORRIDOR_LOOKAHEAD_SEC,
|
||||
DISPLAY_CORRIDOR_CELL_BUDGET,
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
|
||||
@@ -178,7 +179,7 @@ export function createMotion({
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -320,7 +321,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -415,7 +416,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -499,9 +500,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.velocity) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -549,6 +556,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -147,6 +147,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 30;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
@@ -986,6 +987,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
|
||||
/** Source-independent aircraft metadata and bounded missing-poll retention. */
|
||||
export class FlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
};
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
// Facts about an airframe, not a moment, kept across a reset.
|
||||
this.airframes = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
this.fixRepeated = new Set();
|
||||
@@ -90,6 +99,9 @@ export class FlightRecords {
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = this.data.get(icao24);
|
||||
// The airframe's facts: this record's, or those kept across a reset.
|
||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||
this.airframes.delete(icao24);
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
@@ -244,16 +256,16 @@ export class FlightRecords {
|
||||
// A route (and its airline) belongs to the callsign it was looked up for;
|
||||
// a new callsign drops it, so an old flight's route never shows under it.
|
||||
const routeHeld =
|
||||
Boolean(prevMeta?.routeCallsign) &&
|
||||
prevMeta.routeCallsign === nextCallsign.toUpperCase();
|
||||
Boolean(airframe?.routeCallsign) &&
|
||||
airframe.routeCallsign === nextCallsign.toUpperCase();
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
: this.now();
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: nextCallsign,
|
||||
altitude: alt,
|
||||
@@ -273,7 +285,7 @@ export class FlightRecords {
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
@@ -308,14 +320,14 @@ export class FlightRecords {
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: routeHeld ? (prevMeta.airline ?? null) : null,
|
||||
route: routeHeld ? (prevMeta.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
typeName: airframe?.typeName ?? null,
|
||||
registration: airframe?.registration ?? null,
|
||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||
route: routeHeld ? (airframe.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? airframe.routeCallsign : null,
|
||||
// Planespotters photo of the tracked airframe (Core asks it directly).
|
||||
photo: prevMeta?.photo ?? null,
|
||||
photo: airframe?.photo ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
@@ -328,7 +340,10 @@ export class FlightRecords {
|
||||
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
|
||||
}
|
||||
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -337,7 +352,7 @@ export class FlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -349,7 +364,7 @@ export class FlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -361,8 +376,33 @@ export class FlightRecords {
|
||||
return 'remove';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every observation, as when the world clock jumps to another time:
|
||||
* what an airframe is (its type, registration, route and photo) is kept for
|
||||
* when it is seen again; where it was and when is not.
|
||||
*/
|
||||
reset() {
|
||||
for (const [id, meta] of this.data) {
|
||||
this.airframes.set(id, {
|
||||
typeCode: meta.typeCode,
|
||||
typeName: meta.typeName,
|
||||
registration: meta.registration,
|
||||
airline: meta.airline,
|
||||
route: meta.route,
|
||||
routeCallsign: meta.routeCallsign,
|
||||
photo: meta.photo,
|
||||
});
|
||||
}
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.airframes.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.fixRepeated.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -895,7 +895,7 @@ export function createRendering({
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { followsClock } from './policy.js';
|
||||
|
||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||
export function createFlightSnapshotRenderer({
|
||||
@@ -25,7 +26,11 @@ export function createFlightSnapshotRenderer({
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
|
||||
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
@@ -153,7 +158,10 @@ export function createFlightSnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -263,12 +271,15 @@ export function createFlightSnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -302,6 +313,14 @@ export function createFlightSnapshotRenderer({
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new FlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('flights');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
@@ -385,6 +389,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import {
|
||||
drawTrailSegments,
|
||||
@@ -376,7 +377,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -392,7 +393,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -458,7 +459,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -587,15 +588,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let path = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
path = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -625,6 +627,7 @@ export function createTracking({
|
||||
} = waypoint;
|
||||
const time = observedAtMs / 1000;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (atMs !== undefined && !(observedAtMs <= atMs)) continue;
|
||||
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses and the tracked trail
|
||||
* belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MilitaryFlightRecords } from './records.js';
|
||||
import { createIngestion, createMilitaryFeed } from './ingestion.js';
|
||||
import { displayDelayMs, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Military aircraft follow the world clock only while their source declares
|
||||
// a timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
|
||||
test('military contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => false } }), 15_000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => true } }), 0);
|
||||
});
|
||||
|
||||
test('military records judge absence at the layer clock and reset at a jump', () => {
|
||||
let now = T;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now: () => now,
|
||||
});
|
||||
const { meta } = store.receive(
|
||||
{
|
||||
id: 'ae0001',
|
||||
reference: 'ae0001',
|
||||
latitude: 39,
|
||||
longitude: -77,
|
||||
baroAltitudeM: 3000,
|
||||
onGround: false,
|
||||
positionTimeMs: T - 1_000,
|
||||
},
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
);
|
||||
store.receive(
|
||||
{ id: 'ae0001', latitude: 39, longitude: -77, positionTimeMs: T },
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
store.absence('ae0001', { complete: true });
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
});
|
||||
|
||||
test('military polls carry no time; a snapshot at a time carries it, and polls stop following', async () => {
|
||||
const queries = [];
|
||||
const applied = [];
|
||||
let follows = false;
|
||||
const feed = createMilitaryFeed({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
applySnapshot: (_snapshot, _viewer, options) => {
|
||||
applied.push(options);
|
||||
return { count: 0, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => follows,
|
||||
});
|
||||
await methods.update(null);
|
||||
assert.deepEqual(queries, [{}]);
|
||||
assert.deepEqual(applied, [undefined]);
|
||||
follows = true;
|
||||
await methods.update(null);
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await methods.loadAt(T, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { atMs: T });
|
||||
assert.deepEqual(applied[1], { replace: true });
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one military-flight layer with application-owned scene services. */
|
||||
export function createMilitaryFlightLayer({
|
||||
source,
|
||||
@@ -25,6 +28,7 @@ export function createMilitaryFlightLayer({
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -38,7 +42,11 @@ export function createMilitaryFlightLayer({
|
||||
});
|
||||
parts.ingestion = createIngestion({
|
||||
feed: flightState.feed,
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -46,6 +54,19 @@ export function createMilitaryFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'military',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
@@ -10,78 +10,110 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(
|
||||
{},
|
||||
{ signal: updateSignal },
|
||||
);
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(timed ? { atMs } : {}, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrder(viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -175,6 +176,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -219,6 +221,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
parts.controller._abortActiveUpdates();
|
||||
releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix)
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
modelAnchorWorld,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
BILLBOARD_SCALE,
|
||||
@@ -73,7 +74,7 @@ export function createMotion({
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -211,7 +212,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -285,7 +286,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -370,9 +371,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.speedMps) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -403,6 +410,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -186,6 +186,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 15;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (mirror of flights.js; owner field report
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
contactMatchWins,
|
||||
CONTACT_MATCH_TIER,
|
||||
} from '../../data/contactMatch.js';
|
||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||
import {
|
||||
LANDED_ALT_MAX_FT,
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -834,6 +838,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
fallback: false,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,8 +11,15 @@ import {
|
||||
|
||||
/** Portable military aircraft metadata and bounded missing-poll retention. */
|
||||
export class MilitaryFlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
@@ -170,7 +177,7 @@ export class MilitaryFlightRecords {
|
||||
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
|
||||
const meta = {
|
||||
sourceReference: aircraft.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
type: stickyType,
|
||||
@@ -230,7 +237,10 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -239,7 +249,7 @@ export class MilitaryFlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -251,7 +261,7 @@ export class MilitaryFlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -262,6 +272,14 @@ export class MilitaryFlightRecords {
|
||||
this.missingPolls.delete(id);
|
||||
return 'remove';
|
||||
}
|
||||
/** Forget every observation, as when the world clock jumps to another time. */
|
||||
reset() {
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
import {
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
TRACKED_ICON_COLOR,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -27,7 +32,11 @@ export function createMilitarySnapshotRenderer({
|
||||
groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
const { registerMilitaryIcaos } = militaryRegistry;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const receiptNowMs = snapshot.observedAtMs;
|
||||
records.beginSnapshot(snapshot.observedAtMs);
|
||||
@@ -120,7 +129,10 @@ export function createMilitarySnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.speedMps,
|
||||
track: meta.track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -272,12 +284,15 @@ export function createMilitarySnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -310,6 +325,14 @@ export function createMilitarySnapshotRenderer({
|
||||
flightState._groundSnap.forget(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new MilitaryFlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('military');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
@@ -321,6 +325,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
@@ -574,7 +575,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -590,7 +591,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -651,7 +652,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -789,15 +790,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let trace = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
trace = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -825,6 +827,7 @@ export function createTracking({
|
||||
lon = point.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
|
||||
if (atMs !== undefined && !(point.observedAtMs <= atMs)) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
lon,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VesselRecords } from './records.js';
|
||||
import { createIngestion, createVesselFeed } from './ingestion.js';
|
||||
|
||||
// Vessels follow the world clock only while their source declares a
|
||||
// timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const row = (mmsi, fields = {}) => ({
|
||||
mmsi,
|
||||
lat: 51.93,
|
||||
lon: 4.05,
|
||||
name: mmsi,
|
||||
last_position_epoch: 1_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
function store() {
|
||||
const records = new VesselRecords({ now: () => 5_000_000 });
|
||||
const events = [];
|
||||
const effects = {
|
||||
add: (record) => events.push(['add', record.mmsi]),
|
||||
beforeUpdate: () => null,
|
||||
updated: (record) => events.push(['update', record.mmsi]),
|
||||
remove: (record, evicted) => events.push(['remove', record.mmsi, evicted]),
|
||||
removed: (mmsi) => events.push(['removed', mmsi]),
|
||||
staleSelected: (record) => events.push(['stale', record.mmsi]),
|
||||
};
|
||||
return {
|
||||
records,
|
||||
events,
|
||||
reconcile: (rows, options = {}) =>
|
||||
records.reconcile(rows, options, effects),
|
||||
};
|
||||
}
|
||||
|
||||
test('rows at another time stand in for every vessel, selected or not', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111'), row('222')]);
|
||||
const selectedRecord = probe.records.byMmsi.get('222');
|
||||
probe.reconcile([row('111', { last_position_epoch: 900 })], {
|
||||
complete: false,
|
||||
selectedRecord,
|
||||
replace: true,
|
||||
});
|
||||
assert.deepEqual([...probe.records.byMmsi.keys()], ['111']);
|
||||
assert.deepEqual(probe.events.slice(-3), [
|
||||
['update', '111'],
|
||||
['remove', '222', true],
|
||||
['removed', '222'],
|
||||
]);
|
||||
assert.equal(probe.records.byMmsi.get('111').motion, null);
|
||||
});
|
||||
|
||||
test('following the clock, a vessel keeps where its fix moved from', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111')]);
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
const record = probe.records.byMmsi.get('111');
|
||||
assert.deepEqual(record.motion, {
|
||||
fromLat: 51.93,
|
||||
fromLon: 4.05,
|
||||
fromMs: 1_000_000,
|
||||
toLat: 52,
|
||||
toLon: 4.05,
|
||||
toMs: 1_010_000,
|
||||
});
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
assert.equal(record.motion.toMs, 1_010_000, 'a repeated fix keeps its move');
|
||||
const live = store();
|
||||
live.reconcile([row('111')]);
|
||||
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
|
||||
assert.equal(
|
||||
Object.hasOwn(live.records.byMmsi.get('111'), 'motion'),
|
||||
false,
|
||||
'live records are as before',
|
||||
);
|
||||
});
|
||||
|
||||
function ingestion(source) {
|
||||
const feed = createVesselFeed();
|
||||
feed.enabled = true;
|
||||
feed.sessionId = 1;
|
||||
const applied = [];
|
||||
const state = { follows: false, reloads: 0, radiusKm: 50 };
|
||||
const methods = createIngestion({
|
||||
feed,
|
||||
readSource: () => source,
|
||||
readViewer: () => ({}),
|
||||
readArea: () => ({ lat: 1, lon: 2, radiusKm: state.radiusKm }),
|
||||
getRowLimit: () => 500,
|
||||
readCount: () => applied.at(-1)?.rows.length ?? 0,
|
||||
now: () => 9999,
|
||||
setSourceLabel: () => {},
|
||||
applyRows: (_viewer, rows, options) => applied.push({ rows, options }),
|
||||
classifySnapshot: (payload) => ({
|
||||
acceptedRows: payload.rows,
|
||||
acceptedRowCount: payload.rows.length,
|
||||
rawRowCount: payload.rows.length,
|
||||
transportStatus: payload.status,
|
||||
lastMessageAt: payload.lastMessageAt,
|
||||
error: null,
|
||||
}),
|
||||
isDefinitiveTransportFailure: () => false,
|
||||
isGraceEligibleTransport: () => false,
|
||||
markUnavailable: () => {},
|
||||
settleFirstConnect: () => {},
|
||||
followsClock: () => state.follows,
|
||||
reloadAtClock: () => {
|
||||
state.reloads += 1;
|
||||
},
|
||||
});
|
||||
return { feed, applied, state, ...methods };
|
||||
}
|
||||
|
||||
test('vessel polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return {
|
||||
records: [{ id: '111', latitude: 1, longitude: 2, observedAtMs: 5 }],
|
||||
observedAtMs: 5,
|
||||
freshness: 'current',
|
||||
complete: true,
|
||||
transportStatus: 'live',
|
||||
};
|
||||
},
|
||||
});
|
||||
await probe.methods.update();
|
||||
assert.equal(Object.hasOwn(queries[0], 'atMs'), false);
|
||||
assert.deepEqual(probe.applied[0].options, { complete: true });
|
||||
probe.state.follows = true;
|
||||
await probe.methods.update();
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await probe.loadAt(5_000, { replace: true }), true);
|
||||
assert.equal(queries[1].atMs, 5_000);
|
||||
assert.deepEqual(probe.applied[1].options, { complete: true, replace: true });
|
||||
assert.equal(probe.feed.lastUpdate, 5);
|
||||
// Following the clock, a moved view reloads the time shown.
|
||||
probe.state.radiusKm = 2_000;
|
||||
await probe.refreshIfMoved({});
|
||||
assert.equal(probe.state.reloads, 1);
|
||||
assert.equal(queries.length, 2);
|
||||
});
|
||||
|
||||
test('an empty vessel snapshot at another time shows no vessels', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
return { records: [], observedAtMs: 5, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await probe.loadAt(5_000, { replace: true });
|
||||
assert.deepEqual(probe.applied, [
|
||||
{ rows: [], options: { complete: true, replace: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a vessel snapshot at a time the source drops for a newer one is no failure', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await assert.rejects(probe.loadAt(5_000, { replace: true }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(probe.feed.error, null);
|
||||
assert.equal(probe.feed.loading, false);
|
||||
assert.deepEqual(probe.applied, []);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ import { createCards } from './cards.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { followsClock } from './policy.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one vessel layer with application-owned scene services. */
|
||||
export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
const vesselState = createVesselState({ source, services });
|
||||
@@ -31,6 +34,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
tracking: parts.tracking,
|
||||
selection: parts.selection,
|
||||
cards: parts.cards,
|
||||
followsClock: () => followsClock(vesselState),
|
||||
});
|
||||
|
||||
parts.ingestion = createIngestion({
|
||||
@@ -50,6 +54,29 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
followsClock: () => followsClock(vesselState),
|
||||
reloadAtClock: () => vesselState.timeline.reload(),
|
||||
});
|
||||
// Vessels follow the world clock while the source declares a timeline.
|
||||
vesselState.timeline = createSnapshotTimeline({
|
||||
id: 'ais-live-vessels',
|
||||
clock: worldClock,
|
||||
source: () => vesselState._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.loadAt(atMs, { signal, replace }),
|
||||
clear: () => {
|
||||
const { state } = vesselState;
|
||||
if (state.viewer)
|
||||
parts.snapshots.reconcileVessels(state.viewer, [], {
|
||||
complete: true,
|
||||
replace: true,
|
||||
});
|
||||
state.feed.count = state.records.all.length;
|
||||
state.feed.lastUpdate = null;
|
||||
},
|
||||
resumeLive: () => {
|
||||
void parts.ingestion.loadLivePositions(vesselState.state.viewer);
|
||||
},
|
||||
});
|
||||
Object.assign(
|
||||
layer,
|
||||
|
||||
@@ -21,9 +21,36 @@ export function createIngestion({
|
||||
settleFirstConnect,
|
||||
now,
|
||||
setSourceLabel,
|
||||
followsClock = () => false,
|
||||
reloadAtClock = () => {},
|
||||
}) {
|
||||
async function loadLivePositions(viewer) {
|
||||
if (!viewer || feed.loading) return;
|
||||
// Following the world clock, the layer shows the snapshots loadAt applies.
|
||||
if (followsClock()) return;
|
||||
await loadPositions(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every vessel when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function loadAt(atMs, { signal = null, replace = false } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace && feed.loading) {
|
||||
feed.abort?.abort();
|
||||
feed.loading = false;
|
||||
feed.abort = null;
|
||||
}
|
||||
return loadPositions(readViewer(), { atMs, replace, signal });
|
||||
}
|
||||
|
||||
async function loadPositions(viewer, { atMs, replace = false, signal } = {}) {
|
||||
const timed = atMs !== undefined;
|
||||
if (!viewer || feed.loading) return false;
|
||||
feed.loading = true;
|
||||
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
|
||||
const requestController = new AbortController();
|
||||
@@ -33,21 +60,29 @@ export function createIngestion({
|
||||
try {
|
||||
// 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 =
|
||||
const requestSignal =
|
||||
typeof AbortSignal.any === 'function'
|
||||
? AbortSignal.any([
|
||||
requestController.signal,
|
||||
AbortSignal.timeout(10000),
|
||||
...(signal ? [signal] : []),
|
||||
])
|
||||
: requestController.signal;
|
||||
// Ask for the vessels in view; a wide view asks for every vessel.
|
||||
const area = readArea(viewer);
|
||||
feed.lastArea = area;
|
||||
const snapshot = await readSource().getSnapshot(
|
||||
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
|
||||
{ signal },
|
||||
{
|
||||
maxRows: getRowLimit(),
|
||||
...(area ? { area } : {}),
|
||||
...(timed ? { atMs } : {}),
|
||||
},
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return false;
|
||||
if (signal?.aborted) return false;
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return false;
|
||||
setSourceLabel(snapshot.source);
|
||||
// Map observations into the existing display store; source fields stop here.
|
||||
applyAisFeedSnapshot(viewer, {
|
||||
@@ -67,8 +102,12 @@ export function createIngestion({
|
||||
: new Date(snapshot.observedAtMs).toISOString(),
|
||||
silentForMs: snapshot.silentForMs,
|
||||
reconnectAttempt: snapshot.reconnectAttempt,
|
||||
...(replace && { replace }),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A snapshot at a time the source dropped for a newer one is no failure.
|
||||
if (timed && error?.name === 'AbortError') throw error;
|
||||
if (
|
||||
ownsAisRequest(requestController, requestSessionId) &&
|
||||
error?.name !== 'AbortError'
|
||||
@@ -76,6 +115,7 @@ export function createIngestion({
|
||||
markUnavailable(error?.message || msg('AIS live load failed'));
|
||||
console.warn('[Data:ais-live-vessels]', feed.error, error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (
|
||||
feed.abort === requestController &&
|
||||
@@ -113,6 +153,9 @@ export function createIngestion({
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
// At another time, a snapshot with no vessels shows none, not the last.
|
||||
if (payload?.replace && snapshot.acceptedRowCount === 0)
|
||||
applyRows(viewer, [], { complete: true, replace: true });
|
||||
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||
feed.count = readCount();
|
||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||
@@ -139,6 +182,7 @@ export function createIngestion({
|
||||
settleFirstConnect('ready');
|
||||
applyRows(viewer, snapshot.acceptedRows, {
|
||||
complete: payload?.complete !== false,
|
||||
...(payload?.replace && { replace: true }),
|
||||
});
|
||||
feed.count = readCount();
|
||||
feed.stale =
|
||||
@@ -180,10 +224,16 @@ export function createIngestion({
|
||||
}
|
||||
/** Ask again when the view moved away from the last area asked for. */
|
||||
function refreshIfMoved(viewer) {
|
||||
if (!feed.enabled || feed.loading) return Promise.resolve();
|
||||
const following = followsClock();
|
||||
if (!feed.enabled || (feed.loading && !following)) return Promise.resolve();
|
||||
const active = viewer || readViewer();
|
||||
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
|
||||
return Promise.resolve();
|
||||
// Following the world clock, the new area is asked for at the same time.
|
||||
if (following) {
|
||||
reloadAtClock();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return loadLivePositions(active);
|
||||
}
|
||||
|
||||
@@ -196,6 +246,7 @@ export function createIngestion({
|
||||
|
||||
return {
|
||||
loadLivePositions,
|
||||
loadAt,
|
||||
refreshIfMoved,
|
||||
ownsAisRequest,
|
||||
applyAisFeedSnapshot,
|
||||
|
||||
@@ -200,10 +200,12 @@ export function createLifecycle({
|
||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
||||
components.ingestion.refreshIfMoved(activeViewer);
|
||||
});
|
||||
vesselState.timeline?.start();
|
||||
return components.ingestion.loadLivePositions(activeViewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
@@ -224,6 +226,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Where a vessel is at world time `tMs` while the layer follows the world
|
||||
* clock: between the fix it moved from and the fix it moved to, along the
|
||||
* great circle; at the earlier fix before it, at the newer fix after it. A
|
||||
* vessel is never projected past its newest fix.
|
||||
* @param {{lat: number, lon: number, motion?: {fromLat: number, fromLon: number,
|
||||
* fromMs: number, toLat: number, toLon: number, toMs: number}|null}} record
|
||||
* @param {number} tMs
|
||||
* @returns {{lat: number, lon: number}}
|
||||
*/
|
||||
export function vesselPositionAt(record, tMs) {
|
||||
const motion = record?.motion;
|
||||
if (
|
||||
!motion ||
|
||||
!Number.isFinite(tMs) ||
|
||||
!(motion.toMs > motion.fromMs) ||
|
||||
tMs >= motion.toMs
|
||||
)
|
||||
return record;
|
||||
if (tMs <= motion.fromMs) return { lat: motion.fromLat, lon: motion.fromLon };
|
||||
const fraction = (tMs - motion.fromMs) / (motion.toMs - motion.fromMs);
|
||||
return greatCircleAt(
|
||||
motion.fromLat,
|
||||
motion.fromLon,
|
||||
motion.toLat,
|
||||
motion.toLon,
|
||||
fraction,
|
||||
);
|
||||
}
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** The point `fraction` of the way from one position to another. */
|
||||
function greatCircleAt(lat1, lon1, lat2, lon2, fraction) {
|
||||
const p1 = unit(lat1, lon1);
|
||||
const p2 = unit(lat2, lon2);
|
||||
const dot = Math.min(
|
||||
1,
|
||||
Math.max(-1, p1[0] * p2[0] + p1[1] * p2[1] + p1[2] * p2[2]),
|
||||
);
|
||||
const angle = Math.acos(dot);
|
||||
if (angle < 1e-12) return { lat: lat2, lon: lon2 };
|
||||
const a = Math.sin((1 - fraction) * angle) / Math.sin(angle);
|
||||
const b = Math.sin(fraction * angle) / Math.sin(angle);
|
||||
const x = a * p1[0] + b * p2[0];
|
||||
const y = a * p1[1] + b * p2[1];
|
||||
const z = a * p1[2] + b * p2[2];
|
||||
return {
|
||||
lat: Math.atan2(z, Math.hypot(x, y)) / RAD,
|
||||
lon: Math.atan2(y, x) / RAD,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(lat, lon) {
|
||||
const phi = lat * RAD;
|
||||
const lambda = lon * RAD;
|
||||
return [
|
||||
Math.cos(phi) * Math.cos(lambda),
|
||||
Math.cos(phi) * Math.sin(lambda),
|
||||
Math.sin(phi),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
const record = {
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
motion: {
|
||||
fromLat: 0,
|
||||
fromLon: 0,
|
||||
fromMs: 1_000,
|
||||
toLat: 1,
|
||||
toLon: 1,
|
||||
toMs: 3_000,
|
||||
},
|
||||
};
|
||||
|
||||
test('a vessel holds at its fixes and moves between them along the great circle', () => {
|
||||
assert.deepEqual(vesselPositionAt(record, 500), { lat: 0, lon: 0 });
|
||||
assert.equal(vesselPositionAt(record, 3_000), record);
|
||||
assert.equal(vesselPositionAt(record, 9_000), record, 'never projected past');
|
||||
const half = vesselPositionAt(record, 2_000);
|
||||
assert.ok(Math.abs(half.lat - 0.5) < 1e-3);
|
||||
assert.ok(Math.abs(half.lon - 0.5) < 1e-3);
|
||||
});
|
||||
|
||||
test('a vessel with no move is at its record', () => {
|
||||
const still = { lat: 4, lon: 5 };
|
||||
assert.equal(vesselPositionAt(still, 2_000), still);
|
||||
assert.equal(vesselPositionAt({ ...still, motion: null }, 2_000).lat, 4);
|
||||
});
|
||||
@@ -53,6 +53,11 @@ export const TRAIL_MAX_POINTS = 400;
|
||||
|
||||
export const TRAIL_MIN_MOVE_M = 25;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (vesselState) =>
|
||||
vesselState.timeline?.active() === true;
|
||||
|
||||
export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
REFRESH_MS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
VESSEL_LIFT_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
@@ -660,6 +661,8 @@ export function createQueries({
|
||||
retryInSec: aisRetryInSec(),
|
||||
// Same field as flights and military, so Contacts names the provider.
|
||||
source: layer.source,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(vesselState) && { atMs: vesselState.worldNow() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,6 +44,26 @@ function finiteNumber(value) {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The move from a record's fix to the next row's, when the fix moves on in
|
||||
* time; otherwise the move it was making, if the row repeats its fix.
|
||||
*/
|
||||
function motionBetween(record, next) {
|
||||
const fromEpoch = record.lastPositionEpoch;
|
||||
const toEpoch = next.lastPositionEpoch;
|
||||
if (fromEpoch === toEpoch) return record.motion ?? null;
|
||||
if (!Number.isFinite(fromEpoch) || !Number.isFinite(toEpoch)) return null;
|
||||
if (!(toEpoch > fromEpoch)) return null;
|
||||
return {
|
||||
fromLat: record.lat,
|
||||
fromLon: record.lon,
|
||||
fromMs: fromEpoch * 1000,
|
||||
toLat: next.lat,
|
||||
toLon: next.lon,
|
||||
toMs: toEpoch * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Own stable vessel records and bounded incomplete/selected retention. */
|
||||
export class VesselRecords {
|
||||
constructor({ now = () => Date.now() } = {}) {
|
||||
@@ -52,9 +72,23 @@ export class VesselRecords {
|
||||
this.unkeyed = [];
|
||||
this.all = [];
|
||||
}
|
||||
/**
|
||||
* Fold a refresh into the records. Following the world clock (`follow`),
|
||||
* a vessel whose fix moves on keeps where it moved from, so it can be drawn
|
||||
* between the two (see motion.js). `replace`: the rows are the world at
|
||||
* another time (the clock jumped), so they stand in for every vessel: one
|
||||
* they lack is gone at once, selected or not, and none moves from where it
|
||||
* was at the time the clock left.
|
||||
*/
|
||||
reconcile(
|
||||
rows,
|
||||
{ complete = true, selectedRecord = null, cap = Infinity },
|
||||
{
|
||||
complete = true,
|
||||
selectedRecord = null,
|
||||
cap = Infinity,
|
||||
replace = false,
|
||||
follow = false,
|
||||
},
|
||||
effects,
|
||||
) {
|
||||
const receivedAtMs = this.now();
|
||||
@@ -94,6 +128,8 @@ export class VesselRecords {
|
||||
record.fixReceivedAtMs = next.fixReceivedAtMs;
|
||||
record.fixFeedEpoch = next.fixFeedEpoch;
|
||||
}
|
||||
if (replace) record.motion = null;
|
||||
else if (follow) record.motion = motionBetween(record, next);
|
||||
record.reference = next.reference;
|
||||
record.receivedAtMs = next.receivedAtMs;
|
||||
record.lat = next.lat;
|
||||
@@ -117,6 +153,12 @@ export class VesselRecords {
|
||||
}
|
||||
for (const [mmsi, record] of this.byMmsi) {
|
||||
if (seen.has(mmsi)) continue;
|
||||
if (replace) {
|
||||
effects.remove(record, record === selectedRecord);
|
||||
this.byMmsi.delete(mmsi);
|
||||
effects.removed(mmsi);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!complete &&
|
||||
Number.isFinite(record.receivedAtMs) &&
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
FOCUS_UPDATE_MS,
|
||||
LABEL_GRID_PX,
|
||||
CARD_MIN_SEP_PX,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
/** How often vessels move on as the world clock plays (wall ms). */
|
||||
const FOLLOW_POSITION_MS = 250;
|
||||
|
||||
export function createRendering({
|
||||
vesselState,
|
||||
@@ -42,20 +47,16 @@ export function createRendering({
|
||||
visual = { billboard: record.billboard || null };
|
||||
visualRecords.set(record, visual);
|
||||
}
|
||||
// Following the world clock, a vessel is drawn where it was at the time.
|
||||
const { lat, lon } = followsClock(vesselState)
|
||||
? vesselPositionAt(record, vesselState.worldNow())
|
||||
: record;
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
components.tracking.currentGeoidN(record.lat, record.lon),
|
||||
components.tracking.currentGeoidN(lat, lon),
|
||||
VESSEL_LIFT_M,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
heightM,
|
||||
);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
0,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
visual.position,
|
||||
new Cesium.Cartesian3(),
|
||||
@@ -212,9 +213,27 @@ export function createRendering({
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
updateVisibility(),
|
||||
);
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
if (followsClock(vesselState)) followClockPositions();
|
||||
updateVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// As the world clock plays, vessels between two fixes move on along them.
|
||||
let followedAtWallMs = -Infinity;
|
||||
let followedAtMs = null;
|
||||
function followClockPositions() {
|
||||
const wallMs = performance.now();
|
||||
if (wallMs - followedAtWallMs < FOLLOW_POSITION_MS) return;
|
||||
followedAtWallMs = wallMs;
|
||||
const atMs = vesselState.worldNow();
|
||||
if (atMs === followedAtMs) return;
|
||||
followedAtMs = atMs;
|
||||
for (const record of state.records.all) {
|
||||
if (!record.motion) continue;
|
||||
const visual = prepareRecordVisual(record);
|
||||
if (visual.billboard) visual.billboard.position = visual.position;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
|
||||
@@ -6,16 +6,25 @@ export function createVesselSnapshotRenderer({
|
||||
tracking,
|
||||
selection,
|
||||
cards,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
||||
/** `replace`: the rows are the world at another time (see VesselRecords). */
|
||||
function reconcileVessels(
|
||||
viewer,
|
||||
rows,
|
||||
{ complete = true, replace = false } = {},
|
||||
) {
|
||||
rendering.ensureCollections(viewer);
|
||||
const occluder = rendering.makeOccluder();
|
||||
const follow = followsClock();
|
||||
records.reconcile(
|
||||
rows,
|
||||
{
|
||||
complete,
|
||||
selectedRecord: state.selectedRecord,
|
||||
cap: rendering.renderRowLimit(),
|
||||
...(replace && { replace }),
|
||||
...(follow && { follow }),
|
||||
},
|
||||
{
|
||||
add(record) {
|
||||
@@ -54,6 +63,9 @@ export function createVesselSnapshotRenderer({
|
||||
},
|
||||
},
|
||||
);
|
||||
// The selected vessel at the new time: its trail starts there.
|
||||
if (replace && state.selectedRecord && state.trailMmsi)
|
||||
tracking.startSelectedVesselTrail(state.selectedRecord);
|
||||
state.lastVisibilityUpdate = 0;
|
||||
rendering.updateVisibility(true);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createVesselFeed } from './ingestion.js';
|
||||
import { VesselRecords } from './records.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||
import { DEFAULT_AIS_RUNTIME, followsClock } from './policy.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createVesselState({ source, services }) {
|
||||
const {
|
||||
@@ -31,6 +32,19 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
|
||||
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock in policy.js. */
|
||||
vesselState.timeline = null;
|
||||
|
||||
/** The world time the layer draws at while it follows the clock. */
|
||||
vesselState.worldNow = worldTimeFor('ais-live-vessels');
|
||||
|
||||
/** The time records are judged at: the world's while following the clock. */
|
||||
vesselState.recordsNow = () =>
|
||||
followsClock(vesselState)
|
||||
? vesselState.worldNow()
|
||||
: vesselState._aisRuntime.now();
|
||||
|
||||
vesselState._aisSessionSequence = 0;
|
||||
|
||||
/**
|
||||
@@ -48,7 +62,7 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState.state = {
|
||||
feed: createVesselFeed(),
|
||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||
records: new VesselRecords({ now: () => vesselState.recordsNow() }),
|
||||
viewer: null,
|
||||
|
||||
billboardCollection: null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
TRAIL_MAX_POINTS,
|
||||
TRAIL_MIN_MOVE_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTracking({
|
||||
@@ -116,12 +117,17 @@ export function createTracking({
|
||||
|
||||
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
||||
const owner = state.trailAbort;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
|
||||
const signal = owner
|
||||
? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)])
|
||||
: AbortSignal.timeout(8000);
|
||||
let samples = null;
|
||||
try {
|
||||
const track = await vesselState._source.getTrack?.(reference, { signal });
|
||||
const track = await vesselState._source.getTrack?.(
|
||||
reference,
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
samples = track?.records ?? null;
|
||||
} catch {
|
||||
return; // silent — keep the live-accumulated trail
|
||||
@@ -140,6 +146,12 @@ export function createTracking({
|
||||
const lat = Number(sample?.latitude);
|
||||
const lon = Number(sample?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Following the clock, an undated sample may be later than the time.
|
||||
if (
|
||||
atMs !== undefined &&
|
||||
!(Number.isFinite(sample?.observedAtMs) && sample.observedAtMs <= atMs)
|
||||
)
|
||||
continue;
|
||||
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
|
||||
// the live vertices so the spliced trail is height-continuous.
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
* 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.
|
||||
*
|
||||
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
||||
* answer a snapshot at a given time, so the layer follows the world clock.
|
||||
* The query then carries `atMs` (absent while the clock is live, so a live
|
||||
* request is unchanged), and the snapshot is the world as it stood at that
|
||||
* time: `observedAtMs` <= `atMs`, no record observed later than `atMs`,
|
||||
* freshness and staleness judged against `atMs`, and `atMs` echoed back. A
|
||||
* vessel snapshot at a time reports `transportStatus: 'live'`. getTrack then
|
||||
* receives `{ signal, atMs }` and returns only points at or before `atMs`.
|
||||
*/
|
||||
// Messages are English data (AI tools read them too); the Layers panel shows
|
||||
// them translated by their shape (translateFeedError in src/ui/statusText.js),
|
||||
@@ -140,3 +149,31 @@ export function httpError(response, source) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline a source declares, or null when it has none or it is
|
||||
* malformed: `{ coverage(), stepMs, subscribe? }`. `coverage()` returns the
|
||||
* span `{start, end}` (epoch ms) the source can answer a snapshot for, or
|
||||
* null while it does not know yet; `stepMs` is the spacing of its snapshots;
|
||||
* the optional `subscribe(listener)` tells of a change in coverage and
|
||||
* returns the unsubscribe.
|
||||
* @param {object|null|undefined} source
|
||||
* @returns {{coverage: () => ({start: number, end: number}|null),
|
||||
* stepMs: number, subscribe: ((listener: () => void) => () => void)|null}|null}
|
||||
*/
|
||||
export function readSnapshotTimeline(source) {
|
||||
const timeline = source?.timeline;
|
||||
if (!timeline || typeof timeline !== 'object') return null;
|
||||
if (typeof timeline.coverage !== 'function') return null;
|
||||
const stepMs = timeline.stepMs;
|
||||
if (!Number.isFinite(stepMs) || stepMs <= 0) return null;
|
||||
if (timeline.subscribe != null && typeof timeline.subscribe !== 'function')
|
||||
return null;
|
||||
return {
|
||||
coverage: () => timeline.coverage(),
|
||||
stepMs,
|
||||
subscribe: timeline.subscribe
|
||||
? (listener) => timeline.subscribe(listener)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeVesselObservation,
|
||||
openSkySnapshot,
|
||||
readsbSnapshot,
|
||||
readSnapshotTimeline,
|
||||
} from './index.js';
|
||||
|
||||
const now = 1800000000000;
|
||||
@@ -486,3 +487,41 @@ test('a photo enrichment outage rejects without echoing the body', async () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a source declares a timeline only with a coverage reader and a positive step', () => {
|
||||
assert.equal(readSnapshotTimeline(null), null);
|
||||
assert.equal(readSnapshotTimeline({ getSnapshot() {} }), null);
|
||||
assert.equal(readSnapshotTimeline({ timeline: { stepMs: 10_000 } }), null);
|
||||
assert.equal(
|
||||
readSnapshotTimeline({ timeline: { coverage: () => null, stepMs: 0 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readSnapshotTimeline({
|
||||
timeline: { coverage: () => null, stepMs: 10_000, subscribe: 'no' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const span = { start: 1000, end: 5000 };
|
||||
const plain = readSnapshotTimeline({
|
||||
timeline: { coverage: () => span, stepMs: 10_000 },
|
||||
});
|
||||
assert.equal(plain.stepMs, 10_000);
|
||||
assert.deepEqual(plain.coverage(), span);
|
||||
assert.equal(plain.subscribe, null);
|
||||
const listeners = [];
|
||||
const told = readSnapshotTimeline({
|
||||
timeline: {
|
||||
coverage: () => null,
|
||||
stepMs: 5_000,
|
||||
subscribe(listener) {
|
||||
listeners.push(listener);
|
||||
return () => listeners.splice(listeners.indexOf(listener), 1);
|
||||
},
|
||||
},
|
||||
});
|
||||
const stop = told.subscribe(() => {});
|
||||
assert.equal(listeners.length, 1);
|
||||
stop();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export { LiveSourceError } from './contract.js';
|
||||
export { LiveSourceError, readSnapshotTimeline } from './contract.js';
|
||||
export {
|
||||
normalizeOpenSkyAircraft,
|
||||
normalizeReadsbAircraft,
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { readSnapshotTimeline } from '../sources/live/contract.js';
|
||||
|
||||
/** The longest play waits on a snapshot before it goes on without it. */
|
||||
export const SNAPSHOT_WAIT_MS = 8_000;
|
||||
/** How long a snapshot that failed to load waits before it is asked again. */
|
||||
export const SNAPSHOT_RETRY_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Let a layer of live contacts (flights, military aircraft, vessels) follow
|
||||
* the world clock when, and only when, its source declares a timeline: it can
|
||||
* answer a snapshot at a given time (see readSnapshotTimeline). With no
|
||||
* timeline this does nothing at all: the layer never follows the clock and
|
||||
* stays live.
|
||||
*
|
||||
* While the layer is on, it follows the clock. At the live edge the layer
|
||||
* polls as it always has. Away from it the layer is `active()`: it stops
|
||||
* polling and shows the snapshots this asks for.
|
||||
*
|
||||
* - A jump (a seek, time going backwards, leaving live) drops what loads,
|
||||
* holds play, and asks for the snapshot at the step at or before the time;
|
||||
* `load(atMs, {signal, replace: true})` puts it in place of every contact.
|
||||
* - Playing on, it stays a snapshot ahead of the time: it asks for the next
|
||||
* step as the clock nears the newest it has, and play does not pass a
|
||||
* snapshot still loading (for at most SNAPSHOT_WAIT_MS). Those snapshots
|
||||
* are applied as polls are (`replace: false`), so contacts move on smoothly.
|
||||
* - Outside the source's coverage the layer is `clear()`ed and nothing loads;
|
||||
* while the coverage is not known yet nothing loads and nothing is cleared.
|
||||
* - Back at live, the layer is `clear()`ed and `resumeLive()` polls at once.
|
||||
*
|
||||
* `load` resolves true once the snapshot is applied and false (or rejects)
|
||||
* when it could not be; it must not apply anything once `signal` aborts. A
|
||||
* rejection named AbortError is a request the source dropped for a newer
|
||||
* one: it is not a failure, and nothing is cleared.
|
||||
*
|
||||
* @param {{id: string, clock: ReturnType<typeof import('./worldClock.js').createWorldClock>,
|
||||
* source: () => object|null,
|
||||
* load: (atMs: number, options: {signal: AbortSignal, replace: boolean}) => Promise<boolean>,
|
||||
* clear: () => void, resumeLive: () => void,
|
||||
* now?: () => number, setTimeout?: typeof globalThis.setTimeout,
|
||||
* clearTimeout?: typeof globalThis.clearTimeout}} options `source`
|
||||
* returns the layer's current source; it is read when the layer starts.
|
||||
*/
|
||||
export function createSnapshotTimeline({
|
||||
id,
|
||||
clock,
|
||||
source,
|
||||
load,
|
||||
clear,
|
||||
resumeLive,
|
||||
now = () => globalThis.performance.now(),
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
}) {
|
||||
let timeline = null;
|
||||
let releases = [];
|
||||
let started = false;
|
||||
// Whether the layer was away from live when it last heard of the clock.
|
||||
let wasActive = false;
|
||||
let lastJumps = null;
|
||||
let lastTimeMs = null;
|
||||
// Each request's number; an answer to an older one is dropped.
|
||||
let sequence = 0;
|
||||
let inflight = null;
|
||||
// The time of the newest snapshot applied since the last jump.
|
||||
let lead = null;
|
||||
// Cleared for a time outside coverage, so it is not cleared again.
|
||||
let empty = false;
|
||||
let waiting = null;
|
||||
let waitTimer = null;
|
||||
// Play waited its longest on the snapshot loading: it goes on without it.
|
||||
let capped = false;
|
||||
let gated;
|
||||
let failedUntil = -Infinity;
|
||||
let retryTimer = null;
|
||||
|
||||
const span = () => {
|
||||
let covered;
|
||||
try {
|
||||
covered = timeline.coverage();
|
||||
} catch (error) {
|
||||
console.error(`Snapshot coverage failed: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
return covered &&
|
||||
Number.isFinite(covered.start) &&
|
||||
Number.isFinite(covered.end) &&
|
||||
covered.end >= covered.start
|
||||
? covered
|
||||
: null;
|
||||
};
|
||||
const within = (timeMs, covered) =>
|
||||
timeMs >= covered.start && timeMs <= covered.end;
|
||||
const stepAtOrBefore = (timeMs) =>
|
||||
Math.floor(timeMs / timeline.stepMs) * timeline.stepMs;
|
||||
const playsOn = (state) => state.playing && !state.stepping;
|
||||
|
||||
const setGate = (ms) => {
|
||||
if (ms === gated) return;
|
||||
gated = ms;
|
||||
clock.gate(id, ms);
|
||||
};
|
||||
const stopWaiting = () => {
|
||||
const release = waiting;
|
||||
waiting = null;
|
||||
if (waitTimer !== null) clearTimeout(waitTimer);
|
||||
waitTimer = null;
|
||||
release?.();
|
||||
};
|
||||
const startWaiting = () => {
|
||||
if (waiting || capped) return;
|
||||
waiting = clock.wait(id);
|
||||
waitTimer = setTimeout(() => {
|
||||
waitTimer = null;
|
||||
capped = true;
|
||||
stopWaiting();
|
||||
setGate(null);
|
||||
}, SNAPSHOT_WAIT_MS);
|
||||
};
|
||||
const stopRetry = () => {
|
||||
if (retryTimer !== null) clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
// Drop what loads; nothing that answers afterwards applies.
|
||||
const cancel = () => {
|
||||
sequence += 1;
|
||||
inflight?.controller.abort();
|
||||
inflight = null;
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
stopRetry();
|
||||
};
|
||||
const clearOnce = () => {
|
||||
if (empty) return;
|
||||
empty = true;
|
||||
clear();
|
||||
};
|
||||
|
||||
function request(atMs, replace) {
|
||||
// One load at a time: an older one is dropped before a newer starts.
|
||||
inflight?.controller.abort();
|
||||
const mine = ++sequence;
|
||||
const controller = new AbortController();
|
||||
inflight = { atMs, controller };
|
||||
const failed = () => {
|
||||
inflight = null;
|
||||
// A time that could not be shown shows nothing, not another time.
|
||||
if (replace) clearOnce();
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
failedUntil = now() + SNAPSHOT_RETRY_MS;
|
||||
stopRetry();
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (started) follow(clock.getState());
|
||||
}, SNAPSHOT_RETRY_MS);
|
||||
};
|
||||
// The source dropped this request for a newer one of its own: nothing
|
||||
// failed, so nothing is cleared or held back; the time is asked for again
|
||||
// as the clock next moves, or shortly.
|
||||
const superseded = () => {
|
||||
inflight = null;
|
||||
stopRetry();
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (started) follow(clock.getState());
|
||||
}, SNAPSHOT_RETRY_MS);
|
||||
};
|
||||
Promise.resolve()
|
||||
.then(() => load(atMs, { signal: controller.signal, replace }))
|
||||
.then(
|
||||
(applied) => {
|
||||
if (mine !== sequence || controller.signal.aborted) return;
|
||||
if (applied === false) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
inflight = null;
|
||||
lead = atMs;
|
||||
empty = false;
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
follow(clock.getState());
|
||||
},
|
||||
(error) => {
|
||||
if (mine !== sequence || controller.signal.aborted) return;
|
||||
if (error?.name === 'AbortError') {
|
||||
superseded();
|
||||
return;
|
||||
}
|
||||
console.warn(`Snapshot at ${atMs} failed: ${id}`, error);
|
||||
failed();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Show the time afresh: the snapshot at its step replaces every contact.
|
||||
function jumpTo(timeMs) {
|
||||
cancel();
|
||||
lead = null;
|
||||
setGate(null);
|
||||
const covered = span();
|
||||
if (!covered) return;
|
||||
if (!within(timeMs, covered)) {
|
||||
clearOnce();
|
||||
return;
|
||||
}
|
||||
startWaiting();
|
||||
request(Math.max(covered.start, stepAtOrBefore(timeMs)), true);
|
||||
}
|
||||
|
||||
// The time moved on by itself, or nothing moved: load what it now needs.
|
||||
function keepUp(timeMs, state) {
|
||||
const covered = span();
|
||||
if (!covered) return;
|
||||
if (!within(timeMs, covered)) {
|
||||
cancel();
|
||||
lead = null;
|
||||
setGate(null);
|
||||
clearOnce();
|
||||
return;
|
||||
}
|
||||
if (inflight) {
|
||||
if (playsOn(state) && timeMs >= inflight.atMs) startWaiting();
|
||||
return;
|
||||
}
|
||||
if (now() < failedUntil) return;
|
||||
if (lead === null) {
|
||||
jumpTo(timeMs);
|
||||
return;
|
||||
}
|
||||
if (!playsOn(state)) {
|
||||
setGate(null);
|
||||
return;
|
||||
}
|
||||
const step = timeline.stepMs;
|
||||
if (lead - timeMs >= step) {
|
||||
setGate(lead);
|
||||
return;
|
||||
}
|
||||
const next = Math.min(
|
||||
covered.end,
|
||||
Math.max(lead + step, stepAtOrBefore(timeMs) + step),
|
||||
);
|
||||
if (next <= lead) {
|
||||
setGate(null);
|
||||
return;
|
||||
}
|
||||
setGate(capped ? null : next);
|
||||
request(next, false);
|
||||
}
|
||||
|
||||
function follow(state) {
|
||||
if (!started) return;
|
||||
if (state.live) {
|
||||
lastJumps = state.jumps;
|
||||
lastTimeMs = null;
|
||||
if (!wasActive) return;
|
||||
wasActive = false;
|
||||
cancel();
|
||||
setGate(null);
|
||||
lead = null;
|
||||
empty = false;
|
||||
failedUntil = -Infinity;
|
||||
clear();
|
||||
resumeLive();
|
||||
return;
|
||||
}
|
||||
const timeMs = state.timeMs;
|
||||
const jumped =
|
||||
!wasActive ||
|
||||
state.jumps !== lastJumps ||
|
||||
(lastTimeMs !== null && timeMs < lastTimeMs);
|
||||
wasActive = true;
|
||||
lastJumps = state.jumps;
|
||||
lastTimeMs = timeMs;
|
||||
if (jumped) {
|
||||
failedUntil = -Infinity;
|
||||
jumpTo(timeMs);
|
||||
} else keepUp(timeMs, state);
|
||||
}
|
||||
|
||||
return {
|
||||
/** The source's timeline while the layer follows the clock, else null. */
|
||||
get timeline() {
|
||||
return started ? timeline : null;
|
||||
},
|
||||
/** Whether the layer shows the snapshots this loads instead of polling. */
|
||||
active: () => started && !clock.isLive(),
|
||||
/** The layer is on: follow the clock if its source declares a timeline. */
|
||||
start() {
|
||||
if (started) return;
|
||||
timeline = readSnapshotTimeline(source());
|
||||
if (!timeline) return;
|
||||
started = true;
|
||||
wasActive = false;
|
||||
lastJumps = null;
|
||||
lastTimeMs = null;
|
||||
lead = null;
|
||||
empty = false;
|
||||
failedUntil = -Infinity;
|
||||
gated = undefined;
|
||||
releases = [
|
||||
clock.hold(id),
|
||||
clock.register({ id, coverage: () => span() }),
|
||||
clock.subscribe(follow),
|
||||
];
|
||||
const unsubscribe = timeline.subscribe?.(() => clock.coverageChanged());
|
||||
if (typeof unsubscribe === 'function') releases.push(unsubscribe);
|
||||
follow(clock.getState());
|
||||
},
|
||||
/** Load the time shown afresh, as when the area the layer asks for moved. */
|
||||
reload() {
|
||||
if (!started || clock.isLive()) return;
|
||||
failedUntil = -Infinity;
|
||||
jumpTo(clock.getState().timeMs);
|
||||
},
|
||||
/** The layer is off: stop following and drop what loads. */
|
||||
stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
cancel();
|
||||
setGate(null);
|
||||
wasActive = false;
|
||||
lead = null;
|
||||
for (const release of releases.splice(0).reverse()) release();
|
||||
timeline = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWorldClock } from './worldClock.js';
|
||||
import {
|
||||
createSnapshotTimeline,
|
||||
SNAPSHOT_RETRY_MS,
|
||||
SNAPSHOT_WAIT_MS,
|
||||
} from './snapshotTimeline.js';
|
||||
|
||||
const STEP = 10_000;
|
||||
const WALL = Date.UTC(2026, 10, 1, 12);
|
||||
|
||||
function harness({ timeline = true, coverage } = {}) {
|
||||
const t = {
|
||||
wall: WALL,
|
||||
mono: 1_000,
|
||||
timers: new Map(),
|
||||
nextId: 1,
|
||||
};
|
||||
const clock = createWorldClock({
|
||||
now: () => t.wall,
|
||||
monotonicNow: () => t.mono,
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
requestFrame: null,
|
||||
cancelFrame: null,
|
||||
});
|
||||
let span =
|
||||
coverage === undefined ? { start: WALL - 3_600_000, end: WALL } : coverage;
|
||||
const coverageListeners = new Set();
|
||||
const source = {
|
||||
getSnapshot() {},
|
||||
...(timeline && {
|
||||
timeline: {
|
||||
coverage: () => span,
|
||||
stepMs: STEP,
|
||||
subscribe(listener) {
|
||||
coverageListeners.add(listener);
|
||||
return () => coverageListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const timers = {
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = t.nextId++;
|
||||
t.timers.set(id, { fn, at: t.mono + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => t.timers.delete(id),
|
||||
};
|
||||
const follower = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock,
|
||||
source: () => source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
calls.push({ type: 'load', atMs, replace });
|
||||
pending.push({ atMs, replace, signal, resolve, reject });
|
||||
}),
|
||||
clear: () => calls.push({ type: 'clear' }),
|
||||
resumeLive: () => calls.push({ type: 'live' }),
|
||||
now: () => t.mono,
|
||||
...timers,
|
||||
});
|
||||
return {
|
||||
t,
|
||||
clock,
|
||||
follower,
|
||||
calls,
|
||||
pending,
|
||||
setCoverage(next) {
|
||||
span = next;
|
||||
for (const listener of coverageListeners) listener();
|
||||
},
|
||||
coverageListeners,
|
||||
advance(ms) {
|
||||
t.wall += ms;
|
||||
t.mono += ms;
|
||||
for (const [id, timer] of [...t.timers]) {
|
||||
if (timer.at > t.mono) continue;
|
||||
t.timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
async answer(index = 0, value = true) {
|
||||
pending.splice(index, 1)[0].resolve(value);
|
||||
await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const loads = (calls) => calls.filter((call) => call.type === 'load');
|
||||
|
||||
test('a source with no timeline never follows the clock nor asks for a time', async () => {
|
||||
const h = harness({ timeline: false });
|
||||
h.follower.start();
|
||||
assert.equal(h.follower.timeline, null);
|
||||
assert.equal(h.clock.follows('flights'), false);
|
||||
assert.equal(h.clock.coverage(), null);
|
||||
h.clock.seek(WALL - 600_000);
|
||||
h.clock.play();
|
||||
h.advance(30_000);
|
||||
h.clock.goLive();
|
||||
await flush();
|
||||
assert.equal(h.follower.active(), false);
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('at the live edge a layer with a timeline only follows and registers', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
await flush();
|
||||
assert.equal(h.clock.follows('flights'), true);
|
||||
assert.deepEqual(h.clock.coverage(), {
|
||||
start: WALL - 3_600_000,
|
||||
end: WALL,
|
||||
marks: [],
|
||||
});
|
||||
assert.equal(h.follower.active(), false);
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.follower.stop();
|
||||
assert.equal(h.clock.follows('flights'), false);
|
||||
assert.equal(h.clock.coverage(), null);
|
||||
});
|
||||
|
||||
test('a seek asks for the step at or before the time, holding play while it loads', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const at = WALL - 600_000 + 4_321;
|
||||
h.clock.seek(at);
|
||||
await flush();
|
||||
assert.equal(h.follower.active(), true);
|
||||
assert.deepEqual(loads(h.calls), [
|
||||
{ type: 'load', atMs: WALL - 600_000, replace: true },
|
||||
]);
|
||||
h.clock.play();
|
||||
h.advance(2_000);
|
||||
assert.equal(h.clock.now(), at, 'play waits on the snapshot');
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
await h.answer();
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a seek back replaces the contacts and an older answer is dropped', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
const first = h.pending[0];
|
||||
h.clock.seek(WALL - 1_200_000);
|
||||
await flush();
|
||||
assert.equal(first.signal.aborted, true, 'the older load is cancelled');
|
||||
first.resolve(true);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
loads(h.calls).map(({ atMs, replace }) => [atMs, replace]),
|
||||
[
|
||||
[WALL - 600_000, true],
|
||||
[WALL - 1_200_000, true],
|
||||
],
|
||||
);
|
||||
await h.answer(1);
|
||||
// Going backwards in time is a jump too, even without a seek.
|
||||
h.clock.seek(WALL - 1_300_000);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).at(-1).replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('playing on asks for the next step and play does not pass it unloaded', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const at = WALL - 600_000;
|
||||
h.clock.seek(at);
|
||||
await flush();
|
||||
await h.answer();
|
||||
h.clock.play();
|
||||
h.advance(100);
|
||||
// A tick: the clock tells its listeners as play goes on.
|
||||
h.clock.coverageChanged();
|
||||
await flush();
|
||||
assert.deepEqual(loads(h.calls).at(-1), {
|
||||
type: 'load',
|
||||
atMs: at + STEP,
|
||||
replace: false,
|
||||
});
|
||||
h.advance(STEP * 2);
|
||||
assert.equal(h.clock.now(), at + STEP, 'gated at the step still loading');
|
||||
h.clock.coverageChanged();
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
await h.answer(h.pending.length - 1);
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
assert.deepEqual(loads(h.calls).at(-1), {
|
||||
type: 'load',
|
||||
atMs: at + 2 * STEP,
|
||||
replace: false,
|
||||
});
|
||||
// A step that never comes holds play for at most SNAPSHOT_WAIT_MS.
|
||||
h.advance(STEP * 2);
|
||||
h.clock.coverageChanged();
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
h.advance(SNAPSHOT_WAIT_MS);
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
h.advance(1_000);
|
||||
assert.ok(h.clock.now() > at + 2 * STEP, 'play went on without it');
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a failed snapshot clears the time and is asked again later', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
await h.answer(0, false);
|
||||
assert.deepEqual(h.calls.at(-1), { type: 'clear' });
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.advance(SNAPSHOT_RETRY_MS);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, 2);
|
||||
assert.equal(loads(h.calls)[1].replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('outside coverage the layer is cleared and nothing loads; unknown coverage waits', async () => {
|
||||
const h = harness({ coverage: null });
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, [], 'no coverage yet: nothing loads or clears');
|
||||
h.setCoverage({ start: WALL - 3_600_000, end: WALL });
|
||||
await flush();
|
||||
assert.deepEqual(loads(h.calls), [
|
||||
{ type: 'load', atMs: WALL - 600_000, replace: true },
|
||||
]);
|
||||
await h.answer();
|
||||
h.clock.seek(WALL - 7_200_000);
|
||||
await flush();
|
||||
assert.deepEqual(h.calls.at(-1), { type: 'clear' });
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.follower.stop();
|
||||
assert.equal(h.coverageListeners.size, 0);
|
||||
});
|
||||
|
||||
test('back at live the layer is cleared and polls at once', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
const inflight = h.pending[0];
|
||||
h.clock.goLive();
|
||||
await flush();
|
||||
assert.equal(inflight.signal.aborted, true);
|
||||
assert.deepEqual(h.calls.slice(-2), [{ type: 'clear' }, { type: 'live' }]);
|
||||
assert.equal(h.follower.active(), false);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a load the source drops for a newer one is no failure: nothing clears, it is asked again', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
h.pending
|
||||
.shift()
|
||||
.reject(new DOMException('Superseded by a newer request', 'AbortError'));
|
||||
await flush();
|
||||
assert.equal(
|
||||
h.calls.some((call) => call.type === 'clear'),
|
||||
false,
|
||||
'the layer keeps what it shows',
|
||||
);
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.advance(SNAPSHOT_RETRY_MS);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, 2, 'the time is asked for again');
|
||||
assert.equal(loads(h.calls)[1].replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a layer never has two loads out: the older is aborted before a newer starts', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const live = () => h.pending.filter(({ signal }) => !signal.aborted).length;
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
await h.answer();
|
||||
h.clock.play();
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'the next step loads');
|
||||
h.follower.reload();
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'a reload drops it first');
|
||||
h.clock.seek(WALL - 900_000);
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'a seek drops it first');
|
||||
assert.equal(h.pending.length, 3);
|
||||
h.follower.stop();
|
||||
assert.equal(live(), 0);
|
||||
});
|
||||
+14
-4
@@ -8,8 +8,10 @@
|
||||
* clock holds a cursor, fixed or playing. Only the layers that follow the clock
|
||||
* move with the cursor; the rest keep reading the live edge through
|
||||
* `timeFor(id)`. On the page's clock the layers whose data has a time of its
|
||||
* own (the sky, satellites, launches and weather) follow it; live feeds such
|
||||
* as flights and vessels have no time to move to and stay live.
|
||||
* own (the sky, satellites, launches and weather) follow it. Flights,
|
||||
* military aircraft and vessels follow it only when their source declares a
|
||||
* timeline, that is, can answer a snapshot at a given time (see
|
||||
* src/time/snapshotTimeline.js); otherwise they stay live.
|
||||
*
|
||||
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
|
||||
* it: weather replay steps its observed frames itself and moves the clock as
|
||||
@@ -64,6 +66,9 @@ export function createWorldClock({
|
||||
let anchorMono = 0;
|
||||
let rate = 1;
|
||||
let playing = false;
|
||||
// How many times the cursor has jumped (a seek, a return to live), so a
|
||||
// layer can tell a jump from the cursor playing on.
|
||||
let jumps = 0;
|
||||
// The driver handle stepping the clock, or null.
|
||||
let active = null;
|
||||
let ticker = null;
|
||||
@@ -118,6 +123,7 @@ export function createWorldClock({
|
||||
buffering: continuous() && buffering(),
|
||||
following: followedIds(),
|
||||
supported: isSupported(),
|
||||
jumps,
|
||||
});
|
||||
// Each listener hears the state as it is when called. A change made from
|
||||
// inside a listener notifies everyone afresh, and the outer round stops, so
|
||||
@@ -182,6 +188,7 @@ export function createWorldClock({
|
||||
live = false;
|
||||
cursorMs = ms;
|
||||
anchorMono = monotonicNow();
|
||||
jumps += 1;
|
||||
changed();
|
||||
},
|
||||
/** Back to the wall clock; playback stops and its rate goes back to 1. */
|
||||
@@ -193,6 +200,7 @@ export function createWorldClock({
|
||||
playing = false;
|
||||
active = null;
|
||||
rate = 1;
|
||||
jumps += 1;
|
||||
// Its driven(false) finds nothing left to stop.
|
||||
stepping?.driver.pause();
|
||||
changed();
|
||||
@@ -300,8 +308,10 @@ export function createWorldClock({
|
||||
active = handle;
|
||||
playing = true;
|
||||
live = false;
|
||||
if (Number.isFinite(atMs)) cursorMs = atMs;
|
||||
else cursorMs ??= now();
|
||||
if (Number.isFinite(atMs)) {
|
||||
cursorMs = atMs;
|
||||
jumps += 1;
|
||||
} else cursorMs ??= now();
|
||||
anchorMono = monotonicNow();
|
||||
if (previous && previous !== handle) previous.driver.pause();
|
||||
changed();
|
||||
|
||||
@@ -387,3 +387,24 @@ test('playing, listeners hear of the time every animation frame', () => {
|
||||
clock.pause();
|
||||
assert.equal(frames.size, 0);
|
||||
});
|
||||
|
||||
test('a seek or a return to live counts as a jump; play does not', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
assert.equal(clock.getState().jumps, 0);
|
||||
clock.seek(t.wall - 60_000);
|
||||
assert.equal(clock.getState().jumps, 1);
|
||||
clock.seek(t.wall - 60_000);
|
||||
assert.equal(clock.getState().jumps, 1, 'the same paused time is no jump');
|
||||
clock.play();
|
||||
t.advance(1_000);
|
||||
t.timers.forEach(({ fn }) => fn());
|
||||
assert.equal(clock.getState().jumps, 1, 'playing on is no jump');
|
||||
clock.pause();
|
||||
clock.setRate(4);
|
||||
assert.equal(clock.getState().jumps, 1);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getState().jumps, 2);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getState().jumps, 2, 'already live is no jump');
|
||||
});
|
||||
@@ -740,7 +740,9 @@ export class LayerPanel {
|
||||
) {
|
||||
return `${source} · ${translateStatusMessage(stats.statusMessage.trim())}`;
|
||||
}
|
||||
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : t('never');
|
||||
const ago = stats.lastUpdate
|
||||
? this._timeAgo(stats.lastUpdate, stats.atMs)
|
||||
: t('never');
|
||||
if (stats.loading) {
|
||||
const loadingLabel =
|
||||
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
||||
@@ -783,7 +785,7 @@ export class LayerPanel {
|
||||
// row by the oldest of it, not by the load that re-served it.
|
||||
const dataAgo =
|
||||
Number(stats.oldestTileAt) > 0
|
||||
? this._timeAgo(stats.oldestTileAt)
|
||||
? this._timeAgo(stats.oldestTileAt, stats.atMs)
|
||||
: ago;
|
||||
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
||||
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
||||
@@ -808,8 +810,11 @@ export class LayerPanel {
|
||||
return t('{source} · {ago}', { source, ago });
|
||||
}
|
||||
|
||||
_timeAgo(timestamp) {
|
||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||
/** How long before `nowMs` (the layer's world time, else the wall clock). */
|
||||
_timeAgo(timestamp, nowMs) {
|
||||
const diff = Math.floor(
|
||||
((Number.isFinite(nowMs) ? nowMs : Date.now()) - timestamp) / 1000,
|
||||
);
|
||||
if (diff < 5) return t('just now');
|
||||
if (diff < 60) return t('{count}s ago', { count: diff });
|
||||
if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
|
||||
|
||||
Reference in new issue
Block a user