mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
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:
1 parent
7451c9b372
commit
5d733eb07b
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).
|
||||
@@ -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
|
||||
`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
|
||||
`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.
|
||||
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
|
||||
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
|
||||
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.
|
||||
Manifest rollover preserves the requested history time, even when frames expire.
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user