mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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.
This commit is contained in:
1 parent
3cfd414e70
commit
326cab4cf2
16 files changed
+141
-242
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": "الغيوم من الأقمار الصناعية",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "עננים מלוויין",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "衛星雲画像",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Спутниковая облачность",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Супутникова хмарність",
|
||||
|
||||
@@ -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": "卫星云图",
|
||||
|
||||
@@ -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": "衛星雲圖",
|
||||
|
||||
+49
-160
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user