Merge pull request #1736 from bilawalsidhu/timeline/scrub-ages-cloud-gaps

Fix scrubs to live on long tracks, fire ages in a past window, and gaps between hourly cloud frames
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 15:06:42 -08:00
commit e53cfef1d1
12 files changed
+327 -15

No files matched your search

@@ -0,0 +1,9 @@
- Fix three time readings on the world timeline. A scrub near now goes live
only within two minutes of now, however long the track: with a layer whose
source covers years, any time in the last few weeks used to snap to live.
Fire cards and fire details count a detection's age from the end of the
24 hours shown, and from now only when live, so a window that ended months
ago reads "new 3h" rather than "new 105d". Weather and satellite cloud
frames stay shown, and the latest reads late, for at least one and a half
of the feed's own frame spacings, so a feed whose frames come an hour apart
no longer goes blank for half of every hour (samehkhamis, #1736).
+7 -2
View File
@@ -3455,7 +3455,8 @@ reads `NASA FIRMS · LIVE · updated …`. A source may instead answer the 24 ho
to a set time, naming its end as `at` (epoch ms): the row then reads to a set time, naming its end as `at` (epoch ms): the row then reads
`NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and `NASA FIRMS · 24 h to MM-DD HH:MM UTC`, `getStats()` reports `at`, and
`get_active_fires` says "in the 24 hours to" that time and reports it as `get_active_fires` says "in the 24 hours to" that time and reports it as
`window_end`. The dev proxy never sets `at`. `window_end`, and card and detail ages count back from that time instead of
now. The dev proxy never sets `at`.
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line.
@@ -8411,7 +8412,11 @@ clock drives all enabled observation rows. Earlier/Later use the sorted union of
non-suspended products' advertised times; each product selects its nearest frame non-suspended products' advertised times; each product selects its nearest frame
at or before the requested UTC time, never a future frame or an interpolation. at or before the requested UTC time, never a future frame or an interpolation.
The maximum gap is 30 minutes for radar, regional infrared and lightning, and The maximum gap is 30 minutes for radar, regional infrared and lightning, and
3 hours for hourly global infrared. A missing eligible frame hides that product 3 hours for hourly global infrared, but never less than one and a half of the
feed's median frame spacing over its latest frames, so a regional feed whose
frames come an hour apart holds each for 90 minutes; the latest frame reads late
after the same floor (20 minutes for radar and regional infrared, 45 for
lightning, 4 hours for global infrared). A missing eligible frame hides that product
and reports the gap; rows show actual displayed UTC time with synced/nearest labels. and reports the gap; rows show actual displayed UTC time with synced/nearest labels.
Manifest rollover preserves the requested history time, even when frames expire. Manifest rollover preserves the requested history time, even when frames expire.
+2 -1
View File
@@ -31,7 +31,8 @@ export function createCards({
function rebuildAmbientLabels() { function rebuildAmbientLabels() {
if (!layerState._viewer) return; if (!layerState._viewer) return;
const scene = layerState._viewer.scene; const scene = layerState._viewer.scene;
const now = Date.now(); // Ages count back from the time shown: the window's end, or now when live.
const now = components.model.ageReferenceMs();
const entries = []; const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = []; const accepted = [];
+19 -4
View File
@@ -343,7 +343,7 @@ export function createModel({
* (on top) and never distance-faded by the overlay. * (on top) and never distance-faded by the overlay.
* Exported for unit tests. * Exported for unit tests.
* @param {Object} fire - Detection record. * @param {Object} fire - Detection record.
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -384,9 +384,10 @@ export function createModel({
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail * Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
* "high · 14h · N20". Ages are computed against each detection's acquisition * "high · 14h · N20". Ages are computed against each detection's acquisition
* time — with the live feed everything reads under 24 h (and a stale cache * time — with the live feed everything reads under 24 h (and a stale cache
* reads truthfully old). Exported for unit tests. * reads truthfully old); `nowMs` is the time ages count back from, the end
* of the window shown (see {@link ageReferenceMs}). Exported for unit tests.
* @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate. * @param {{fire: Object, position: Cesium.Cartesian3}} candidate - Label candidate.
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -418,7 +419,7 @@ export function createModel({
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests. * it with {@link cellCardAccent}). Exported for unit tests.
* @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate
* @param {number} nowMs - Current epoch milliseconds. * @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry. * @returns {Object} firmsLabels entry.
*/ */
@@ -549,6 +550,19 @@ export function createModel({
return { unit: 'd', count: Math.round(hours / 24) }; return { unit: 'd', count: Math.round(hours / 24) };
} }
/**
* The time a card's ages count back from: the end of the 24 hours shown
* when the snapshot ends at a set time, now while it runs to now. A window
* that ended months ago then reads its fires as hours old, as they were.
* @param {number} [nowMs] - Current epoch milliseconds.
* @returns {number}
*/
function ageReferenceMs(nowMs = Date.now()) {
const at = layerState?._at;
return Number.isFinite(at) ? at : nowMs;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) { function formatAge(deltaMs) {
@@ -624,6 +638,7 @@ export function createModel({
cellCardAccent, cellCardAccent,
formatLatLon, formatLatLon,
formatFrp, formatFrp,
ageReferenceMs,
formatAge, formatAge,
formatAgeLabel, formatAgeLabel,
formatAgoMinutes, formatAgoMinutes,
+3 -1
View File
@@ -202,7 +202,9 @@ export function createSelection({
confidence: components.model.confidenceBucket(fire.confidence), confidence: components.model.confidenceBucket(fire.confidence),
age: age:
fire.acqMs > 0 fire.acqMs > 0
? components.model.formatAge(Date.now() - fire.acqMs) ? components.model.formatAge(
components.model.ageReferenceMs() - fire.acqMs,
)
: 'unknown', : 'unknown',
sensor: fire.sensor || 'unknown', sensor: fire.sensor || 'unknown',
}, },
+53
View File
@@ -1,8 +1,10 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getLocale, setLocale } from '../../i18n/index.js'; import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js'; import { createModel } from './model.js';
import { createQueries } from './queries.js'; import { createQueries } from './queries.js';
import { createCards } from './cards.js';
test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => { test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (context) => {
const now = 1800000000000; const now = 1800000000000;
@@ -42,3 +44,54 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte
setLocale(previousLocale); setLocale(previousLocale);
} }
}); });
test('fire cards count ages from the end of the window shown, and from now when live', (context) => {
const now = Date.UTC(2026, 10, 14, 12);
context.mock.method(Date, 'now', () => now);
// A window that ended months before now: its newest fire three hours earlier.
const at = Date.UTC(2026, 7, 1, 12);
const layerState = { _at: at };
const model = createModel({ layerState, services: { anchors: {} } });
const cell = {
count: 4,
maxFrp: 210,
newestAcqMs: at - 3 * 3_600_000,
latCell: 1,
lonCell: 2,
};
const previousLocale = getLocale();
try {
setLocale('en');
assert.equal(model.ageReferenceMs(), at);
const card = model.buildCellCard({ cell, position: {} }, model.ageReferenceMs());
assert.equal(card.details[0], 'max 210 MW · new 3h', 'not months old');
// The cards the scene shows use the same time.
const entries = [];
context.mock.method(Cesium.SceneTransforms, 'worldToWindowCoordinates', () => ({ x: 400, y: 300 }));
layerState._viewer = { scene: { canvas: { clientWidth: 800, clientHeight: 600 } } };
layerState._fireByCardId = new Map();
layerState._selectedFire = null;
layerState._labelCandidates = [{ cell, position: {} }];
layerState._labelLodDistance = 0;
const { rebuildAmbientLabels } = createCards({
layerState,
services: {},
components: { model, rendering: { fireHorizonOccluder: () => null } },
config: { overlayHost: { setEntries: (source, shown) => entries.push(...shown) } },
});
rebuildAmbientLabels();
assert.equal(entries.length, 1);
assert.equal(entries[0].details[0], 'max 210 MW · new 3h');
// Live, they count from now.
layerState._at = null;
cell.newestAcqMs = now - 5 * 3_600_000;
assert.equal(model.ageReferenceMs(), now);
entries.length = 0;
rebuildAmbientLabels();
assert.equal(entries[0].details[0], 'max 210 MW · new 5h');
} finally {
setLocale(previousLocale);
}
});
+46
View File
@@ -4,6 +4,52 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** How many of a feed's latest frames say how far apart its frames come. */
const SPACING_FRAMES = 12;
/** A frame holds this many of its feed's frame spacings before it lapses. */
const SPACING_SLACK = 1.5;
/**
* How far apart a feed's frames come: the median spacing of its latest
* frames, in ms, or null with fewer than two. Takes UTC times or epoch ms.
* @param {Array<string|number>} times
* @returns {number|null}
*/
export function frameSpacingMs(times) {
const at = [
...new Set(
(times ?? [])
.map((time) => (typeof time === 'number' ? time : Date.parse(time)))
.filter(Number.isFinite),
),
]
.sort((a, b) => a - b)
.slice(-(SPACING_FRAMES + 1));
if (at.length < 2) return null;
const gaps = at
.slice(1)
.map((time, i) => time - at[i])
.sort((a, b) => a - b);
const middle = gaps.length >> 1;
return gaps.length % 2 ? gaps[middle] : (gaps[middle - 1] + gaps[middle]) / 2;
}
/**
* A product's least time, `leastMs`, held to its feed's own frame spacing:
* never less than one and a half spacings, so a feed whose frames come an
* hour apart covers the whole hour between them, while one with frames
* minutes apart keeps `leastMs`.
* @param {number} leastMs
* @param {Array<string|number>} times
* @returns {number}
*/
export function atLeastFrameSpacing(leastMs, times) {
const spacing = frameSpacingMs(times);
return spacing === null
? leastMs
: Math.max(leastMs, spacing * SPACING_SLACK);
}
/** The longest play waits on a frame before it goes on without it. */ /** The longest play waits on a frame before it goes on without it. */
export const PLAY_WAIT_MS = 8_000; export const PLAY_WAIT_MS = 8_000;
// Playing on its own, the world does not wait on the frame it is at: that // Playing on its own, the world does not wait on the frame it is at: that
+64
View File
@@ -7,6 +7,8 @@ import {
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS, PLAY_WAIT_MS,
frameSpacingMs,
atLeastFrameSpacing,
} from './clock.js'; } from './clock.js';
import { createWorldClock } from '../../time/worldClock.js'; import { createWorldClock } from '../../time/worldClock.js';
@@ -94,6 +96,68 @@ test('union excludes suspended products; nearest selection never uses future or
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000); assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
}); });
test('a feed’s frame spacing is the median of its latest frames', () => {
assert.equal(frameSpacingMs([]), null);
assert.equal(frameSpacingMs([time(0)]), null);
assert.equal(frameSpacingMs([0, 10, 20, 30].map(time)), 10 * 60_000);
// Out of order, repeated, unparseable, and one late frame among many.
assert.equal(
frameSpacingMs([
time(60),
time(0),
'never',
time(0),
time(10),
time(20),
time(30),
]),
10 * 60_000,
);
assert.equal(frameSpacingMs([0, 60, 120].map(time)), 60 * 60_000);
assert.equal(frameSpacingMs([0, 10, 70, 130]), 60);
// Only the latest frames count: an hourly start gives way to ten minutes.
const times = [
...[-600, -540, -480, -420, -360, -300, -240, -180, -120, -60],
...[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120],
].map(time);
assert.equal(frameSpacingMs(times), 10 * 60_000);
});
test('a product’s gap is at least one and a half of its feed’s frame spacings', (t) => {
const tenMinutes = [0, 10, 20, 30].map(time);
const hourly = [0, 60, 120].map(time);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, tenMinutes),
REGIONAL_INFRARED_MAX_GAP_MS,
'frames minutes apart keep the product’s own gap',
);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly),
90 * 60_000,
);
assert.equal(
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, [time(0)]),
REGIONAL_INFRARED_MAX_GAP_MS,
);
assert.equal(
atLeastFrameSpacing(GLOBAL_INFRARED_MAX_GAP_MS, hourly),
GLOBAL_INFRARED_MAX_GAP_MS,
);
// Selected against it, an hourly feed has a frame all through each hour.
const { clock } = harness(t);
const clouds = product(
'clouds',
[0, 60, 120],
atLeastFrameSpacing(REGIONAL_INFRARED_MAX_GAP_MS, hourly),
);
clock.register(clouds);
assert.equal(clock.selectFor('clouds', time(45)), time(0));
assert.equal(clock.selectFor('clouds', time(119)), time(60));
assert.equal(clock.selectFor('clouds', time(210)), time(120));
assert.equal(clock.selectFor('clouds', time(211)), null);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => { test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t); const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]); const radar = product('radar', [0, 10, 20]);
+26 -6
View File
@@ -8,6 +8,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
atLeastFrameSpacing,
} from './clock.js'; } from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js'; import { formatScaleLabel } from '../../ui/unitLabels.js';
@@ -96,7 +97,10 @@ export function createWeatherLayer({
let unregisterClock = null; let unregisterClock = null;
let frameRequest = null; let frameRequest = null;
let noFrame = false; let noFrame = false;
const maxGap = () => // A product's own gap, and the delay after which its latest frame reads
// late. A feed whose frames come further apart holds each frame, and waits
// for the next, as long as its frames are apart (see atLeastFrameSpacing).
const productGap = () =>
radar radar
? RADAR_MAX_GAP_MS ? RADAR_MAX_GAP_MS
: lightning : lightning
@@ -104,6 +108,23 @@ export function createWeatherLayer({
: product === 'clouds' : product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS ? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS; : REGIONAL_INFRARED_MAX_GAP_MS;
const productDelay = () =>
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
// Read for every frame a selection weighs, so worked out once per product
// and frame list.
let spaced = null;
const spacedTimes = () => {
const times = manifest?.times ?? [];
if (spaced?.times !== times || spaced?.product !== product)
spaced = {
times,
product,
gap: atLeastFrameSpacing(productGap(), times),
delay: atLeastFrameSpacing(productDelay(), times),
};
return spaced;
};
const maxGap = () => spacedTimes().gap;
const isLatest = () => const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest; clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () => const getHost = () =>
@@ -117,8 +138,7 @@ export function createWeatherLayer({
}); });
const observationDelayed = () => const observationDelayed = () =>
manifest?.latest && manifest?.latest &&
Date.now() - Date.parse(manifest.latest) > Date.now() - Date.parse(manifest.latest) > spacedTimes().delay;
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => { const stop = () => {
if (clock) return; if (clock) return;
playing = false; playing = false;
@@ -544,11 +564,11 @@ export function createWeatherLayer({
shared.products.find((entry) => entry.id === id)?.selected === null shared.products.find((entry) => entry.id === id)?.selected === null
? maxGap() / 60_000 < 60 ? maxGap() / 60_000 < 60
? t('No frame within {gap} min of {time}', { ? t('No frame within {gap} min of {time}', {
gap: maxGap() / 60_000, gap: Math.round(maxGap() / 60_000),
time: utc(shared.target), time: utc(shared.target),
}) })
: t('No frame within {gap} h of {time}', { : t('No frame within {gap} h of {time}', {
gap: maxGap() / 3600_000, gap: Math.round(maxGap() / 360_000) / 10,
time: utc(shared.target), time: utc(shared.target),
}) })
: null; : null;
@@ -688,7 +708,7 @@ export function createWeatherLayer({
? t('Global · 60°S–60°N') ? t('Global · 60°S–60°N')
: t('North America')), : t('North America')),
shownTime: time, shownTime: time,
maxGapMinutes: maxGap() / 60_000, maxGapMinutes: Math.round(maxGap() / 60_000),
detail, detail,
status: status:
missing || missing ||
+57
View File
@@ -1949,6 +1949,63 @@ test('satellite global selection uses the three-hour gap and preserves the share
assert.equal(params.target, undefined); assert.equal(params.target, undefined);
}); });
test('satellite clouds from an hourly feed draw all through each hour and read late only past it', async (t) => {
const clock = createWeatherClock();
const now = Date.now();
const hour = 3_600_000;
const top = Math.floor(now / hour) * hour;
// Frames an hour apart, the latest forty minutes old.
const hourly = [3, 2, 1].map((back) =>
new Date(top - back * hour + 20 * 60_000).toISOString(),
);
const latest = hourly.at(-1);
const h = layerHarness({
clock,
id: 'weather-satellite',
feed: {
getSnapshot: async ({ product }) => ({
...snapshot,
product,
times: hourly,
latest,
}),
},
});
t.after(() => {
h.layer.destroy();
clock.destroy();
});
t.mock.method(Date, 'now', () => Date.parse(latest) + 40 * 60_000);
const update = h.layer.update();
await flush();
h.stages.at(-1).finish();
await update;
assert.equal(h.layer.getParams().product, 'clouds-regional');
assert.equal(h.layer.getRowControls().summary.maxGapMinutes, 90);
assert.equal(
h.layer.getStats().stale,
false,
'forty minutes is within the hour',
);
// Forty-five minutes past a frame, between it and the next, it still shows.
const between = clock.setTarget(
new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(),
);
await flush();
h.stages.at(-1).finish();
await between;
assert.equal(h.layer.getDiagnostics().hidden, false);
assert.equal(h.stages.at(-1).time, hourly[0]);
t.mock.method(Date, 'now', () => Date.parse(latest) + 100 * 60_000);
assert.equal(
h.layer.getStats().stale,
true,
'past one and a half hours, late',
);
});
test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => { test('switching to history cancels a still-loading latest refresh and a later target cancels the first history load', async (t) => {
const clock = createWeatherClock(); const clock = createWeatherClock();
const h = layerHarness({ clock }); const h = layerHarness({ clock });
+6 -1
View File
@@ -8,6 +8,11 @@ const LIVE_TICK_MS = 1000;
const LIVE_EDGE = 0.005; const LIVE_EDGE = 0.005;
/** The least that counts as at now, past the readout's own second. */ /** The least that counts as at now, past the readout's own second. */
const LIVE_EDGE_MS = 5000; const LIVE_EDGE_MS = 5000;
/**
* The most that counts as at now, however long the track: on a track that
* runs over years, a share of it would take whole days back to live.
*/
const LIVE_EDGE_MAX_MS = 2 * 60_000;
/** The speeds play offers, in world ms per real ms. */ /** The speeds play offers, in world ms per real ms. */
export const WORLD_TIME_SPEEDS = Object.freeze({ export const WORLD_TIME_SPEEDS = Object.freeze({
real: 1, real: 1,
@@ -51,7 +56,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
}; };
const nearNow = (ms, length) => const nearNow = (ms, length) =>
Math.abs(ms - worldClock.wallNow()) <= Math.abs(ms - worldClock.wallNow()) <=
Math.max(LIVE_EDGE_MS, length * LIVE_EDGE); Math.max(LIVE_EDGE_MS, Math.min(LIVE_EDGE_MAX_MS, length * LIVE_EDGE));
// Whether the track ends at now (and moves on with it), not past it. // Whether the track ends at now (and moves on with it), not past it.
const endsAtNow = (covered) => const endsAtNow = (covered) =>
covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; covered.end - worldClock.wallNow() <= LIVE_EDGE_MS;
@@ -149,6 +149,41 @@ test('the track runs over the time the layers show, to now, and scrubs', async (
assert.equal(worldClock.now(), ahead); assert.equal(worldClock.now(), ahead);
}); });
test('on a track that runs over years, only the last minutes are near now', async (t) => {
const bar = mount(t);
const now = Date.now();
// A layer whose source covers fourteen years, up to now.
const start = now - 14 * 365 * 86_400_000;
const release = worldClock.hold('test-years');
const unregister = worldClock.register({
id: 'test-years',
coverage: () => ({ start, end: now }),
});
t.after(() => {
unregister();
release();
});
await nextTick();
const input = bar().querySelector('input[type=range]');
// A day back is a day back, though it is well within a share of the track.
input.value = String(now - 86_400_000);
input.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(worldClock.isLive(), false, 'a day back stays off live');
assert.equal(worldClock.now(), now - 86_400_000);
input.value = String(now - 10 * 60_000);
input.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(worldClock.isLive(), false, 'ten minutes back stays off live');
input.value = String(Date.now() - 60_000);
input.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(worldClock.isLive(), true, 'a minute back goes live');
});
test('play runs in real time, from the start of the track when at live', async (t) => { test('play runs in real time, from the start of the track when at live', async (t) => {
const bar = mount(t); const bar = mount(t);
const now = Date.now(); const now = Date.now();