From 326cab4cf2e21fec0d78197a168e88d90896eaf5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:26:26 -0800 Subject: [PATCH] A weather layer without a shared clock keeps its own on the world clock Built without a weather clock, the weather layer still ran a frame replay of its own: params.play stepped its frames every two seconds and went round from the last to the first, params.step and params.latest picked frames locally, and the layer tracked whether it followed the newest frame. That was a second way to move time. The layer now builds a weather clock on the page's world clock when none is passed, and destroys it with the layer, so every layer follows the world clock and its params move the world clock as the timeline does. The local replay, its timer, the follow-latest flag, the wrap to the first frame, the timerActive diagnostic and the reduced-motion "manual history available" message are removed. --- docs/CURRENT-STATE.md | 4 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/he.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/sv.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/weather/index.js | 209 +++++++--------------------- src/layers/weather/weather.test.mjs | 157 ++++++++++++--------- 16 files changed, 141 insertions(+), 242 deletions(-) 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);