mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
AIS Vessels are keyed by the id their live source gives, with the MMSI as an attribute: an MMSI can be shared, spoofed or reused, so two vessels with one MMSI are now two vessels. Looking a vessel up or selecting it by MMSI still finds it. A share link made while a vessel is selected opens that vessel. A UUID vessel id travels as 16 bytes in a new thing field (13). A shared thing that is not reporting now says so in a sentence per kind, with its id (ICAO, NORAD, MMSI or vessel UUID) on a second line.
338 lines
10 KiB
JavaScript
338 lines
10 KiB
JavaScript
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);
|
|
}
|
|
|
|
/** Load the time shown afresh. */
|
|
function reloadShown() {
|
|
if (!started || clock.isLive()) return;
|
|
failedUntil = -Infinity;
|
|
jumpTo(clock.getState().timeMs);
|
|
}
|
|
|
|
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),
|
|
];
|
|
// A source tells of new coverage, or of a better answer for times it
|
|
// has answered; paused, a time already shown is loaded again.
|
|
const unsubscribe = timeline.subscribe?.(() => {
|
|
clock.coverageChanged();
|
|
if (!clock.isPlaying() && lead !== null && !inflight) reloadShown();
|
|
});
|
|
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: () => reloadShown(),
|
|
/** 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;
|
|
},
|
|
};
|
|
}
|