mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +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,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user