diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 14a754cb8..26e6a5e3b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8557,7 +8557,9 @@ and the timeline time alone at a past time. The catalog exposes `weatherClock` and destroys it with its lifetime signal. It has no time controls of its own: `weatherClock.world` is the world clock it follows, where time moves. A weather row's `play`, `latest` and `step` params -move that world clock as the timeline does. +move that world clock as the timeline does. A weather layer built without a shared +clock keeps one of its own on the page's world clock for its lifetime; it has +no frame replay of its own either way. Source, opacity and coverage navigation live in the WEATHER panel's cards, not in the rows; the panel shows the frame time and has no time controls. The diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f5d368a45..86b9aef8b 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2032,7 +2032,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° ج–60° ش · بتأخير 2–3 س عادةً", "North America · infrared imagery": "أمريكا الشمالية · صور بالأشعة تحت الحمراء", "Reduced motion · history playback unavailable": "تقليل الحركة · تشغيل السجل غير متاح", - "Reduced motion · manual history available": "تقليل الحركة · التصفح اليدوي للسجل متاح", "Rain radar · US": "رادار الأمطار · الولايات المتحدة", "Lightning density · 15 min": "كثافة البرق · 15 د", "Satellite clouds": "الغيوم من الأقمار الصناعية", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9a4738b19..f9d162c01 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1936,7 +1936,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert", "North America · infrared imagery": "Nordamerika · Infrarotbilder", "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", - "Reduced motion · manual history available": "Reduzierte Bewegung · manueller Verlauf verfügbar", "Rain radar · US": "Regenradar · USA", "Lightning density · 15 min": "Blitzdichte · 15 min", "Satellite clouds": "Wolken (Satellit)", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0848335b3..8e47a0a4a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1436,7 +1436,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso", "North America · infrared imagery": "Norteamérica · imágenes infrarrojas", "Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible", - "Reduced motion · manual history available": "Movimiento reducido · puedes recorrer el historial a mano", "Rain radar · US": "Radar de lluvia · EE. UU.", "Lightning density · 15 min": "Densidad de rayos · 15 min", "Satellite clouds": "Nubes por satélite", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 03ae2354e..f865c807f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1350,7 +1350,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", - "Reduced motion · manual history available": "Animations réduites · historique parcourable manuellement", "Rain radar · US": "Radar de pluie · États-Unis", "Lightning density · 15 min": "Densité de foudre · 15 min", "Satellite clouds": "Nuages par satellite", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 5d76d6c4f..415b8d743 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1208,7 +1208,6 @@ "Color: strikes/km²/min ×10³": "צבע: פגיעות ברק/קמ\"ר/דק׳ ×10³", "Color: {units}": "צבע: {units}", "Reduced motion · history playback unavailable": "תנועה מופחתת · הצגת ההיסטוריה אינה זמינה", - "Reduced motion · manual history available": "תנועה מופחתת · היסטוריה ידנית זמינה", "Rain radar · US": "רדאר גשם · ארה\"ב", "Lightning density · 15 min": "צפיפות ברקים · 15 דק׳", "Satellite clouds": "עננים מלוויין", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 65fd4f5fe..8a7190022 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1350,7 +1350,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo", "North America · infrared imagery": "Nord America · immagini all'infrarosso", "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", - "Reduced motion · manual history available": "Movimento ridotto · storico manuale disponibile", "Rain radar · US": "Radar pioggia · USA", "Lightning density · 15 min": "Densità di fulmini · 15 min", "Satellite clouds": "Nuvole da satellite", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e50ef01ca..d722f8934 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1312,7 +1312,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ", "North America · infrared imagery": "北米 · 赤外画像", "Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可", - "Reduced motion · manual history available": "動きを減らす設定 · 履歴は手動で表示可能", "Rain radar · US": "降水レーダー · 米国", "Lightning density · 15 min": "雷密度 · 15分", "Satellite clouds": "衛星雲画像", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7791af632..82113bb02 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1348,7 +1348,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso", "North America · infrared imagery": "América do Norte · imagens infravermelhas", "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", - "Reduced motion · manual history available": "Movimento reduzido · é possível percorrer o histórico manualmente", "Rain radar · US": "Radar de chuva · EUA", "Lightning density · 15 min": "Densidade de raios · 15 min", "Satellite clouds": "Nuvens por satélite", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7d4ab5612..aad80ad25 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1458,7 +1458,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч", "North America · infrared imagery": "Северная Америка · ИК-снимки", "Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно", - "Reduced motion · manual history available": "Уменьшение движения · историю можно листать вручную", "Rain radar · US": "Радар осадков · США", "Lightning density · 15 min": "Плотность молний · 15 мин", "Satellite clouds": "Спутниковая облачность", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 46d671561..9dbf46a7a 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1858,7 +1858,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning", "North America · infrared imagery": "Nordamerika · infraröda bilder", "Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig", - "Reduced motion · manual history available": "Minskad rörelse · manuell historik tillgänglig", "Rain radar · US": "Regnradar · USA", "Lightning density · 15 min": "Blixttäthet · 15 min", "Satellite clouds": "Satellitmoln", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7456299a3..6d341b93e 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1368,7 +1368,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", "North America · infrared imagery": "Північна Америка · ІЧ-знімки", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", - "Reduced motion · manual history available": "Зменшення руху · історію можна гортати вручну", "Rain radar · US": "Радар опадів · США", "Lightning density · 15 min": "Щільність блискавок · 15 хв", "Satellite clouds": "Супутникова хмарність", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 79d11512b..dcf63a875 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1288,7 +1288,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "North America · infrared imagery": "北美洲 · 红外影像", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", - "Reduced motion · manual history available": "减弱动效 · 可手动浏览历史", "Rain radar · US": "降雨雷达 · 美国", "Lightning density · 15 min": "闪电密度 · 15 分钟", "Satellite clouds": "卫星云图", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 5135b13ce..5bea36d08 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1283,7 +1283,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", "North America · infrared imagery": "北美洲 · 紅外影像", "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", - "Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史", "Rain radar · US": "降雨雷達 · 美國", "Lightning density · 15 min": "閃電密度 · 15 分鐘", "Satellite clouds": "衛星雲圖", diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 9f5761ccc..9a014bbc6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -9,7 +9,9 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, atLeastFrameSpacing, + createWeatherClock, } from './clock.js'; +import { worldClock } from '../../time/worldClock.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; @@ -86,12 +88,14 @@ function moveWorldTime(world, { play, latest, step }) { /** A per-application observation layer, using the existing layer lifecycle and * row controls. Its time is the world clock's, so a link made away from live - * opens its imagery at that time; one made live opens the latest. The feed - * may also name its source with `sourceName(kind)`, `kind` being 'radar', - * 'lightning' or 'satellite'; NOAA nowCOAST is named otherwise. */ + * opens its imagery at that time; one made live opens the latest. Its frames + * follow `clock`, a weather clock several layers share; without one the layer + * keeps its own on the page's world clock, for its lifetime. The feed may also + * name its source with `sourceName(kind)`, `kind` being 'radar', 'lightning' + * or 'satellite'; NOAA nowCOAST is named otherwise. */ export function createWeatherLayer({ feed, - clock, + clock: sharedClock, id = 'weather-radar', cesium = Cesium, createRendering = createWeatherRendering, @@ -101,6 +105,10 @@ export function createWeatherLayer({ } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Weather requires a snapshot source'); + const ownClock = sharedClock + ? null + : createWeatherClock({ world: worldClock }); + const clock = sharedClock ?? ownClock; // A feed may also supply decoded frames; see weatherFrameSource. const radar = id === 'weather-radar'; const lightning = id === 'weather-lightning'; @@ -128,11 +136,8 @@ export function createWeatherLayer({ request = null, listener = null; let enabled = false, - loading = false, - playing = false, - followLatest = true; + loading = false; let generation = 0, - timer = null, error = null, removeCamera = null; let motion = null; @@ -172,13 +177,12 @@ export function createWeatherLayer({ return spaced; }; const maxGap = () => spacedTimes().gap; - const isLatest = () => - clock ? clock.getState().mode === 'latest' : followLatest; + const isLatest = () => clock.getState().mode === 'latest'; const getHost = () => imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' }; const notify = () => listener?.(); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); - const unsubscribeClock = clock?.subscribe(() => { + const unsubscribeClock = clock.subscribe(() => { if (!clock.getState().advancing || suspended()) rendering?.cancelPrefetch?.(); notify(); @@ -186,18 +190,10 @@ export function createWeatherLayer({ const observationDelayed = () => manifest?.latest && Date.now() - Date.parse(manifest.latest) > spacedTimes().delay; - const stop = () => { - if (clock) return; - playing = false; - rendering?.cancelPrefetch?.(); - clearTimeout(timer); - timer = null; - }; const suspended = () => hostHidden || documentRef?.hidden || motion?.matches; const onVisibility = () => { if (suspended()) rendering?.cancelPrefetch?.(); - if (clock) void clock.refresh(); - else if (suspended()) stop(); + void clock.refresh(); notify(); }; @@ -212,46 +208,15 @@ export function createWeatherLayer({ if (!changed || !rendering) return; ++generation; rendering.rehome?.(); - clearTimeout(timer); - timer = null; loading = Boolean(request && !request.signal.aborted); - if (clock && enabled) { - if (resume) { - void clock.refresh(); - if (!hostHidden && manifest && isLatest()) void show(manifest.latest); - } - notify(); - return; - } - if (resume && enabled && !hostHidden && manifest) { - const time = - followLatest || !manifest.times.includes(shownTime()) - ? manifest.latest - : shownTime(); - if ( - time !== shownTime() || - (satellite && rendering.getDiagnostics().infrared !== infrared) - ) - void show(time); - else schedule(); + if (enabled && resume) { + void clock.refresh(); + if (!hostHidden && manifest && isLatest()) void show(manifest.latest); } notify(); } const onMapStackChanged = () => checkHost(); - function schedule() { - if (clock) return; - clearTimeout(timer); - timer = null; - if (!enabled || !playing || suspended()) return; - timer = setTimeout(() => { - timer = null; - const times = manifest?.times || []; - const next = (times.indexOf(shownTime()) + 1) % times.length; - if (times[next]) void show(times[next]); - else stop(); - }, 2000); - } async function applyClockTime(time, { signal }) { if (!enabled || signal.aborted) return false; if (time !== null) return show(time, signal); @@ -265,7 +230,7 @@ export function createWeatherLayer({ return true; } function registerClock() { - if (!clock || unregisterClock || !rendering || !enabled) return; + if (unregisterClock || !rendering || !enabled) return; unregisterClock = clock.register({ id, get maxGapMs() { @@ -278,18 +243,11 @@ export function createWeatherLayer({ }); } async function warmNext(time) { - if ( - !enabled || - suspended() || - !(clock ? clock.getState().advancing : playing) - ) - return; + if (!enabled || suspended() || !clock.getState().advancing) return; + // The world plays on past the newest frame, never round to the first. const times = manifest?.times ?? []; - // The world plays on past the newest frame; this layer's own replay - // (without a clock) goes round to the first. - const after = times.indexOf(time) + 1; - const next = clock ? times[after] : times[after % times.length]; - if (!next || next === time) return; + const next = times[times.indexOf(time) + 1]; + if (!next) return; try { await rendering.prefetch?.(manifest, next, { infrared }); } catch { @@ -324,13 +282,10 @@ export function createWeatherLayer({ error = ok ? null : msg('Frame unavailable; previous observation retained'); - if (!ok) stop(); return ok; } catch { - if (owner === generation && !controller.signal.aborted) { + if (owner === generation && !controller.signal.aborted) error = msg('Weather imagery unavailable'); - stop(); - } return false; } finally { signal?.removeEventListener('abort', abort); @@ -338,8 +293,7 @@ export function createWeatherLayer({ if (owner === generation) { loading = Boolean(request && !request.signal.aborted); notify(); - if (clock && isLatest()) void clock.refresh(); - schedule(); + if (isLatest()) void clock.refresh(); } } } @@ -407,26 +361,20 @@ export function createWeatherLayer({ noFrame = false; request?.abort(); request = null; - stop(); + rendering?.cancelPrefetch?.(); rendering?.clear(); manifest = null; loading = false; error = null; - followLatest = true; }, async update(_viewer, { signal } = {}) { if (!enabled) return false; checkHost(false); - clearTimeout(timer); - timer = null; request?.abort(); const controller = new AbortController(); if (signal?.aborted) controller.abort(signal.reason); request = controller; - const abort = () => { - controller.abort(signal.reason); - stop(); - }; + const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); loading = true; notify(); @@ -442,36 +390,15 @@ export function createWeatherLayer({ error = msg( 'Weather source unavailable; previous observation retained', ); - stop(); return true; } manifest = snapshot; error = null; // The shown frame may stay; its credit follows the refreshed manifest. rendering?.updateSnapshot?.(snapshot); - if (clock) { - await clock.refresh(); - if ( - isLatest() && - request === controller && - !controller.signal.aborted - ) - await show(snapshot.latest, controller.signal); - return ( - enabled && - !signal?.aborted && - (!controller.signal.aborted || request !== controller) - ); - } - const time = - followLatest || !snapshot.times.includes(shownTime()) - ? snapshot.latest - : shownTime(); - if ( - shownTime() !== time || - (satellite && rendering.getDiagnostics().infrared !== infrared) - ) - await show(time, controller.signal); + await clock.refresh(); + if (isLatest() && request === controller && !controller.signal.aborted) + await show(snapshot.latest, controller.signal); return ( enabled && !signal?.aborted && @@ -481,7 +408,6 @@ export function createWeatherLayer({ if (controller.signal.aborted || request !== controller) return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Weather unavailable'); - stop(); return true; } finally { signal?.removeEventListener('abort', abort); @@ -489,7 +415,6 @@ export function createWeatherLayer({ request = null; loading = Boolean(rendering?.getDiagnostics().loading); notify(); - schedule(); } } }, @@ -524,19 +449,16 @@ export function createWeatherLayer({ noFrame = false; request?.abort(); request = null; - stop(); + rendering?.cancelPrefetch?.(); manifest = null; error = null; loading = false; - followLatest = true; rendering?.clear(); - if (clock) void clock.refresh(); + void clock.refresh(); if (enabled) void layer.update(viewer); } if (infraredChanged && enabled && manifest) { - clearTimeout(timer); - timer = null; - if (clock && !isLatest()) void clock.refresh(); + if (!isLatest()) void clock.refresh(); else void show(shownTime() || manifest.latest); } if ( @@ -562,36 +484,9 @@ export function createWeatherLayer({ }), ); } - // With a clock, time is the world's: these move the world clock as its - // timeline does, and every layer that follows it moves along. - if (clock && enabled) moveWorldTime(clock.world, params); - if ( - !clock && - params.play === true && - enabled && - manifest?.times?.length > 1 && - !suspended() - ) { - playing = !playing; - followLatest = false; - if (playing) schedule(); - else stop(); - } - if (!clock && params.latest === true && manifest) { - stop(); - followLatest = true; - void show(manifest.latest); - } - if (!clock && [-1, 1].includes(params.step) && manifest && !loading) { - stop(); - followLatest = false; - const at = manifest.times.indexOf(shownTime()); - const next = Math.max( - 0, - Math.min(manifest.times.length - 1, at + params.step), - ); - void show(manifest.times[next]); - } + // Time is the world's: these move the world clock as its timeline + // does, and every layer that follows it moves along. + if (enabled) moveWorldTime(clock.world, params); notify(); }, getParams() { @@ -602,13 +497,13 @@ export function createWeatherLayer({ : { product, opacity }; }, getRowControls() { - const shared = clock?.getState(); + const shared = clock.getState(); const followLatest = isLatest(); // Source-described text is data: shown as is, never translated. const display = weatherDisplay(manifest); const missing = noFrame && - shared?.mode === 'history' && + shared.mode === 'history' && shared.products.find((entry) => entry.id === id)?.selected === null ? maxGap() / 60_000 < 60 ? t('No frame within {gap} min of {time}', { @@ -734,9 +629,7 @@ export function createWeatherLayer({ const stops = display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []); const infoMotion = motion?.matches - ? clock - ? t('Reduced motion · history playback unavailable') - : t('Reduced motion · manual history available') + ? t('Reduced motion · history playback unavailable') : ''; const controls = { readout: true, @@ -926,27 +819,23 @@ export function createWeatherLayer({ }; }, getDiagnostics() { - const shared = clock?.getState(); + const shared = clock.getState(); return { ...rendering?.getDiagnostics(), - playing: shared ? shared.advancing : playing && !hostHidden, + playing: shared.advancing, followLatest: isLatest(), - ...(shared - ? { - clock: { - mode: shared.mode, - target: shared.target, - playing: shared.advancing, - }, - } - : {}), + clock: { + mode: shared.mode, + target: shared.target, + playing: shared.advancing, + }, historyFrames: manifest?.times?.length || 0, - timerActive: timer !== null, }; }, destroy() { layer.disable(); - unsubscribeClock?.(); + unsubscribeClock(); + ownClock?.destroy(); removeCamera?.(); removeCamera = null; motion?.removeEventListener?.('change', onVisibility); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 78f2875bd..0748d69df 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,8 +1,9 @@ -import test from 'node:test'; +import test, { afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; +import { worldClock } from '../../time/worldClock.js'; import * as Cesium from 'cesium'; import { Color, ImageryLayerCollection, GeographicTilingScheme } from 'cesium'; import { NO_IMAGERY_HOST } from './imageryHost.js'; @@ -482,6 +483,14 @@ test('failed and timed-out stages retain the previous observation; a healthy rep h.rendering.clear(); }); +// A layer built without a clock keeps its own on the page's world clock: +// each test leaves that clock live, with no layer of its own on it. +const harnessed = new Set(); +afterEach(() => { + for (const layer of harnessed) layer.destroy(); + harnessed.clear(); + worldClock.goLive(); +}); function layerHarness({ clock, reducedMotion = false, @@ -562,6 +571,7 @@ function layerHarness({ }); layer.init(viewer); layer.enable(); + harnessed.add(layer); return { layer, rendering, @@ -575,72 +585,91 @@ function layerHarness({ }; } -test('history has one owned timer, stops while hidden, and releases all work on disable/destroy', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('without a clock the layer keeps one of its own on the world clock and lets it go on destroy', async () => { const h = layerHarness(); const update = h.layer.update(); await flush(); h.stages[0].finish(); await update; + assert.equal(worldClock.follows('weather-radar'), true); + for (const time of times) + assert.ok(worldClock.coverage().marks.includes(Date.parse(time))); h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().timerActive, true); - t.mock.timers.tick(2000); - assert.equal(h.stages.length, 2); - assert.equal( - h.layer.getDiagnostics().timerActive, - false, - 'next timer waits for stage settlement', - ); - h.stages[1].finish(); - await flush(); - assert.equal(h.layer.getDiagnostics().timerActive, true); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getState().stepping, false); + assert.equal(h.layer.getDiagnostics().playing, true); h.documentRef.hidden = true; h.documentRef.emit('visibilitychange'); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); - t.mock.timers.tick(20_000); - assert.equal(h.stages.length, 2); + await flush(); + assert.equal(worldClock.isPlaying(), true, 'hiding suspends the layer only'); h.documentRef.hidden = false; h.documentRef.emit('visibilitychange'); h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); h.layer.disable(); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getDiagnostics().historyFrames, 0); - t.mock.timers.tick(20_000); - assert.equal(h.stages.length, 2); h.layer.destroy(); + assert.equal(worldClock.follows('weather-radar'), false); assert.equal(h.documentRef.size, 0); assert.equal(h.motion.size, 0); assert.equal(h.moveEnd.size, 0); assert.ok(h.clears() >= 1); }); -test('reduced motion blocks autoplay but permits manual history and stops newly suspended playback', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('reduced motion offers no play; steps still move the world clock from mark to mark', async () => { const h = layerHarness({ reducedMotion: true }); const update = h.layer.update(); await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal( h.layer.getRowControls().chips.some((chip) => chip.id === 'play'), false, ); + // As the timeline's arrow keys do, a step back from live goes to the + // newest frame, and the next one back to the frame before it. h.layer.setParams({ step: -1 }); - assert.equal(h.stages[1].time, times[1]); - h.stages[1].finish(); await flush(); - h.motion.matches = false; - h.motion.emit('change'); + assert.equal(worldClock.now(), Date.parse(times[2])); + h.layer.setParams({ step: -1 }); + await flush(); + assert.equal(worldClock.now(), Date.parse(times[1])); + assert.equal(h.stages.at(-1).time, times[1]); +}); + +test('a row steps forward and plays from a past time through the world clock', async () => { + const h = layerHarness(); + const update = h.layer.update(); + await flush(); + h.stages[0].finish(); + await update; + worldClock.seek(Date.parse(times[0])); + await flush(); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.now(), Date.parse(times[1])); + assert.equal(worldClock.isPlaying(), false); + await flush(); + assert.equal(h.stages.at(-1).time, times[1]); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.now(), Date.parse(times[2])); + h.layer.setParams({ step: 1 }); + assert.equal( + worldClock.now(), + Date.parse(times[2]), + 'no mark after the newest frame', + ); + worldClock.seek(Date.parse(times[1])); + await flush(); h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().playing, true); - h.motion.matches = true; - h.motion.emit('change'); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); - h.layer.destroy(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getState().stepping, false); + assert.ok( + worldClock.now() - Date.parse(times[1]) < 1_000, + 'away from live, play goes on from the time shown', + ); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.isPlaying(), false, 'a step holds play'); + assert.equal(worldClock.now(), Date.parse(times[2])); }); test('disable invalidates an abort-insensitive source result and an already staged frame', async (t) => { @@ -669,7 +698,6 @@ test('disable invalidates an abort-insensitive source result and an already stag await next; await flush(); assert.equal(h.layer.getStats().count, 0); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getStats().loading, false); h.layer.destroy(); }); @@ -729,7 +757,6 @@ test('update cancellation reaches imagery after source acquisition has completed assert.equal(h.stages[0].signal.aborted, true); assert.equal(h.layer.getStats().count, 0); assert.equal(h.layer.getStats().loading, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); h.layer.destroy(); }); @@ -837,21 +864,22 @@ test('coverage navigation requires the shared shell handoff and detaches cleanly h.layer.destroy(); }); -test('manifest refresh during a manual history stage does not strand loading controls', async () => { +test('manifest refresh during a history stage restages the same time and does not strand loading controls', async () => { const h = layerHarness(); const first = h.layer.update(); await flush(); h.stages[0].finish(); await first; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(times[1])); + await flush(); assert.equal(h.layer.getStats().loading, true); - await h.layer.update(); - assert.equal( - h.stages.length, - 2, - 'metadata refresh preserves the staged manual selection', - ); - h.stages[1].finish(); + const refreshed = h.layer.update(); + await flush(); + assert.equal(h.stages.length, 3); + assert.equal(h.stages[1].signal.aborted, true); + assert.equal(h.stages[2].time, times[1], 'the refresh keeps the time'); + h.stages[2].finish(); + await refreshed; await flush(); assert.equal(h.layer.getStats().loading, false); assert.equal(h.layer.getStats().observedAt, times[1]); @@ -881,7 +909,8 @@ test('historical observations do not relabel a fresh lightning feed as stale', a await flush(); h.stages[0].finish(); await first; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(old)); + await flush(); h.stages[1].finish(); await flush(); assert.equal(h.layer.getStats().stale, false); @@ -1256,8 +1285,7 @@ test('404 fails a frame without retry', async () => { h.rendering.clear(); }); -test('no host pauses history, refreshes metadata, and restores retained time and playback', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('no host holds the shown history frame, refreshes metadata, and restores the retained time', async () => { let refreshes = 0; const h = layerHarness({ feed: { @@ -1273,28 +1301,23 @@ test('no host pauses history, refreshes metadata, and restores retained time and await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ play: true }); - t.mock.timers.tick(2000); + worldClock.seek(Date.parse(times[0])); + await flush(); assert.equal(h.stages.length, 2); + h.stages[1].finish(); + await flush(); host = { collection: null, kind: 'none' }; h.eventTarget.emit('gev:map-stack-changed'); await flush(); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getRowControls().summary.status, NO_IMAGERY_HOST); assert.equal(h.layer.getRowControls().info, NO_IMAGERY_HOST); await h.layer.update(); assert.equal(refreshes, 2); - t.mock.timers.tick(10_000); - assert.equal(h.stages.length, 2); - assert.equal(h.layer.getDiagnostics().time, times[2]); + assert.equal(h.stages.length, 2, 'nothing stages without a host'); host = { collection: {}, kind: 'globe' }; h.eventTarget.emit('gev:map-stack-changed'); - assert.equal(h.layer.getDiagnostics().playing, true); - assert.equal(h.layer.getDiagnostics().timerActive, true); - t.mock.timers.tick(2000); - assert.equal(h.stages.length, 3); - assert.equal(h.stages[2].time, times[0]); + await flush(); + assert.equal(h.stages.at(-1).time, times[0], 'the retained time restages'); h.layer.destroy(); assert.equal(h.eventTarget.size, 0); }); @@ -1431,25 +1454,21 @@ for (const [id, product] of [ const [shell] = h.shells(); assert.equal(shell.show, true); - layer.setParams({ play: true }); - t.mock.timers.tick(2000); + worldClock.seek(Date.parse(times[0])); await flush(); assert.equal( layer.getDiagnostics().time, times[0], - 'history advances at street level', + 'history moves at street level', ); camera.positionCartographic.height = 59_999; camera.moveEnd.emit(); assert.equal(shell.show, true, 'no height gate'); - assert.equal(layer.getDiagnostics().playing, true); - assert.equal(layer.getDiagnostics().timerActive, true); host = { collection: null, kind: 'none' }; events.emit('gev:map-stack-changed'); assert.equal(shell.show, false); assert.equal(layer.getRowControls().summary.status, NO_IMAGERY_HOST); - assert.equal(layer.getDiagnostics().timerActive, false); host = { collection: h.viewer.imageryLayers, kind: 'globe' }; events.emit('gev:map-stack-changed'); assert.equal(shell.destroyed, true); @@ -1648,10 +1667,12 @@ test('satellite mode chips restage history without changing time or latest-follo await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(times[1])); + await flush(); h.stages[1].finish(); await flush(); h.layer.setParams({ infrared: 'full' }); + await flush(); assert.equal(h.stages[2].time, times[1]); assert.equal(h.stages[2].infrared, 0); assert.equal(h.layer.getParams().infrared, 0);