Fix scrubs to live on long tracks, fire ages in a past window, and gaps between hourly cloud frames

- World timeline: a scrub counts as at now within a share of the track
  (0.5%), now held to at most two minutes. A layer whose source covers
  years made the window weeks wide, so most of the last month snapped to
  live. Short tracks keep their window.
- Fires: card and detail ages count back from the end of the 24 hours the
  layer shows when its snapshot ends at a set time, and from now only when
  it runs to now. A window that ended months ago read "new 105d" on fires
  three hours old at the time shown.
- Weather: a product's max gap and the delay after which its latest frame
  reads late are at least one and a half of its feed's median frame
  spacing (over its latest frames). A regional cloud feed with frames an
  hour apart drew nothing for the second half of every hour while
  scrubbing and read late after twenty minutes; feeds with frames minutes
  apart keep their gaps.
This commit is contained in:
Sameh Khamis committed 2026-11-14 14:52:48 -08:00
1 parent 7451c9b372
commit 5d733eb07b
12 files changed
+327 -15

No files matched your search

+2 -1
View File
@@ -31,7 +31,8 @@ export function createCards({
function rebuildAmbientLabels() {
if (!layerState._viewer) return;
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 = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = [];
+19 -4
View File
@@ -343,7 +343,7 @@ export function createModel({
* (on top) and never distance-faded by the overlay.
* Exported for unit tests.
* @param {Object} fire - Detection record.
* @param {number} nowMs - Current epoch milliseconds.
* @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry.
*/
@@ -384,9 +384,10 @@ export function createModel({
* Card model for an ambient fire detection, e.g. title "▲ 47 MW", detail
* "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
* 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 {number} nowMs - Current epoch milliseconds.
* @param {number} nowMs - Epoch milliseconds ages count back from.
* @returns {Object} firmsLabels entry.
*/
@@ -418,7 +419,7 @@ export function createModel({
* "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets
* it with {@link cellCardAccent}). Exported for unit tests.
* @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.
*/
@@ -549,6 +550,19 @@ export function createModel({
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. */
function formatAge(deltaMs) {
@@ -624,6 +638,7 @@ export function createModel({
cellCardAccent,
formatLatLon,
formatFrp,
ageReferenceMs,
formatAge,
formatAgeLabel,
formatAgoMinutes,
+3 -1
View File
@@ -202,7 +202,9 @@ export function createSelection({
confidence: components.model.confidenceBucket(fire.confidence),
age:
fire.acqMs > 0
? components.model.formatAge(Date.now() - fire.acqMs)
? components.model.formatAge(
components.model.ageReferenceMs() - fire.acqMs,
)
: 'unknown',
sensor: fire.sensor || 'unknown',
},
+53
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.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) => {
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);
}
});
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 GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 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. */
export const PLAY_WAIT_MS = 8_000;
// 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,
LIGHTNING_MAX_GAP_MS,
PLAY_WAIT_MS,
frameSpacingMs,
atLeastFrameSpacing,
} from './clock.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);
});
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) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
+26 -6
View File
@@ -8,6 +8,7 @@ import {
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
atLeastFrameSpacing,
} from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js';
@@ -96,7 +97,10 @@ export function createWeatherLayer({
let unregisterClock = null;
let frameRequest = null;
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_MAX_GAP_MS
: lightning
@@ -104,6 +108,23 @@ export function createWeatherLayer({
: product === 'clouds'
? GLOBAL_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 = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () =>
@@ -117,8 +138,7 @@ export function createWeatherLayer({
});
const observationDelayed = () =>
manifest?.latest &&
Date.now() - Date.parse(manifest.latest) >
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
Date.now() - Date.parse(manifest.latest) > spacedTimes().delay;
const stop = () => {
if (clock) return;
playing = false;
@@ -544,11 +564,11 @@ export function createWeatherLayer({
shared.products.find((entry) => entry.id === id)?.selected === null
? maxGap() / 60_000 < 60
? t('No frame within {gap} min of {time}', {
gap: maxGap() / 60_000,
gap: Math.round(maxGap() / 60_000),
time: utc(shared.target),
})
: t('No frame within {gap} h of {time}', {
gap: maxGap() / 3600_000,
gap: Math.round(maxGap() / 360_000) / 10,
time: utc(shared.target),
})
: null;
@@ -688,7 +708,7 @@ export function createWeatherLayer({
? t('Global · 60°S–60°N')
: t('North America')),
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
maxGapMinutes: Math.round(maxGap() / 60_000),
detail,
status:
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);
});
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) => {
const clock = createWeatherClock();
const h = layerHarness({ clock });
+6 -1
View File
@@ -8,6 +8,11 @@ const LIVE_TICK_MS = 1000;
const LIVE_EDGE = 0.005;
/** The least that counts as at now, past the readout's own second. */
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. */
export const WORLD_TIME_SPEEDS = Object.freeze({
real: 1,
@@ -51,7 +56,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
};
const nearNow = (ms, length) =>
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.
const endsAtNow = (covered) =>
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);
});
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) => {
const bar = mount(t);
const now = Date.now();