mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
The main timeline is the only time control; the Weather panel shows its frame time
The Weather panel kept a second time transport of its own: a slider, step buttons, Latest and Play, backed by a replay in the weather clock that stepped the observed frames every two seconds and went round from the last to the first. The weather clock also attached itself to the world clock as a driver, so the timeline's Play became that replay whenever weather was on. The panel now shows the frame time only: the newest frame and its age while live, the timeline time alone at a past time. The weather clock no longer has setTarget, step, latest, play, pause or togglePlay, its state no longer has playing, and it no longer drives the world clock. It still follows the world time, registers each product's frames as marks the timeline's arrow keys step to, holds its products, and gates the world's continuous play at each next frame until it loads. weatherClock.world names the world clock it follows. A weather row's play, latest and step params move the world clock as the timeline does: play toggles continuous play (from the start of the covered time when live), latest goes live, and step goes to the previous or next mark. The rail timeline component, which only the panel used, is removed, along with its unused messages in every locale.
This commit is contained in:
1 parent
bce737bf32
commit
3cfd414e70
35 files changed
+328
-1033
No files matched your search
@@ -6,6 +6,11 @@ import { createStandaloneLayerSources } from '../standalone/layerSources.js';
|
||||
import { catalogControlServices } from './catalog.js';
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function fixtureSources(ids, calls) {
|
||||
const sources = createStandaloneLayerSources();
|
||||
@@ -27,6 +32,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
t.after(() => {
|
||||
a.abort();
|
||||
b.abort();
|
||||
worldClock.goLive();
|
||||
});
|
||||
const callsA = [];
|
||||
const callsB = [];
|
||||
@@ -53,7 +59,10 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
'the hardware-local layer is never serialized into links',
|
||||
);
|
||||
assert.notEqual(first.weatherClock, second.weatherClock);
|
||||
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
||||
// Time moves on the world clock, which the catalog's weather clock follows.
|
||||
assert.equal(first.weatherClock.world, worldClock);
|
||||
worldClock.seek(Date.parse('2026-09-21T12:00:00.000Z'));
|
||||
await flush();
|
||||
assert.match(
|
||||
first.get('wind').getRowControls().info,
|
||||
/Forecast · does not follow history/,
|
||||
@@ -111,9 +120,16 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
|
||||
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
|
||||
a.abort();
|
||||
worldClock.seek(Date.parse('2026-09-21T13:00:00.000Z'));
|
||||
await flush();
|
||||
assert.equal(
|
||||
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
|
||||
false,
|
||||
first.weatherClock.getState().target,
|
||||
'2026-09-21T12:00:00.000Z',
|
||||
'an aborted catalog no longer follows the world clock',
|
||||
);
|
||||
assert.equal(
|
||||
second.weatherClock.getState().target,
|
||||
'2026-09-21T13:00:00.000Z',
|
||||
);
|
||||
assert.equal(callsA[0].aborted, true);
|
||||
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
|
||||
|
||||
@@ -2542,12 +2542,8 @@
|
||||
"PAUSE": "إيقاف مؤقت",
|
||||
"RESUME": "استئناف",
|
||||
"Clear reading": "مسح القراءة",
|
||||
"Earlier observation": "رصد أسبق",
|
||||
"Observed history": "سجل الأرصاد",
|
||||
"Later observation": "رصد لاحق",
|
||||
"Latest": "الأحدث",
|
||||
"Newest frame per product": "أحدث إطار لكل منتج",
|
||||
"Replay observed history": "إعادة تشغيل سجل الأرصاد",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح",
|
||||
"Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض",
|
||||
"That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة",
|
||||
@@ -2848,7 +2844,6 @@
|
||||
"dB": "dB",
|
||||
"{time} · {minutes} min ago": "{time} · قبل {minutes} د",
|
||||
"Active weather": "الطقس النشط",
|
||||
"LATEST · newest per product": "الأحدث · آخر إطار لكل منتج",
|
||||
"Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · لا يتبع السجل",
|
||||
"Forecast · valid {valid}": "توقع عند {valid}",
|
||||
|
||||
@@ -2402,12 +2402,8 @@
|
||||
"PAUSE": "PAUSE",
|
||||
"RESUME": "FORTSETZEN",
|
||||
"Clear reading": "Wert ausblenden",
|
||||
"Earlier observation": "Frühere Beobachtung",
|
||||
"Observed history": "Beobachteter Verlauf",
|
||||
"Later observation": "Spätere Beobachtung",
|
||||
"Latest": "Neueste",
|
||||
"Newest frame per product": "Neuester Frame je Produkt",
|
||||
"Replay observed history": "Beobachteten Verlauf abspielen",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · nach LÖSCHEN wieder Google 3D",
|
||||
"Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden",
|
||||
"That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern",
|
||||
@@ -2656,7 +2652,6 @@
|
||||
"dB": "dB",
|
||||
"{time} · {minutes} min ago": "{time} · vor {minutes} min",
|
||||
"Active weather": "Aktives Wetter",
|
||||
"LATEST · newest per product": "NEUESTE · je Produkt",
|
||||
"Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf",
|
||||
"Forecast · valid {valid}": "Vorhersage · gültig {valid}",
|
||||
|
||||
@@ -1667,12 +1667,8 @@
|
||||
"CH {index} / {total}": "CANAL {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
||||
"Clear reading": "Borrar lectura",
|
||||
"Earlier observation": "Observación anterior",
|
||||
"Observed history": "Historial observado",
|
||||
"Later observation": "Observación posterior",
|
||||
"Latest": "Último",
|
||||
"Newest frame per product": "Fotograma más reciente por producto",
|
||||
"Replay observed history": "Reproducir el historial observado",
|
||||
"S on another day replaces it · × unpins": "S en otro día lo reemplaza · × quita la fijación",
|
||||
"← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado",
|
||||
"Recent imagery": "Imágenes recientes",
|
||||
@@ -1836,7 +1832,6 @@
|
||||
"Reset map bearing to north": "Restablecer la orientación del mapa al norte",
|
||||
"Rain radar": "Radar de lluvia",
|
||||
"Lightning density": "Densidad de rayos",
|
||||
"LATEST · newest per product": "ÚLTIMO · más reciente por producto",
|
||||
"Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · No sigue el historial",
|
||||
"Forecast · valid {valid}": "Pronóstico · válido {valid}",
|
||||
|
||||
@@ -2150,12 +2150,8 @@
|
||||
"Radio is disabling…": "Désactivation de la Radio…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "État de la Radio incertain — utilisez Activer ou Désactiver pour le rétablir",
|
||||
"Clear reading": "Effacer le relevé",
|
||||
"Earlier observation": "Observation précédente",
|
||||
"Observed history": "Historique des observations",
|
||||
"Later observation": "Observation suivante",
|
||||
"Latest": "Dernière",
|
||||
"Newest frame per product": "Image la plus récente par produit",
|
||||
"Replay observed history": "Rejouer l'historique des observations",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Sur Esri · EFFACER rétablit Google 3D",
|
||||
"Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue",
|
||||
"That point cannot anchor a box": "Ce point ne peut pas ancrer une zone",
|
||||
@@ -2446,7 +2442,6 @@
|
||||
"DETECT": "DÉTECT.",
|
||||
"{time} · {minutes} min ago": "{time} · il y a {minutes} min",
|
||||
"Active weather": "Couches météo actives",
|
||||
"LATEST · newest per product": "DERNIÈRE · la plus récente par produit",
|
||||
"Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Ne suit pas l'historique",
|
||||
"Forecast · valid {valid}": "Prévision · valide {valid}",
|
||||
|
||||
@@ -1996,11 +1996,7 @@
|
||||
"READY": "מוכן",
|
||||
"Clear reading": "ניקוי הקריאה",
|
||||
"Observed history": "היסטוריה נצפית",
|
||||
"Earlier observation": "תצפית קודמת",
|
||||
"Later observation": "תצפית מאוחרת",
|
||||
"Newest frame per product": "השקופית החדשה לכל מוצר",
|
||||
"Latest": "האחרונה",
|
||||
"Replay observed history": "הצגת ההיסטוריה הנצפית שוב",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים",
|
||||
"Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה",
|
||||
"That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה",
|
||||
@@ -2783,7 +2779,6 @@
|
||||
"Detection overlay: off": "שכבת הזיהוי: כבויה",
|
||||
"Active weather": "מזג אוויר פעיל",
|
||||
"{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳",
|
||||
"LATEST · newest per product": "האחרון · החדש ביותר לכל מוצר",
|
||||
"Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה",
|
||||
"Forecast · valid {valid}": "תחזית · תקפה {valid}",
|
||||
|
||||
@@ -2150,12 +2150,8 @@
|
||||
"Radio is disabling…": "Disattivazione della Radio…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto: usa Attiva o Disattiva per riconciliare",
|
||||
"Clear reading": "Cancella la lettura",
|
||||
"Earlier observation": "Osservazione precedente",
|
||||
"Observed history": "Storico osservato",
|
||||
"Later observation": "Osservazione successiva",
|
||||
"Latest": "Più recente",
|
||||
"Newest frame per product": "Fotogramma più recente per prodotto",
|
||||
"Replay observed history": "Rivedi lo storico osservato",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna con CANCELLA",
|
||||
"Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista",
|
||||
"That point cannot anchor a box": "Quel punto non può ancorare un riquadro",
|
||||
@@ -2446,7 +2442,6 @@
|
||||
"DETECT": "RILEV.",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes} min fa",
|
||||
"Active weather": "Meteo attivo",
|
||||
"LATEST · newest per product": "ULTIMO · il più recente per prodotto",
|
||||
"Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Non segue lo storico",
|
||||
"Forecast · valid {valid}": "Previsione · valida {valid}",
|
||||
|
||||
@@ -2076,12 +2076,8 @@
|
||||
"Radio is disabling…": "ラジオを無効化しています…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください",
|
||||
"Clear reading": "読み取り値を消去",
|
||||
"Earlier observation": "前の観測",
|
||||
"Observed history": "観測履歴",
|
||||
"Later observation": "次の観測",
|
||||
"Latest": "最新",
|
||||
"Newest frame per product": "プロダクトごとの最新フレーム",
|
||||
"Replay observed history": "観測履歴を再生",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります",
|
||||
"Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください",
|
||||
"That point cannot anchor a box": "その地点は範囲の基点にできません",
|
||||
@@ -2346,7 +2342,6 @@
|
||||
"DETECT": "検出",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes}分前",
|
||||
"Active weather": "有効な気象レイヤー",
|
||||
"LATEST · newest per product": "最新 · プロダクトごとの最新",
|
||||
"Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · 履歴とは連動しません",
|
||||
"Forecast · valid {valid}": "予報 · 対象時刻 {valid}",
|
||||
|
||||
@@ -2150,12 +2150,8 @@
|
||||
"Radio is disabling…": "O rádio está sendo desativado…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar",
|
||||
"Clear reading": "Limpar leitura",
|
||||
"Earlier observation": "Observação anterior",
|
||||
"Observed history": "Histórico observado",
|
||||
"Later observation": "Observação posterior",
|
||||
"Latest": "Mais recente",
|
||||
"Newest frame per product": "Quadro mais recente por produto",
|
||||
"Replay observed history": "Reproduzir o histórico observado",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D",
|
||||
"Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista",
|
||||
"That point cannot anchor a box": "Esse ponto não pode ancorar uma área",
|
||||
@@ -2446,7 +2442,6 @@
|
||||
"DETECT": "DETECÇÃO",
|
||||
"{time} · {minutes} min ago": "{time} · há {minutes} min",
|
||||
"Active weather": "Camadas de clima ativas",
|
||||
"LATEST · newest per product": "MAIS RECENTE · último por produto",
|
||||
"Forecast · valid {valid} · issued {issued}": "Previsão · válida para {valid} · emitida em {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Não acompanha o histórico",
|
||||
"Forecast · valid {valid}": "Previsão · válida para {valid}",
|
||||
|
||||
@@ -1697,12 +1697,8 @@
|
||||
"CH {index} / {total}": "КАН. {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
||||
"Clear reading": "Очистить показания",
|
||||
"Earlier observation": "Более раннее наблюдение",
|
||||
"Observed history": "История наблюдений",
|
||||
"Later observation": "Более позднее наблюдение",
|
||||
"Latest": "Последние",
|
||||
"Newest frame per product": "Самый свежий кадр каждого продукта",
|
||||
"Replay observed history": "Воспроизвести историю наблюдений",
|
||||
"S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет",
|
||||
"← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день",
|
||||
"Recent imagery": "Свежие снимки",
|
||||
@@ -1867,7 +1863,6 @@
|
||||
"Reset map bearing to north": "Сбросить азимут карты на север",
|
||||
"Rain radar": "Радар осадков",
|
||||
"Lightning density": "Плотность молний",
|
||||
"LATEST · newest per product": "ПОСЛЕДНИЕ · самые свежие по каждому продукту",
|
||||
"Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · не следует за историей",
|
||||
"Forecast · valid {valid}": "Прогноз · действует на {valid}",
|
||||
|
||||
@@ -2324,12 +2324,8 @@
|
||||
"PAUSE": "PAUSA",
|
||||
"RESUME": "FORTSÄTT",
|
||||
"Clear reading": "Rensa avläsningen",
|
||||
"Earlier observation": "Tidigare observation",
|
||||
"Observed history": "Observerad historik",
|
||||
"Later observation": "Senare observation",
|
||||
"Latest": "Senaste",
|
||||
"Newest frame per product": "Senaste bildruta per produkt",
|
||||
"Replay observed history": "Spela upp observerad historik",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas",
|
||||
"Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn",
|
||||
"That point cannot anchor a box": "Den punkten kan inte förankra ett område",
|
||||
@@ -2578,7 +2574,6 @@
|
||||
"dB": "dB",
|
||||
"{time} · {minutes} min ago": "{time} · för {minutes} min sedan",
|
||||
"Active weather": "Aktuellt väder",
|
||||
"LATEST · newest per product": "SENASTE · nyaste per produkt",
|
||||
"Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · Följer inte historiken",
|
||||
"Forecast · valid {valid}": "Prognos · gäller {valid}",
|
||||
|
||||
@@ -2186,12 +2186,8 @@
|
||||
"Radio is disabling…": "Радіо вимикається…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»",
|
||||
"Clear reading": "Очистити покази",
|
||||
"Earlier observation": "Попереднє спостереження",
|
||||
"Observed history": "Історія спостережень",
|
||||
"Later observation": "Наступне спостереження",
|
||||
"Latest": "Останні",
|
||||
"Newest frame per product": "Найсвіжіший кадр кожного продукту",
|
||||
"Replay observed history": "Відтворити історію спостережень",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D",
|
||||
"Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область",
|
||||
"That point cannot anchor a box": "Від цієї точки не можна побудувати область",
|
||||
@@ -2495,7 +2491,6 @@
|
||||
"DETECT": "ВИЯВЛЕННЯ",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes} хв тому",
|
||||
"Active weather": "Активні погодні шари",
|
||||
"LATEST · newest per product": "ОСТАННІ · найсвіжіші для кожного продукту",
|
||||
"Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · не синхронізується з історією",
|
||||
"Forecast · valid {valid}": "Прогноз · чинний на {valid}",
|
||||
|
||||
@@ -2046,12 +2046,8 @@
|
||||
"Radio is disabling…": "电台正在停用…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对",
|
||||
"Clear reading": "清除读数",
|
||||
"Earlier observation": "较早观测",
|
||||
"Observed history": "观测历史",
|
||||
"Later observation": "较晚观测",
|
||||
"Latest": "最新",
|
||||
"Newest frame per product": "每个产品的最新一帧",
|
||||
"Replay observed history": "回放观测历史",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D",
|
||||
"Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野",
|
||||
"That point cannot anchor a box": "该点无法作为框选锚点",
|
||||
@@ -2310,7 +2306,6 @@
|
||||
"DETECT": "探测",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes} 分钟前",
|
||||
"Active weather": "已启用的天气图层",
|
||||
"LATEST · newest per product": "最新 · 每个产品的最新一帧",
|
||||
"Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · 不随观测历史变化",
|
||||
"Forecast · valid {valid}": "预报 · 有效时间 {valid}",
|
||||
|
||||
@@ -2036,12 +2036,8 @@
|
||||
"Radio is disabling…": "電臺正在停用…",
|
||||
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對",
|
||||
"Clear reading": "清除讀數",
|
||||
"Earlier observation": "較早觀測",
|
||||
"Observed history": "觀測歷史",
|
||||
"Later observation": "較晚觀測",
|
||||
"Latest": "最新",
|
||||
"Newest frame per product": "每個產品的最新一幀",
|
||||
"Replay observed history": "回放觀測歷史",
|
||||
"Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D",
|
||||
"Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野",
|
||||
"That point cannot anchor a box": "該點無法作為框選錨點",
|
||||
@@ -2300,7 +2296,6 @@
|
||||
"DETECT": "探測",
|
||||
"{time} · {minutes} min ago": "{time} · {minutes} 分鐘前",
|
||||
"Active weather": "已啟用的天氣圖層",
|
||||
"LATEST · newest per product": "最新 · 每個產品的最新一幀",
|
||||
"Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}",
|
||||
"{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化",
|
||||
"Forecast · valid {valid}": "預報 · 有效時間 {valid}",
|
||||
|
||||
+22
-146
@@ -59,12 +59,15 @@ export const PLAY_WAIT_MS = 8_000;
|
||||
// costs play nothing.
|
||||
|
||||
let clocks = 0;
|
||||
// How many weather clocks follow each world clock.
|
||||
const followers = new WeakMap();
|
||||
|
||||
/**
|
||||
* Transient observed time. Products must honor the signal before installing a
|
||||
* frame. Its time is the world clock's: latest while the world is live,
|
||||
* history at the world's time otherwise. Replay steps the observed frames and
|
||||
* moves the world clock as it goes, as the world clock's driver.
|
||||
* history at the world's time otherwise. It never moves the world clock
|
||||
* itself; the world's own controls (its timeline) move time, and while the
|
||||
* world plays it stops at each next frame until that frame loads.
|
||||
*/
|
||||
export function createWeatherClock({
|
||||
now = Date.now,
|
||||
@@ -76,17 +79,10 @@ export function createWeatherClock({
|
||||
const listeners = new Set();
|
||||
let mode = 'latest';
|
||||
let target = null;
|
||||
let playing = false;
|
||||
// The world time last applied; while this clock moves the world, the
|
||||
// transition its move started.
|
||||
// The world time last applied.
|
||||
let seen = 'latest';
|
||||
let moving = false;
|
||||
let moved = null;
|
||||
const releases = new Map();
|
||||
// Attached at the end, once the API exists.
|
||||
let driver = null;
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
// This clock's name on the world, for its holds and its gate.
|
||||
const holdId = `weather:${++clocks}`;
|
||||
@@ -147,10 +143,9 @@ export function createWeatherClock({
|
||||
const getState = () => ({
|
||||
mode,
|
||||
target,
|
||||
playing,
|
||||
// Frames come on one after another: this clock replays them, or the world
|
||||
// plays on through them, so the next is worth loading ahead.
|
||||
advancing: playing || worldPlaysOn(),
|
||||
// Frames come on one after another as the world plays on through them,
|
||||
// so the next is worth loading ahead.
|
||||
advancing: worldPlaysOn(),
|
||||
timeline: getTimeline(),
|
||||
products: [...registry.values()].map(({ product }) => ({
|
||||
id: product.id,
|
||||
@@ -162,60 +157,12 @@ export function createWeatherClock({
|
||||
const notify = () => {
|
||||
if (!destroyed) for (const listener of listeners) listener(getState());
|
||||
};
|
||||
const cancelTimer = () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const canPlay = () => getTimeline().length >= 2;
|
||||
const stopPlaying = () => {
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
driver?.driven(false);
|
||||
};
|
||||
// Move the world clock; the change comes back through followWorld. Returns
|
||||
// the transition the move started, if it started one.
|
||||
const move = (fn) => {
|
||||
const outer = [moving, moved];
|
||||
moving = true;
|
||||
moved = null;
|
||||
try {
|
||||
fn();
|
||||
return moved;
|
||||
} finally {
|
||||
[moving, moved] = outer;
|
||||
}
|
||||
};
|
||||
const selections = () =>
|
||||
[...registry.values()].map(({ product }) => selectedFor(product)).join();
|
||||
let applied = null;
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (!playing || destroyed || pending) return;
|
||||
if (!canPlay()) {
|
||||
stopPlaying();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const owner = generation;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (destroyed || !playing || owner !== generation) return;
|
||||
const timeline = getTimeline();
|
||||
if (timeline.length < 2) {
|
||||
api.pause();
|
||||
return;
|
||||
}
|
||||
const next = timeline.find(
|
||||
(time) => Date.parse(time) > Date.parse(target),
|
||||
);
|
||||
void api.setTarget(next ?? timeline[0]);
|
||||
}, 2000);
|
||||
}
|
||||
function transition() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
cancelTimer();
|
||||
const owner = ++generation;
|
||||
if (!canPlay()) stopPlaying();
|
||||
// A scrub drops what loads and moves on; play goes on as this loads.
|
||||
applied = selections();
|
||||
deferred = false;
|
||||
@@ -235,15 +182,12 @@ export function createWeatherClock({
|
||||
if (destroyed || owner !== generation) return false;
|
||||
stopWaiting();
|
||||
pending = null;
|
||||
if (!canPlay()) stopPlaying();
|
||||
notify();
|
||||
schedule();
|
||||
// Play held at the next frame for this one goes on to it.
|
||||
if (deferred) void transition();
|
||||
return true;
|
||||
});
|
||||
pending = result;
|
||||
if (moving) moved ??= result;
|
||||
notify();
|
||||
return result;
|
||||
}
|
||||
@@ -294,81 +238,17 @@ export function createWeatherClock({
|
||||
},
|
||||
getTimeline,
|
||||
selectFor,
|
||||
setTarget(time) {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
if (!Number.isFinite(Date.parse(time)))
|
||||
throw new TypeError('Weather target must be a UTC time');
|
||||
const normalized = new Date(time).toISOString();
|
||||
if (mode === 'history' && target === normalized)
|
||||
return pending ?? Promise.resolve(true);
|
||||
return (
|
||||
move(() => world.seek(Date.parse(normalized))) ??
|
||||
pending ??
|
||||
Promise.resolve(true)
|
||||
);
|
||||
},
|
||||
step(direction) {
|
||||
if (destroyed || ![-1, 1].includes(direction))
|
||||
return Promise.resolve(false);
|
||||
api.pause();
|
||||
const timeline = getTimeline();
|
||||
if (!timeline.length) return Promise.resolve(false);
|
||||
const at = Date.parse(target ?? timeline.at(-1));
|
||||
const next =
|
||||
direction < 0
|
||||
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
|
||||
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
|
||||
return api.setTarget(next);
|
||||
},
|
||||
latest() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
stopPlaying();
|
||||
if (mode === 'latest') return transition();
|
||||
return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
|
||||
},
|
||||
play() {
|
||||
if (destroyed || playing || !canPlay())
|
||||
return pending ?? Promise.resolve(false);
|
||||
playing = true;
|
||||
if (mode === 'latest') {
|
||||
const timeline = getTimeline();
|
||||
const start =
|
||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
|
||||
// Playing and at the first frame in one move of the world.
|
||||
return (
|
||||
move(() => driver.driven(true, Date.parse(start))) ??
|
||||
pending ??
|
||||
Promise.resolve(true)
|
||||
);
|
||||
}
|
||||
driver.driven(true);
|
||||
notify();
|
||||
schedule();
|
||||
return pending ?? Promise.resolve(true);
|
||||
},
|
||||
pause() {
|
||||
cancelTimer();
|
||||
if (!playing) {
|
||||
// The world may be playing on its own; a pause or step here holds it.
|
||||
if (world.isPlaying()) world.pause();
|
||||
return;
|
||||
}
|
||||
stopPlaying();
|
||||
notify();
|
||||
},
|
||||
togglePlay() {
|
||||
return playing ? api.pause() : api.play();
|
||||
},
|
||||
/**
|
||||
* The world clock this clock follows. Time moves there, through the same
|
||||
* seek, play and live the world's timeline uses, never through this clock.
|
||||
*/
|
||||
world,
|
||||
refresh() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
// New frames, or products added or dropped, and with them the next.
|
||||
world.coverageChanged();
|
||||
gated = undefined;
|
||||
gateNext(world.getState());
|
||||
if (!canPlay()) {
|
||||
stopPlaying();
|
||||
cancelTimer();
|
||||
}
|
||||
if (mode === 'history') return transition();
|
||||
notify();
|
||||
return Promise.resolve(true);
|
||||
@@ -380,15 +260,15 @@ export function createWeatherClock({
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
stopPlaying();
|
||||
destroyed = true;
|
||||
++generation;
|
||||
stopWaiting();
|
||||
world.gate(holdId, null);
|
||||
cancelTimer();
|
||||
unfollowWorld();
|
||||
// The last weather clock leaves the world live, as a new one expects.
|
||||
if (driver.detach() === 0) world.goLive();
|
||||
const left = followers.get(world) - 1;
|
||||
followers.set(world, left);
|
||||
if (left === 0) world.goLive();
|
||||
for (const release of releases.values()) release();
|
||||
releases.clear();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
@@ -397,9 +277,8 @@ export function createWeatherClock({
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
// Follow the world clock. A move of this clock's own always applies; one
|
||||
// from elsewhere (a host's seek, continuous play) applies only when it
|
||||
// changes the mode or a product's frame.
|
||||
// Follow the world clock. A move (a seek, play going on) applies only when
|
||||
// it changes the mode or a product's frame.
|
||||
function followWorld(state) {
|
||||
if (destroyed) return;
|
||||
gateNext(state);
|
||||
@@ -420,7 +299,7 @@ export function createWeatherClock({
|
||||
const modeChanged = nextMode !== mode;
|
||||
mode = nextMode;
|
||||
target = nextTarget;
|
||||
if (!moving && !modeChanged && selections() === applied) {
|
||||
if (!modeChanged && selections() === applied) {
|
||||
// Back at the frame that loads, play need not hold for it.
|
||||
if (deferred) {
|
||||
deferred = false;
|
||||
@@ -433,7 +312,7 @@ export function createWeatherClock({
|
||||
}
|
||||
// Playing on into the next frame while the one before it loads, the
|
||||
// world holds here until that one shows.
|
||||
if (!moving && !modeChanged && pending && playsOn) {
|
||||
if (!modeChanged && pending && playsOn) {
|
||||
deferred = true;
|
||||
startWaiting();
|
||||
notify();
|
||||
@@ -456,10 +335,7 @@ export function createWeatherClock({
|
||||
world.gate(holdId, next);
|
||||
}
|
||||
const unfollowWorld = world.subscribe(followWorld);
|
||||
driver = world.attachDriver({
|
||||
play: () => (canPlay() ? void api.play() : false),
|
||||
pause: () => api.pause(),
|
||||
});
|
||||
followers.set(world, (followers.get(world) ?? 0) + 1);
|
||||
followWorld(world.getState());
|
||||
return api;
|
||||
}
|
||||
@@ -158,13 +158,16 @@ test('a product’s gap is at least one and a half of its feed’s frame spacing
|
||||
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 at = (minutes) => Date.parse(time(minutes));
|
||||
|
||||
test('history applies per-product selections, including null; live restores unequal newest times', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
const satellite = product('satellite', [5, 15]);
|
||||
const lightning = product('lightning', [15]);
|
||||
for (const p of [radar, satellite, lightning]) clock.register(p);
|
||||
await clock.setTarget(time(10));
|
||||
world.seek(at(10));
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
[radar.shown, satellite.shown, lightning.shown],
|
||||
[time(10), time(5), null],
|
||||
@@ -173,18 +176,19 @@ test('history applies per-product selections, including null; latest restores un
|
||||
clock.getState().products.map(({ selected }) => selected),
|
||||
[time(10), time(5), null],
|
||||
);
|
||||
await clock.latest();
|
||||
world.goLive();
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
[radar.shown, satellite.shown, lightning.shown],
|
||||
[time(20), time(15), time(15)],
|
||||
);
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
assert.equal(clock.getState().target, null);
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.equal(clock.getState().advancing, false);
|
||||
});
|
||||
|
||||
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
|
||||
const { clock, timers } = harness(t);
|
||||
test('superseded completions cannot commit or notify', async (t) => {
|
||||
const { clock, world, timers } = harness(t);
|
||||
const p = product('radar', [0, 10, 20]);
|
||||
const loads = [];
|
||||
p.apply = (selected, { signal }) =>
|
||||
@@ -201,113 +205,44 @@ test('superseded completions cannot commit, notify or schedule a new playback ad
|
||||
clock.register(p);
|
||||
let notifications = 0;
|
||||
clock.subscribe(() => notifications++);
|
||||
const old = clock.setTarget(time(0));
|
||||
world.seek(at(0));
|
||||
await flush();
|
||||
const current = clock.setTarget(time(10));
|
||||
world.seek(at(10));
|
||||
await flush();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
loads[1].finish();
|
||||
assert.equal(await current, true);
|
||||
await flush();
|
||||
const count = notifications;
|
||||
loads[0].finish();
|
||||
assert.equal(await old, false);
|
||||
await flush();
|
||||
assert.equal(notifications, count);
|
||||
assert.equal(p.shown, time(10));
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
clock.register(product('satellite', [5, 15]));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(15));
|
||||
await clock.setTarget(time(12));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
await clock.setTarget(time(12));
|
||||
await clock.step(1);
|
||||
assert.equal(clock.getState().target, time(15));
|
||||
await clock.setTarget(time(0));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
await clock.setTarget(time(20));
|
||||
await clock.step(1);
|
||||
assert.equal(clock.getState().target, time(20));
|
||||
});
|
||||
|
||||
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
|
||||
const { clock, tick, timers } = harness(t);
|
||||
const a = product('radar', [0, 10]);
|
||||
const b = product('satellite', [5]);
|
||||
const loads = [];
|
||||
b.apply = () =>
|
||||
new Promise((resolve, reject) => loads.push({ resolve, reject }));
|
||||
clock.register(a);
|
||||
clock.register(b);
|
||||
const start = clock.play();
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
tick(10_000);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(a.calls.length, 1);
|
||||
loads[0].reject(new Error('unavailable'));
|
||||
await start;
|
||||
assert.equal(timers.size, 1);
|
||||
tick(1999);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
tick(1);
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
assert.equal(timers.size, 0);
|
||||
tick(10_000);
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
loads[1].resolve(true);
|
||||
await flush();
|
||||
tick(2000);
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(5));
|
||||
clock.pause();
|
||||
loads[2].resolve(false);
|
||||
await flush();
|
||||
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
|
||||
});
|
||||
|
||||
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
|
||||
const { clock, tick, timers } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
clock.register(p);
|
||||
await clock.play();
|
||||
await clock.play();
|
||||
assert.equal(timers.size, 1);
|
||||
clock.pause();
|
||||
tick(2000);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
assert.equal(timers.size, 0);
|
||||
await clock.togglePlay();
|
||||
p.times = [time(0)];
|
||||
await clock.refresh();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
await clock.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
p.times = [time(0), time(10)];
|
||||
await clock.refresh();
|
||||
await clock.play();
|
||||
p.suspended = true;
|
||||
await clock.refresh();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.deepEqual(clock.getTimeline(), []);
|
||||
assert.equal(timers.size, 0);
|
||||
test('the clock has no time controls of its own; the world clock moves time', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
for (const name of [
|
||||
'setTarget',
|
||||
'step',
|
||||
'latest',
|
||||
'play',
|
||||
'pause',
|
||||
'togglePlay',
|
||||
])
|
||||
assert.equal(clock[name], undefined, name);
|
||||
assert.equal(clock.world, world);
|
||||
});
|
||||
|
||||
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const { clock, world } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
clock.register(p);
|
||||
await clock.refresh();
|
||||
assert.equal(p.calls.length, 0);
|
||||
await clock.setTarget(time(10));
|
||||
world.seek(at(10));
|
||||
await flush();
|
||||
p.times = [time(0), time(20)];
|
||||
await clock.refresh();
|
||||
assert.equal(p.shown, time(0));
|
||||
@@ -318,15 +253,16 @@ test('refresh does not apply in latest; history retains its target as expired fr
|
||||
});
|
||||
|
||||
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
|
||||
const { clock, timers, tick } = harness(t);
|
||||
const { clock, world, timers } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
const unregister = clock.register(p);
|
||||
await clock.setTarget(time(0));
|
||||
world.seek(at(0));
|
||||
await flush();
|
||||
const b = product('satellite', [0, 5]);
|
||||
clock.register(b);
|
||||
await flush();
|
||||
assert.equal(b.shown, time(0));
|
||||
await clock.play();
|
||||
world.seek(at(5));
|
||||
unregister();
|
||||
unregister();
|
||||
await flush();
|
||||
@@ -337,31 +273,27 @@ test('registration joins history; unregister and destroy abort work and release
|
||||
);
|
||||
let notifications = 0;
|
||||
const off = clock.subscribe(() => notifications++);
|
||||
clock.pause();
|
||||
off();
|
||||
const before = notifications;
|
||||
await clock.play();
|
||||
assert.equal(notifications, before);
|
||||
world.seek(at(0));
|
||||
await flush();
|
||||
assert.equal(notifications, 0);
|
||||
world.seek(at(5));
|
||||
clock.destroy();
|
||||
clock.destroy();
|
||||
tick(2000);
|
||||
await flush();
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(b.calls.at(-1).signal.aborted, true);
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.deepEqual(clock.getTimeline(), []);
|
||||
assert.equal(await clock.setTarget(time(5)), false);
|
||||
});
|
||||
|
||||
test('the weather time is the world time, both ways', async (t) => {
|
||||
test('the weather time is the world time', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
assert.equal(world.follows('radar'), true);
|
||||
await clock.setTarget(time(10));
|
||||
assert.equal(world.isLive(), false);
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
world.seek(Date.parse(time(3)));
|
||||
world.seek(at(3));
|
||||
await flush();
|
||||
assert.equal(clock.getState().mode, 'history');
|
||||
assert.equal(clock.getState().target, time(3));
|
||||
assert.equal(radar.shown, time(0));
|
||||
world.goLive();
|
||||
@@ -370,30 +302,22 @@ test('the weather time is the world time, both ways', async (t) => {
|
||||
assert.equal(radar.shown, time(20));
|
||||
});
|
||||
|
||||
test('world play and pause drive the weather replay', async (t) => {
|
||||
test('world play runs the world on its own at its rate; weather does not drive it', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.seek(at(0));
|
||||
await flush();
|
||||
world.play();
|
||||
assert.equal(clock.getState().playing, true);
|
||||
assert.equal(world.isPlaying(), true);
|
||||
tick(2000);
|
||||
world.play({ rate: 60 });
|
||||
assert.equal(world.getState().stepping, false);
|
||||
assert.equal(clock.getState().advancing, true);
|
||||
tick(5_000);
|
||||
assert.equal(world.now(), at(5), 'five minutes on at a minute a second');
|
||||
tick(10_000);
|
||||
await flush();
|
||||
assert.equal(radar.shown, time(10));
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
assert.equal(world.now(), at(10), 'held at the next frame');
|
||||
world.pause();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
clock.play();
|
||||
assert.equal(world.isPlaying(), true);
|
||||
clock.pause();
|
||||
assert.equal(world.isPlaying(), false);
|
||||
clock.play();
|
||||
world.goLive();
|
||||
await flush();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
assert.equal(clock.getState().advancing, false);
|
||||
});
|
||||
|
||||
test('a moving world re-applies frames only when a product changes frame', async (t) => {
|
||||
@@ -416,15 +340,6 @@ test('a moving world re-applies frames only when a product changes frame', async
|
||||
assert.equal(radar.shown, time(10));
|
||||
});
|
||||
|
||||
test('a world without frames to step plays on its own', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
tick(1000);
|
||||
assert.equal(world.now(), Date.parse(time(0)) + 1000);
|
||||
});
|
||||
|
||||
function worldAt(at) {
|
||||
return createWorldClock({
|
||||
now: () => at,
|
||||
@@ -434,73 +349,24 @@ function worldAt(at) {
|
||||
});
|
||||
}
|
||||
|
||||
test('weather clocks sharing a world hand play over, and the last leaves it live', async () => {
|
||||
test('weather clocks sharing a world: the last one destroyed leaves it live', () => {
|
||||
const world = worldAt(Date.parse(time(20)));
|
||||
const a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||
const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||
const radarA = product('radar', [0, 10, 20]);
|
||||
const radarB = product('radar', [0, 10, 20]);
|
||||
a.register(radarA);
|
||||
b.register(radarB);
|
||||
await a.play();
|
||||
assert.equal(world.isPlaying(), true);
|
||||
world.pause();
|
||||
assert.equal(a.getState().playing, false);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
await a.play();
|
||||
world.goLive();
|
||||
assert.equal(a.getState().playing, false);
|
||||
b.destroy();
|
||||
assert.equal(world.follows('radar'), true);
|
||||
a.register(product('radar', [0, 10, 20]));
|
||||
b.register(product('radar', [0, 10, 20]));
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
assert.equal(a.getState().playing, true);
|
||||
b.destroy();
|
||||
assert.equal(world.follows('radar'), true);
|
||||
assert.equal(world.isLive(), false);
|
||||
assert.equal(world.isPlaying(), true);
|
||||
a.destroy();
|
||||
assert.equal(world.follows('radar'), false);
|
||||
assert.equal(world.isLive(), true);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
});
|
||||
|
||||
test('pausing from inside the first playing notice stops the world too', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
const off = clock.subscribe((state) => {
|
||||
if (state.playing) {
|
||||
off();
|
||||
clock.pause();
|
||||
}
|
||||
});
|
||||
await clock.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
});
|
||||
|
||||
test('a step holds a world that was playing on its own', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
world.seek(Date.parse(time(0)));
|
||||
world.play();
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
await clock.step(1);
|
||||
assert.equal(world.isPlaying(), false);
|
||||
assert.equal(world.now(), Date.parse(time(10)));
|
||||
});
|
||||
|
||||
test('a target superseded from a listener resolves false', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
await flush();
|
||||
let redirected = false;
|
||||
clock.subscribe((state) => {
|
||||
if (state.target === time(0) && !redirected) {
|
||||
redirected = true;
|
||||
void clock.setTarget(time(10));
|
||||
}
|
||||
});
|
||||
assert.equal(await clock.setTarget(time(0)), false);
|
||||
assert.equal(radar.shown, time(10));
|
||||
});
|
||||
|
||||
test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
assert.equal(world.coverage(), null);
|
||||
|
||||
@@ -53,6 +53,37 @@ const utc = (value) =>
|
||||
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
|
||||
: t('Unavailable');
|
||||
|
||||
/**
|
||||
* Move `world`, the world clock, as its timeline does: `play` toggles play
|
||||
* (from the start of the covered time when live), `latest` goes live, and
|
||||
* `step` (-1 or 1) goes to the previous or next mark, a weather frame among
|
||||
* them, as the timeline's arrow keys do.
|
||||
*/
|
||||
function moveWorldTime(world, { play, latest, step }) {
|
||||
if (!world) return;
|
||||
if (play === true) {
|
||||
const state = world.getState();
|
||||
if (state.playing) world.pause();
|
||||
else {
|
||||
const covered = world.coverage();
|
||||
if (state.live && covered) world.seek(covered.start);
|
||||
world.play({ continuous: true });
|
||||
}
|
||||
}
|
||||
if (latest === true) world.goLive();
|
||||
if (step === -1 || step === 1) {
|
||||
const at = world.getState().timeMs;
|
||||
const marks = world.coverage()?.marks ?? [];
|
||||
const to =
|
||||
step < 0
|
||||
? marks.findLast((mark) => mark < at)
|
||||
: marks.find((mark) => mark > at);
|
||||
if (to === undefined) return;
|
||||
world.pause();
|
||||
world.seek(to);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -254,7 +285,10 @@ export function createWeatherLayer({
|
||||
)
|
||||
return;
|
||||
const times = manifest?.times ?? [];
|
||||
const next = times[(times.indexOf(time) + 1) % times.length];
|
||||
// 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;
|
||||
try {
|
||||
await rendering.prefetch?.(manifest, next, { infrared });
|
||||
@@ -528,11 +562,9 @@ export function createWeatherLayer({
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (clock && enabled) {
|
||||
if (params.play === true) void clock.togglePlay();
|
||||
if (params.latest === true) void clock.latest();
|
||||
if ([-1, 1].includes(params.step)) void clock.step(params.step);
|
||||
}
|
||||
// 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 &&
|
||||
@@ -897,14 +929,14 @@ export function createWeatherLayer({
|
||||
const shared = clock?.getState();
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
playing: shared ? shared.playing : playing && !hostHidden,
|
||||
playing: shared ? shared.advancing : playing && !hostHidden,
|
||||
followLatest: isLatest(),
|
||||
...(shared
|
||||
? {
|
||||
clock: {
|
||||
mode: shared.mode,
|
||||
target: shared.target,
|
||||
playing: shared.playing,
|
||||
playing: shared.advancing,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -35,6 +35,16 @@ const deferred = () => {
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
// Time moves on the world clock the weather clock follows, as the timeline
|
||||
// moves it.
|
||||
const seekTo = (clock, time) => {
|
||||
clock.world.seek(Date.parse(time));
|
||||
return flush();
|
||||
};
|
||||
const goLive = (clock) => {
|
||||
clock.world.goLive();
|
||||
return flush();
|
||||
};
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
@@ -1765,7 +1775,7 @@ test('a delayed installation failure retains the replacement and does not throw
|
||||
h.rendering.clear();
|
||||
});
|
||||
|
||||
test('one row steps every registered observation; missing frames hide and latest restores each product', async (t) => {
|
||||
test('a row steps the world clock through every registered observation; missing frames hide and latest goes live', async (t) => {
|
||||
const clock = createWeatherClock();
|
||||
const radar = layerHarness({ clock });
|
||||
const satellite = layerHarness({
|
||||
@@ -1798,6 +1808,14 @@ test('one row steps every registered observation; missing frames hide and latest
|
||||
.chips.filter(({ id }) =>
|
||||
['previous', 'play', 'next', 'latest'].includes(id),
|
||||
);
|
||||
// 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.
|
||||
radar.layer.setParams({ step: -1 });
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, times[2]);
|
||||
assert.equal(clock.world.isPlaying(), false);
|
||||
for (const h of [radar, satellite]) h.stages.at(-1)?.finish();
|
||||
await flush();
|
||||
radar.layer.setParams({ step: -1 });
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, times[1]);
|
||||
@@ -1849,6 +1867,33 @@ test('one row steps every registered observation; missing frames hide and latest
|
||||
assert.equal(clock.getState().products.length, 1);
|
||||
});
|
||||
|
||||
test('a row’s play and latest move the world clock as its timeline does', async (t) => {
|
||||
const clock = createWeatherClock();
|
||||
const { world } = clock;
|
||||
const h = layerHarness({ clock });
|
||||
t.after(() => {
|
||||
h.layer.destroy();
|
||||
clock.destroy();
|
||||
});
|
||||
const update = h.layer.update();
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await update;
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(world.isPlaying(), true);
|
||||
assert.equal(world.getState().stepping, false, 'the world plays on its own');
|
||||
assert.ok(
|
||||
world.now() - Date.parse(times[0]) < 1_000,
|
||||
'from the start when live',
|
||||
);
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(world.isPlaying(), false);
|
||||
h.layer.setParams({ latest: true });
|
||||
assert.equal(world.isLive(), true);
|
||||
await flush();
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
});
|
||||
|
||||
test('history manifest expiry hides imagery without jumping to latest; host resume keeps the target', async (t) => {
|
||||
const clock = createWeatherClock();
|
||||
let value = snapshot;
|
||||
@@ -1861,7 +1906,7 @@ test('history manifest expiry hides imagery without jumping to latest; host resu
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await update;
|
||||
const history = clock.setTarget(times[0]);
|
||||
const history = seekTo(clock, times[0]);
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await history;
|
||||
@@ -1930,7 +1975,7 @@ test('satellite global selection uses the three-hour gap and preserves the share
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await update;
|
||||
await clock.setTarget(times[0]);
|
||||
await seekTo(clock, times[0]);
|
||||
assert.equal(
|
||||
h.layer.getDiagnostics().hidden,
|
||||
true,
|
||||
@@ -1989,7 +2034,8 @@ test('satellite clouds from an hourly feed draw all through each hour and read l
|
||||
);
|
||||
|
||||
// Forty-five minutes past a frame, between it and the next, it still shows.
|
||||
const between = clock.setTarget(
|
||||
const between = seekTo(
|
||||
clock,
|
||||
new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(),
|
||||
);
|
||||
await flush();
|
||||
@@ -2016,11 +2062,11 @@ test('switching to history cancels a still-loading latest refresh and a later ta
|
||||
const update = h.layer.update();
|
||||
await flush();
|
||||
const latest = h.stages.at(-1);
|
||||
const oldHistory = clock.setTarget(times[0]);
|
||||
const oldHistory = seekTo(clock, times[0]);
|
||||
await flush();
|
||||
assert.equal(latest.signal.aborted, true);
|
||||
const oldStage = h.stages.at(-1);
|
||||
const current = clock.setTarget(times[1]);
|
||||
const current = seekTo(clock, times[1]);
|
||||
await flush();
|
||||
assert.equal(oldStage.signal.aborted, true);
|
||||
h.stages.at(-1).finish();
|
||||
@@ -2330,8 +2376,9 @@ test('failed prefetch leaves the current frame and errors unchanged and releases
|
||||
assert.deepEqual(h.rendering.getDiagnostics(), before);
|
||||
});
|
||||
|
||||
test('layer warms the next advertised observation only after a successful playing frame, wrapping at the end', async (t) => {
|
||||
test('as the world plays, the layer warms the next advertised observation after each frame that shows', async (t) => {
|
||||
const clock = createWeatherClock();
|
||||
const { world } = clock;
|
||||
const h = layerHarness({ clock });
|
||||
const warmed = [];
|
||||
let cancels = 0;
|
||||
@@ -2349,45 +2396,45 @@ test('layer warms the next advertised observation only after a successful playin
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await updated;
|
||||
assert.equal(warmed.length, 0);
|
||||
const play = clock.play();
|
||||
await seekTo(clock, times[0]);
|
||||
h.stages.at(-1).finish();
|
||||
await flush();
|
||||
assert.equal(warmed.length, 0, 'paused, nothing is warmed');
|
||||
world.play({ rate: 1e-9, continuous: true });
|
||||
assert.equal(clock.getState().advancing, true);
|
||||
await seekTo(clock, times[1]);
|
||||
assert.equal(warmed.length, 0, 'still staging');
|
||||
h.stages.at(-1).finish();
|
||||
await play;
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
warmed.map(({ time }) => time),
|
||||
[times[0]],
|
||||
[times[2]],
|
||||
);
|
||||
assert.deepEqual(warmed[0].options, { infrared: 100 });
|
||||
const next = clock.setTarget(times[0]);
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
await next;
|
||||
assert.deepEqual(
|
||||
warmed.map(({ time }) => time),
|
||||
[times[0], times[1]],
|
||||
);
|
||||
const failed = clock.setTarget(times[1]);
|
||||
await flush();
|
||||
await seekTo(clock, times[0]);
|
||||
h.stages.at(-1).finish(false);
|
||||
await failed;
|
||||
assert.equal(warmed.length, 2);
|
||||
const priorCancels = cancels;
|
||||
clock.pause();
|
||||
assert.ok(cancels > priorCancels);
|
||||
const paused = clock.setTarget(times[2]);
|
||||
await flush();
|
||||
assert.equal(warmed.length, 1, 'a failed frame warms nothing');
|
||||
await seekTo(clock, times[2]);
|
||||
h.stages.at(-1).finish();
|
||||
await paused;
|
||||
assert.equal(warmed.length, 2);
|
||||
await clock.play();
|
||||
await flush();
|
||||
assert.equal(warmed.length, 1, 'the world plays on past the newest frame');
|
||||
const priorCancels = cancels;
|
||||
world.pause();
|
||||
await flush();
|
||||
assert.ok(cancels > priorCancels);
|
||||
await seekTo(clock, times[1]);
|
||||
h.stages.at(-1).finish();
|
||||
await flush();
|
||||
assert.equal(warmed.length, 1);
|
||||
world.play({ rate: 1e-9, continuous: true });
|
||||
h.documentRef.hidden = true;
|
||||
h.documentRef.emit('visibilitychange');
|
||||
await flush();
|
||||
h.stages.at(-1).finish();
|
||||
h.stages.at(-1)?.finish();
|
||||
await flush();
|
||||
assert.equal(warmed.length, 2, 'hidden products do not prefetch');
|
||||
assert.equal(warmed.length, 1, 'hidden products do not prefetch');
|
||||
world.goLive();
|
||||
});
|
||||
|
||||
for (const product of ['radar', 'clouds-regional', 'lightning']) {
|
||||
|
||||
@@ -383,20 +383,24 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
|
||||
test('observed history labels wind as a forecast without changing its data or parameters', async () => {
|
||||
const { createWeatherClock } = await import('../weather/clock.js');
|
||||
const clock = createWeatherClock();
|
||||
const { world } = clock;
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
|
||||
let changes = 0;
|
||||
layer.setRowControlsListener(() => changes++);
|
||||
const params = layer.getParams();
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
world.seek(Date.parse('2026-09-14T12:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
|
||||
assert.ok(changes > 0);
|
||||
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
|
||||
assert.deepEqual(layer.getParams(), params);
|
||||
await clock.latest();
|
||||
world.goLive();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getRowControls().summary.status, null);
|
||||
layer.destroy();
|
||||
const before = changes;
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
world.seek(Date.parse('2026-09-14T12:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
assert.equal(changes, before);
|
||||
clock.destroy();
|
||||
});
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
* src/time/snapshotTimeline.js); otherwise they stay live.
|
||||
*
|
||||
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
|
||||
* it: weather replay steps its observed frames itself and moves the clock as
|
||||
* it goes, so play and pause hand over to it. Several drivers may be attached;
|
||||
* play goes to the newest that can play, pause to the one playing.
|
||||
* it: a driver steps the clock itself and moves it as it goes, so play and
|
||||
* pause hand over to it. Several drivers may be attached; play goes to the
|
||||
* newest that can play, pause to the one playing. Layers that load frames
|
||||
* (weather) do not drive it: they gate continuous play at their next frame.
|
||||
*/
|
||||
|
||||
/** The span a JS Date can hold, either side of 1970. */
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||
import { formatUtcDayMonth, t } from '../../i18n/index.js';
|
||||
import { utcClock } from './utcClock.js';
|
||||
import UiSlider from '../kit/UiSlider.js';
|
||||
|
||||
// Stable native transport with local preview and a coalesced 150 ms drag
|
||||
// commit. While the handle is dragged the slider keeps the ticks it was
|
||||
// dragged over; the owner's updates apply once the drag ends.
|
||||
const props = defineProps({
|
||||
state: { type: Object, required: true },
|
||||
sliderClassName: { type: String, default: '' },
|
||||
heading: { type: Boolean, default: true },
|
||||
onCommit: { type: Function, default: () => {} },
|
||||
onPreview: { type: Function, default: () => {} },
|
||||
onStep: { type: Function, default: () => {} },
|
||||
onLatest: { type: Function, default: () => {} },
|
||||
onPlay: { type: Function, default: () => {} },
|
||||
});
|
||||
|
||||
const utc = (tick) =>
|
||||
Number.isFinite(Date.parse(tick))
|
||||
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
|
||||
: '';
|
||||
|
||||
// What the slider shows: the owner's ticks, index and readout, frozen while
|
||||
// dragging.
|
||||
const shown = shallowRef({ ticks: [], index: 0, readout: '' });
|
||||
let dragging = false;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
let live = true;
|
||||
|
||||
const cancel = () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const sync = () => {
|
||||
if (dragging) return;
|
||||
const ticks = [...props.state.ticks];
|
||||
shown.value = {
|
||||
ticks,
|
||||
index: Math.max(0, Math.min(props.state.index, ticks.length - 1)),
|
||||
readout: props.state.readout || '',
|
||||
};
|
||||
};
|
||||
watch(
|
||||
() => props.state,
|
||||
(state) => {
|
||||
if (state.disabled) {
|
||||
cancel();
|
||||
pending = null;
|
||||
dragging = false;
|
||||
}
|
||||
sync();
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
live = false;
|
||||
cancel();
|
||||
});
|
||||
|
||||
const disabled = computed(() => Boolean(props.state.disabled));
|
||||
const latestActive = computed(() => props.state.mode === 'latest');
|
||||
const playing = computed(() => Boolean(props.state.playing));
|
||||
|
||||
function commit() {
|
||||
cancel();
|
||||
const value = pending;
|
||||
pending = null;
|
||||
if (live && value && !props.state.disabled)
|
||||
props.onCommit(value.tick, value.index);
|
||||
}
|
||||
function preview(event) {
|
||||
if (props.state.disabled) return;
|
||||
dragging = true;
|
||||
const index = Number(event.target.value);
|
||||
const tick = shown.value.ticks[index];
|
||||
if (!tick) return;
|
||||
pending = { tick, index };
|
||||
// Shown before the owner hears of it: an update from inside onPreview
|
||||
// must not move the handle back.
|
||||
shown.value = { ...shown.value, index };
|
||||
const readout = props.onPreview(tick, index);
|
||||
// The owner may have ended the drag (disabled) from inside onPreview.
|
||||
if (!dragging) return;
|
||||
if (typeof readout === 'string') shown.value = { ...shown.value, readout };
|
||||
if (timer === null) timer = setTimeout(commit, 150);
|
||||
}
|
||||
// The handle shows the shown index again. A drag that ends where it began
|
||||
// (an input and a cancel, or a refused commit, before the next render)
|
||||
// leaves the slider's model unchanged, so it would not render.
|
||||
const place = (input) => {
|
||||
const index = String(shown.value.index);
|
||||
if (input && input.value !== index) input.value = index;
|
||||
};
|
||||
function change(event) {
|
||||
if (props.state.disabled) return;
|
||||
const index = Number(event.target.value);
|
||||
pending = { tick: shown.value.ticks[index], index };
|
||||
dragging = false;
|
||||
commit();
|
||||
sync();
|
||||
place(event.target);
|
||||
}
|
||||
function reset(event) {
|
||||
cancel();
|
||||
pending = null;
|
||||
dragging = false;
|
||||
sync();
|
||||
place(event?.target);
|
||||
}
|
||||
function blur(event) {
|
||||
if (dragging) change(event);
|
||||
}
|
||||
const act = (fn) => {
|
||||
reset();
|
||||
if (live && !props.state.disabled) fn();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rail-timeline"
|
||||
><div v-if="heading" class="panel-title">{{ t('Observed history') }}</div
|
||||
><div class="rail-timeline-track"
|
||||
><button
|
||||
type="button"
|
||||
class="data-toggle-chip"
|
||||
:title="t('Earlier observation')"
|
||||
:disabled="disabled || state.index <= 0"
|
||||
@click="act(() => onStep(-1))"
|
||||
>‹</button
|
||||
><UiSlider
|
||||
:class="sliderClassName || undefined"
|
||||
min="0"
|
||||
:max="String(Math.max(0, shown.ticks.length - 1))"
|
||||
step="1"
|
||||
:model-value="String(shown.index)"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Observed history')"
|
||||
:aria-valuetext="utc(shown.ticks[shown.index])"
|
||||
@input="preview"
|
||||
@change="change"
|
||||
@pointercancel="reset"
|
||||
@blur="blur"
|
||||
/><button
|
||||
type="button"
|
||||
class="data-toggle-chip"
|
||||
:title="t('Later observation')"
|
||||
:disabled="disabled || state.index >= state.ticks.length - 1"
|
||||
@click="act(() => onStep(1))"
|
||||
>›</button
|
||||
><div class="rail-timeline-endpoints"
|
||||
><span>{{ utcClock(shown.ticks[0]) }}</span
|
||||
><span>{{ utcClock(shown.ticks.at(-1)) }}</span></div
|
||||
></div
|
||||
><div class="rail-timeline-controls"
|
||||
><button
|
||||
type="button"
|
||||
:class="`data-toggle-chip${latestActive ? ' active' : ''}`"
|
||||
:title="t('Newest frame per product')"
|
||||
:disabled="disabled"
|
||||
:aria-pressed="String(latestActive)"
|
||||
@click="act(onLatest)"
|
||||
>{{ t('Latest') }}</button
|
||||
><button
|
||||
type="button"
|
||||
:class="`data-toggle-chip${playing ? ' active' : ''}`"
|
||||
:title="t('Replay observed history')"
|
||||
:disabled="disabled"
|
||||
:aria-pressed="String(playing)"
|
||||
@click="act(onPlay)"
|
||||
>{{ playing ? t('Pause') : t('Play') }}</button
|
||||
><span class="rail-timeline-readout">{{ shown.readout }}</span></div
|
||||
></section
|
||||
>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
import { h, render } from 'vue';
|
||||
import RailTimeline from './rail/RailTimeline.vue';
|
||||
|
||||
export { utcClock } from './rail/utcClock.js';
|
||||
|
||||
/**
|
||||
* Stable native transport with local preview and a coalesced 150 ms drag
|
||||
* commit. Renders the RailTimeline component into `container` and patches it
|
||||
* synchronously on every update.
|
||||
*/
|
||||
export function createRailTimeline({
|
||||
container,
|
||||
document = container?.ownerDocument,
|
||||
sliderClassName = '',
|
||||
heading = true,
|
||||
onCommit = () => {},
|
||||
onPreview = () => {},
|
||||
onStep = () => {},
|
||||
onLatest = () => {},
|
||||
onPlay = () => {},
|
||||
} = {}) {
|
||||
if (!document?.createElement || !container) return null;
|
||||
let destroyed = false;
|
||||
const paint = (state) =>
|
||||
render(
|
||||
h(RailTimeline, {
|
||||
state,
|
||||
sliderClassName,
|
||||
heading,
|
||||
onCommit,
|
||||
onPreview,
|
||||
onStep,
|
||||
onLatest,
|
||||
onPlay,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
paint({
|
||||
ticks: [],
|
||||
index: 0,
|
||||
mode: 'latest',
|
||||
playing: false,
|
||||
readout: '',
|
||||
disabled: true,
|
||||
});
|
||||
return {
|
||||
update(state) {
|
||||
if (!destroyed) paint(state);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
render(null, container);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
import { nextTick } from 'vue';
|
||||
import { createRailTimeline } from './railTimeline.js';
|
||||
import { railDocumentFixture as railFixture } from './railTestFixture.mjs';
|
||||
const ticks = [
|
||||
'2026-09-22T01:00:00Z',
|
||||
'2026-09-22T01:05:00Z',
|
||||
'2026-09-22T01:10:00Z',
|
||||
];
|
||||
const props = {
|
||||
ticks,
|
||||
index: 2,
|
||||
mode: 'latest',
|
||||
playing: false,
|
||||
readout: 'Latest',
|
||||
disabled: false,
|
||||
};
|
||||
test('preview is immediate, commits are coalesced and the drag DOM and tick mapping stay stable', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const f = railFixture();
|
||||
const previews = [];
|
||||
const commits = [];
|
||||
const view = createRailTimeline({
|
||||
...f,
|
||||
onPreview: (tick) => {
|
||||
previews.push(tick);
|
||||
return tick;
|
||||
},
|
||||
onCommit: (tick) => commits.push(tick),
|
||||
});
|
||||
view.update(props);
|
||||
const slider = f.find((n) => n.tagName === 'INPUT');
|
||||
const row = slider.parentElement;
|
||||
assert.equal(slider.getAttribute('aria-label'), 'Observed history');
|
||||
slider.value = '0';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(commits, []);
|
||||
assert.deepEqual(previews, [ticks[0]]);
|
||||
await nextTick();
|
||||
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:00 UTC');
|
||||
view.update({
|
||||
...props,
|
||||
ticks: ticks.slice(1),
|
||||
index: 1,
|
||||
readout: 'Refresh',
|
||||
});
|
||||
assertSameNode(slider.parentElement, row);
|
||||
assertSameNode(row.children[1], slider);
|
||||
assert.equal(slider.value, '0');
|
||||
assert.equal(slider.max, '2');
|
||||
assert.equal(
|
||||
f.find((n) => n.className === 'rail-timeline-readout').textContent,
|
||||
ticks[0],
|
||||
);
|
||||
t.mock.timers.tick(149);
|
||||
assert.equal(commits.length, 0);
|
||||
slider.value = '1';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(commits, [ticks[1]]);
|
||||
slider.value = '2';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(commits, [ticks[1], ticks[2]]);
|
||||
t.mock.timers.tick(150);
|
||||
assert.equal(commits.length, 2);
|
||||
await nextTick();
|
||||
assert.equal(slider.max, '1', 'release applies the latest update');
|
||||
view.destroy();
|
||||
});
|
||||
test('identical updates write nothing; controls call actions and destruction cancels commits', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const f = railFixture();
|
||||
const calls = [];
|
||||
const view = createRailTimeline({
|
||||
...f,
|
||||
onCommit: () => calls.push('commit'),
|
||||
onStep: (n) => calls.push(n),
|
||||
onLatest: () => calls.push('latest'),
|
||||
onPlay: () => calls.push('play'),
|
||||
});
|
||||
view.update({ ...props, index: 1 });
|
||||
f.writes();
|
||||
view.update({ ...props, index: 1 });
|
||||
assert.equal(f.writes(), 0);
|
||||
for (const label of ['‹', '›', 'Latest', 'Play'])
|
||||
f.find((n) => n.textContent === label).click();
|
||||
assert.deepEqual(calls, [-1, 1, 'latest', 'play']);
|
||||
view.update({ ...props, playing: true });
|
||||
assert.ok(f.find((n) => n.textContent === 'Pause'));
|
||||
const slider = f.find((n) => n.tagName === 'INPUT');
|
||||
slider.value = '0';
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.equal(calls.at(-1), 'commit');
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
view.destroy();
|
||||
t.mock.timers.tick(150);
|
||||
assert.equal(calls.length, 5);
|
||||
assert.equal(f.container.children.length, 0);
|
||||
});
|
||||
|
||||
test('observed history label and endpoint times track the advertised tick range', () => {
|
||||
const f = railFixture();
|
||||
const view = createRailTimeline(f);
|
||||
view.update(props);
|
||||
assert.equal(
|
||||
f.find((n) => n.className === 'panel-title').textContent,
|
||||
'Observed history',
|
||||
);
|
||||
const endpoints = f.find((n) => n.className === 'rail-timeline-endpoints');
|
||||
assert.equal(endpoints.parentElement.className, 'rail-timeline-track');
|
||||
assert.deepEqual(
|
||||
[...endpoints.children].map((n) => n.textContent),
|
||||
['01:00 UTC', '01:10 UTC'],
|
||||
);
|
||||
view.update({ ...props, ticks: ticks.slice(1) });
|
||||
assert.equal(endpoints.children[0].textContent, '01:05 UTC');
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
test('an update from inside the preview neither moves the handle back nor survives a disable', async () => {
|
||||
const f = railFixture();
|
||||
let view;
|
||||
let next = null;
|
||||
const commits = [];
|
||||
view = createRailTimeline({
|
||||
...f,
|
||||
onPreview: () => {
|
||||
if (next) view.update(next);
|
||||
return 'preview';
|
||||
},
|
||||
onCommit: (tick, index) => commits.push(index),
|
||||
});
|
||||
view.update(props);
|
||||
const slider = f.find((n) => n.tagName === 'INPUT');
|
||||
next = { ...props, readout: 'Refresh' };
|
||||
slider.value = '0';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
assert.equal(slider.value, '0');
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(commits, [0]);
|
||||
next = { ...props, disabled: true };
|
||||
slider.value = '1';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
await nextTick();
|
||||
assert.equal(slider.value, '2', 'the disabled owner keeps its position');
|
||||
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:10 UTC');
|
||||
view.destroy();
|
||||
});
|
||||
test('a drag that ends where it began puts the handle back on the shown index', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const f = railFixture();
|
||||
const commits = [];
|
||||
const view = createRailTimeline({
|
||||
...f,
|
||||
onCommit: (tick) => commits.push(tick),
|
||||
});
|
||||
view.update(props);
|
||||
await nextTick();
|
||||
const slider = f.find((n) => n.tagName === 'INPUT');
|
||||
slider.value = '0';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
slider.dispatchEvent(new Event('pointercancel'));
|
||||
await nextTick();
|
||||
assert.equal(slider.value, '2', 'a cancelled drag');
|
||||
assert.deepEqual(commits, []);
|
||||
// A refused commit: the owner keeps its index.
|
||||
slider.value = '1';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
assert.deepEqual(commits, [ticks[1]]);
|
||||
assert.equal(slider.value, '2', 'a refused commit');
|
||||
view.destroy();
|
||||
});
|
||||
@@ -125,8 +125,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
/**
|
||||
* Play runs the track at the speed chosen, from the start when it is at
|
||||
* its end (at live, say), as a video plays again from the top. It runs
|
||||
* the clock itself, past weather's own replay: weather follows the time
|
||||
* frame by frame like every other layer the track covers.
|
||||
* the clock itself: weather follows the time frame by frame like every
|
||||
* other layer the track covers.
|
||||
*/
|
||||
togglePlay() {
|
||||
const { playing, live, timeMs, span } = time.value;
|
||||
@@ -138,9 +138,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
},
|
||||
speed,
|
||||
/**
|
||||
* Choose how fast play runs; playing, it changes at once. Weather's own
|
||||
* replay steps at its own pace, so a speed chosen while it plays takes
|
||||
* playback over from where it is.
|
||||
* Choose how fast play runs; playing, it changes at once. A driver steps
|
||||
* at its own pace, so a speed chosen while one plays takes playback over
|
||||
* from where it is.
|
||||
*/
|
||||
setSpeed(next) {
|
||||
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
|
||||
|
||||
@@ -56,8 +56,7 @@
|
||||
}
|
||||
/* Card typography follows the layer rows and app tokens. */
|
||||
.weather-readout,
|
||||
.rail-card,
|
||||
.rail-timeline {
|
||||
.rail-card {
|
||||
min-width: 0;
|
||||
color: var(--text-dim);
|
||||
font: 9px/1.45 var(--font-mono);
|
||||
@@ -128,7 +127,7 @@
|
||||
text-align: right;
|
||||
}
|
||||
.rail-card-line,
|
||||
.rail-timeline-readout {
|
||||
.weather-frame-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -188,54 +187,13 @@ html:not([lang='en']) .weather-card [data-line-id='status'] {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.rail-card [hidden],
|
||||
.weather-timeline-block[hidden],
|
||||
.weather-frame-time[hidden],
|
||||
.weather-observed-group[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.rail-timeline {
|
||||
.weather-frame-time {
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid var(--weather-card-border);
|
||||
}
|
||||
.rail-timeline-track {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 3px 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rail-timeline-track input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.rail-timeline-endpoints {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px 8px;
|
||||
font: 8px/1.45 var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rail-timeline-endpoints span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rail-timeline-endpoints span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
.rail-timeline-controls .data-toggle-chip {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rail-timeline-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rail-timeline-readout {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup>
|
||||
import { t } from '../../i18n/index.js';
|
||||
import RailCards from '../rail/RailCards.vue';
|
||||
import RailTimeline from '../rail/RailTimeline.vue';
|
||||
|
||||
// Active weather: forecast and event cards, then the observed products with
|
||||
// their shared history timeline. The owner computes every value shown.
|
||||
// the time of the frame they show. The world timeline moves time; the owner
|
||||
// computes every value shown.
|
||||
defineProps({
|
||||
view: { type: Object, required: true },
|
||||
onParams: { type: Function, default: () => {} },
|
||||
onOpen: { type: Function, default: () => {} },
|
||||
transport: { type: Object, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -31,16 +30,9 @@ defineProps({
|
||||
:aria-label="t('Observed history')"
|
||||
><h3 class="panel-title">{{ t('Observed history') }}</h3
|
||||
><div class="weather-observed-scope">{{ view.scope }}</div
|
||||
><div class="weather-timeline-block" :hidden="!view.observed"
|
||||
><RailTimeline
|
||||
:state="view.timeline"
|
||||
slider-class-name="weather-timeline"
|
||||
:heading="false"
|
||||
:on-commit="transport.onCommit"
|
||||
:on-preview="transport.onPreview"
|
||||
:on-step="transport.onStep"
|
||||
:on-latest="transport.onLatest"
|
||||
:on-play="transport.onPlay" /></div
|
||||
><div class="weather-frame-time" :hidden="!view.frameTime">{{
|
||||
view.frameTime
|
||||
}}</div
|
||||
><div class="weather-cards"
|
||||
><RailCards
|
||||
:cards="view.observedCards"
|
||||
|
||||
+14
-38
@@ -88,13 +88,6 @@ export function createWeatherPanel({
|
||||
openPanels.set(page, id);
|
||||
render();
|
||||
},
|
||||
transport: {
|
||||
onCommit: (tick) => clock?.setTarget(tick),
|
||||
onPreview: historyTime,
|
||||
onStep: (direction) => clock?.step(direction),
|
||||
onLatest: () => clock?.latest(),
|
||||
onPlay: () => clock?.togglePlay(),
|
||||
},
|
||||
};
|
||||
// The readout patches synchronously, so a newly opened card can be measured.
|
||||
const paint = (view) =>
|
||||
@@ -103,14 +96,7 @@ export function createWeatherPanel({
|
||||
hidden: true,
|
||||
observed: false,
|
||||
scope: '',
|
||||
timeline: {
|
||||
ticks: [],
|
||||
index: 0,
|
||||
mode: 'latest',
|
||||
playing: false,
|
||||
readout: '',
|
||||
disabled: true,
|
||||
},
|
||||
frameTime: '',
|
||||
cards: [],
|
||||
observedCards: [],
|
||||
});
|
||||
@@ -125,32 +111,22 @@ export function createWeatherPanel({
|
||||
.filter(({ summary }) => summary)
|
||||
.sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
|
||||
const observed = active.filter(({ id }) => OBSERVED.has(id));
|
||||
const showTimeline = observed.length > 0;
|
||||
const showObserved = observed.length > 0;
|
||||
const names = {
|
||||
'weather-radar': t('Rain radar'),
|
||||
'weather-satellite': t('Satellite clouds'),
|
||||
'weather-lightning': t('Lightning density'),
|
||||
};
|
||||
const index =
|
||||
state.mode === 'latest'
|
||||
? state.timeline.length - 1
|
||||
: Math.max(
|
||||
0,
|
||||
state.timeline.findLastIndex(
|
||||
(tick) => Date.parse(tick) <= Date.parse(state.target),
|
||||
),
|
||||
);
|
||||
const timeline = {
|
||||
ticks: state.timeline,
|
||||
index,
|
||||
mode: state.mode,
|
||||
playing: Boolean(state.playing),
|
||||
disabled: !showTimeline || state.timeline.length < 2,
|
||||
readout:
|
||||
state.mode === 'latest'
|
||||
? t('LATEST · newest per product')
|
||||
: historyTime(state.target),
|
||||
};
|
||||
// While live, the newest frame and its age; at a past time, the timeline
|
||||
// time alone. The world timeline moves the time.
|
||||
const shownTime =
|
||||
state.mode === 'latest' ? state.timeline.at(-1) : state.target;
|
||||
const frameTime =
|
||||
showObserved && shownTime
|
||||
? state.mode === 'history'
|
||||
? clockTime(shownTime)
|
||||
: historyTime(shownTime)
|
||||
: '';
|
||||
const models = active.map((entry) => {
|
||||
const { id, icon, summary, legend = [], list } = entry;
|
||||
// Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp.
|
||||
@@ -234,9 +210,9 @@ export function createWeatherPanel({
|
||||
const hidden = active.length === 0;
|
||||
paint({
|
||||
hidden,
|
||||
observed: showTimeline,
|
||||
observed: showObserved,
|
||||
scope: observed.map(({ id }) => names[id]).join(' · '),
|
||||
timeline,
|
||||
frameTime,
|
||||
cards: models.filter(({ id }) => !OBSERVED.has(id)),
|
||||
observedCards: models.filter(({ id }) => OBSERVED.has(id)),
|
||||
});
|
||||
|
||||
@@ -54,10 +54,6 @@ function fixture() {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
setTarget: (tick) => calls.push(['target', tick]),
|
||||
latest: () => calls.push(['latest']),
|
||||
togglePlay: () => calls.push(['play']),
|
||||
step: (n) => calls.push(['step', n]),
|
||||
};
|
||||
return {
|
||||
...f,
|
||||
@@ -74,39 +70,36 @@ function fixture() {
|
||||
},
|
||||
};
|
||||
}
|
||||
const timelineHost = (f) =>
|
||||
f.find((n) => n.className === 'weather-timeline-block');
|
||||
const frameTime = (f) => f.find((n) => n.className === 'weather-frame-time');
|
||||
const card = (f, id) => f.find((n) => n.dataset.cardId === id);
|
||||
const line = (f, id, name) =>
|
||||
f.find((n) => n.dataset.lineId === name, card(f, id));
|
||||
test('timeline stays visible with any observed product; native preview and transport use the clock', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
test('the frame time shows with any observed product; the panel has no time controls of its own', (t) => {
|
||||
t.mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.parse('2026-09-22T01:17:00Z'),
|
||||
});
|
||||
const f = fixture();
|
||||
const view = createWeatherPanel(f);
|
||||
view.update([wind]);
|
||||
assert.equal(timelineHost(f).hidden, true);
|
||||
assert.equal(frameTime(f).hidden, true);
|
||||
view.update([wind, radar]);
|
||||
assert.equal(timelineHost(f).hidden, false);
|
||||
assert.equal(frameTime(f).hidden, false);
|
||||
assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago');
|
||||
f.state({ timeline: [ticks[0]] });
|
||||
assert.equal(timelineHost(f).hidden, false);
|
||||
assert.equal(f.find((n) => n.tagName === 'INPUT').disabled, true);
|
||||
assert.equal(frameTime(f).textContent, '01:00 UTC · 17 min ago');
|
||||
f.state({ timeline: [] });
|
||||
assert.equal(timelineHost(f).hidden, false);
|
||||
f.state({ timeline: ticks });
|
||||
assert.equal(timelineHost(f).hidden, false);
|
||||
const slider = f.find((n) => n.tagName === 'INPUT');
|
||||
slider.value = '0';
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(f.calls, []);
|
||||
slider.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(f.calls, [['target', ticks[0]]]);
|
||||
f.find((n) => n.textContent === 'Latest' && n.tagName === 'BUTTON').click();
|
||||
f.find((n) => n.textContent === 'Play').click();
|
||||
assert.deepEqual(f.calls.slice(1), [['latest'], ['play']]);
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
view.update([wind]);
|
||||
t.mock.timers.tick(150);
|
||||
assert.equal(f.calls.length, 3, 'hiding cancels pending drag');
|
||||
assert.equal(frameTime(f).hidden, true);
|
||||
f.state({ timeline: ticks, mode: 'history', target: ticks[0] });
|
||||
assert.equal(frameTime(f).textContent, '01:00 UTC');
|
||||
assert.equal(
|
||||
f.find((n) => n.tagName === 'INPUT'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
f.find((n) => ['Play', 'Latest'].includes(n.textContent)),
|
||||
null,
|
||||
);
|
||||
view.destroy();
|
||||
assert.equal(f.listeners.size, 0);
|
||||
});
|
||||
@@ -179,7 +172,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
|
||||
],
|
||||
};
|
||||
view.update([cyclone]);
|
||||
assert.equal(timelineHost(f).hidden, true);
|
||||
assert.equal(frameTime(f).hidden, true);
|
||||
assert.match(
|
||||
line(f, cyclone.id, 'time').textContent,
|
||||
/JULIO.*Hurricane.*Advisory 10.*UTC/,
|
||||
@@ -410,7 +403,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
|
||||
group.children[1].textContent,
|
||||
'Rain radar · Satellite clouds · Lightning density',
|
||||
);
|
||||
assertSameNode(group.children[2], timelineHost(f));
|
||||
assertSameNode(group.children[2], frameTime(f));
|
||||
assert.deepEqual(
|
||||
[...group.children[3].children].map((n) => n.dataset.cardId),
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'],
|
||||
@@ -672,13 +665,8 @@ test('history time keeps the hour whatever the length of the localized date', ()
|
||||
const f = fixture();
|
||||
const view = createWeatherPanel(f);
|
||||
view.update([wind, radar]);
|
||||
const ends = f.find((n) => n.className === 'rail-timeline-endpoints');
|
||||
assert.deepEqual(
|
||||
[...ends.children].map((n) => n.textContent),
|
||||
['01:00 UTC', '01:05 UTC'],
|
||||
locale,
|
||||
);
|
||||
f.state({ mode: 'history', target: ticks[1] });
|
||||
assert.equal(frameTime(f).textContent, '01:05 UTC', locale);
|
||||
assert.match(
|
||||
line(f, radar.id, 'time').textContent,
|
||||
/(^|[^\d:])01:05 UTC/,
|
||||
|
||||
Reference in new issue
Block a user