mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
No files matched your search
@@ -0,0 +1,218 @@
|
||||
export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
||||
|
||||
/** Transient observed time. Products must honor the signal before installing a frame. */
|
||||
export function createWeatherClock({
|
||||
now = Date.now,
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
} = {}) {
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
let mode = 'latest';
|
||||
let target = null;
|
||||
let playing = false;
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
let destroyed = false;
|
||||
const timesFor = (product) =>
|
||||
[...new Set(product.getTimes())]
|
||||
.filter((time) => Number.isFinite(Date.parse(time)))
|
||||
.sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
const getTimeline = () =>
|
||||
[
|
||||
...new Set(
|
||||
[...registry.values()].flatMap(({ product }) =>
|
||||
product.isSuspended() ? [] : timesFor(product),
|
||||
),
|
||||
),
|
||||
].sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
function selectFor(id, time) {
|
||||
const product = registry.get(id)?.product;
|
||||
if (!product) return null;
|
||||
const at = Date.parse(time);
|
||||
return (
|
||||
timesFor(product).findLast((candidate) => {
|
||||
const age = at - Date.parse(candidate);
|
||||
return age >= 0 && age <= product.maxGapMs;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
const selectedFor = (product) =>
|
||||
mode === 'latest'
|
||||
? (timesFor(product).at(-1) ?? null)
|
||||
: selectFor(product.id, target);
|
||||
const getState = () => ({
|
||||
mode,
|
||||
target,
|
||||
playing,
|
||||
timeline: getTimeline(),
|
||||
products: [...registry.values()].map(({ product }) => ({
|
||||
id: product.id,
|
||||
shown: product.getShownTime() ?? null,
|
||||
selected: selectedFor(product),
|
||||
suspended: Boolean(product.isSuspended()),
|
||||
})),
|
||||
});
|
||||
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;
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (!playing || destroyed || pending) return;
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
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()) playing = false;
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
// Start all products together; a rejection must not skip the settle barrier.
|
||||
const tasks = [...registry.values()].map((entry) => {
|
||||
entry.controller = new AbortController();
|
||||
const { signal } = entry.controller;
|
||||
const time = selectedFor(entry.product);
|
||||
return Promise.resolve().then(() => {
|
||||
if (signal.aborted || owner !== generation || destroyed) return false;
|
||||
return entry.product.apply(time, { signal });
|
||||
});
|
||||
});
|
||||
const result = Promise.allSettled(tasks).then(() => {
|
||||
if (destroyed || owner !== generation) return false;
|
||||
pending = null;
|
||||
if (!canPlay()) playing = false;
|
||||
notify();
|
||||
schedule();
|
||||
return true;
|
||||
});
|
||||
pending = result;
|
||||
notify();
|
||||
return result;
|
||||
}
|
||||
const api = {
|
||||
register(product) {
|
||||
if (destroyed) return () => {};
|
||||
if (registry.has(product.id))
|
||||
throw new Error(`Duplicate weather product: ${product.id}`);
|
||||
const entry = { product, controller: null };
|
||||
registry.set(product.id, entry);
|
||||
void api.refresh();
|
||||
return () => {
|
||||
if (registry.get(product.id) !== entry) return;
|
||||
entry.controller?.abort();
|
||||
registry.delete(product.id);
|
||||
void api.refresh();
|
||||
};
|
||||
},
|
||||
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);
|
||||
mode = 'history';
|
||||
target = normalized;
|
||||
return transition();
|
||||
},
|
||||
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);
|
||||
mode = 'latest';
|
||||
target = null;
|
||||
playing = false;
|
||||
return transition();
|
||||
},
|
||||
play() {
|
||||
if (destroyed || playing || !canPlay())
|
||||
return pending ?? Promise.resolve(false);
|
||||
playing = true;
|
||||
if (mode === 'latest') {
|
||||
const timeline = getTimeline();
|
||||
return api.setTarget(
|
||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
|
||||
);
|
||||
}
|
||||
notify();
|
||||
schedule();
|
||||
return pending ?? Promise.resolve(true);
|
||||
},
|
||||
pause() {
|
||||
cancelTimer();
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
notify();
|
||||
},
|
||||
togglePlay() {
|
||||
return playing ? api.pause() : api.play();
|
||||
},
|
||||
refresh() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
cancelTimer();
|
||||
}
|
||||
if (mode === 'history') return transition();
|
||||
notify();
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
if (!destroyed) listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
++generation;
|
||||
playing = false;
|
||||
cancelTimer();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
registry.clear();
|
||||
listeners.clear();
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWeatherClock,
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
|
||||
const time = (minutes) =>
|
||||
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
|
||||
const p = {
|
||||
id,
|
||||
maxGapMs,
|
||||
times: minutes.map(time),
|
||||
shown: null,
|
||||
suspended: false,
|
||||
calls: [],
|
||||
getTimes: () => p.times,
|
||||
getShownTime: () => p.shown,
|
||||
isSuspended: () => p.suspended,
|
||||
async apply(selected, { signal }) {
|
||||
p.calls.push({ selected, signal });
|
||||
if (signal.aborted) return false;
|
||||
p.shown = selected;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return p;
|
||||
}
|
||||
function harness(t) {
|
||||
let at = Date.parse(time(20));
|
||||
const timers = new Map();
|
||||
let id = 0;
|
||||
const clock = createWeatherClock({
|
||||
now: () => at,
|
||||
setTimeout(fn, delay) {
|
||||
timers.set(++id, { fn, due: at + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
});
|
||||
t.after(() => clock.destroy());
|
||||
return {
|
||||
clock,
|
||||
timers,
|
||||
tick(ms) {
|
||||
at += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.due > at) continue;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [10, 0, 10]);
|
||||
const regional = product('regional', [5, 10]);
|
||||
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
|
||||
const hidden = product('hidden', [99]);
|
||||
hidden.suspended = true;
|
||||
for (const p of [radar, regional, global, hidden]) clock.register(p);
|
||||
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
|
||||
assert.equal(clock.selectFor('radar', time(9)), time(0));
|
||||
assert.equal(clock.selectFor('radar', time(-1)), null);
|
||||
assert.equal(clock.selectFor('radar', time(40)), time(10));
|
||||
assert.equal(clock.selectFor('radar', time(41)), null);
|
||||
assert.equal(clock.selectFor('global', time(120)), time(-60));
|
||||
assert.equal(clock.selectFor('global', time(121)), null);
|
||||
assert.equal(clock.selectFor('missing', time(10)), null);
|
||||
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
|
||||
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
|
||||
});
|
||||
|
||||
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
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));
|
||||
assert.deepEqual(
|
||||
[radar.shown, satellite.shown, lightning.shown],
|
||||
[time(10), time(5), null],
|
||||
);
|
||||
assert.deepEqual(
|
||||
clock.getState().products.map(({ selected }) => selected),
|
||||
[time(10), time(5), null],
|
||||
);
|
||||
await clock.latest();
|
||||
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);
|
||||
});
|
||||
|
||||
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
|
||||
const { clock, timers } = harness(t);
|
||||
const p = product('radar', [0, 10, 20]);
|
||||
const loads = [];
|
||||
p.apply = (selected, { signal }) =>
|
||||
new Promise((resolve) => {
|
||||
loads.push({
|
||||
selected,
|
||||
signal,
|
||||
finish() {
|
||||
if (!signal.aborted) p.shown = selected;
|
||||
resolve(!signal.aborted);
|
||||
},
|
||||
});
|
||||
});
|
||||
clock.register(p);
|
||||
let notifications = 0;
|
||||
clock.subscribe(() => notifications++);
|
||||
const old = clock.setTarget(time(0));
|
||||
await flush();
|
||||
const current = clock.setTarget(time(10));
|
||||
await flush();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
loads[1].finish();
|
||||
assert.equal(await current, true);
|
||||
const count = notifications;
|
||||
loads[0].finish();
|
||||
assert.equal(await old, false);
|
||||
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('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
|
||||
const { clock } = 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));
|
||||
p.times = [time(0), time(20)];
|
||||
await clock.refresh();
|
||||
assert.equal(p.shown, time(0));
|
||||
p.times = [time(20)];
|
||||
await clock.refresh();
|
||||
assert.equal(p.shown, null);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
});
|
||||
|
||||
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
|
||||
const { clock, timers, tick } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
const unregister = clock.register(p);
|
||||
await clock.setTarget(time(0));
|
||||
const b = product('satellite', [0, 5]);
|
||||
clock.register(b);
|
||||
await flush();
|
||||
assert.equal(b.shown, time(0));
|
||||
await clock.play();
|
||||
unregister();
|
||||
unregister();
|
||||
await flush();
|
||||
assert.equal(p.calls.at(-1).signal.aborted, true);
|
||||
assert.deepEqual(
|
||||
clock.getState().products.map(({ id }) => id),
|
||||
['satellite'],
|
||||
);
|
||||
let notifications = 0;
|
||||
const off = clock.subscribe(() => notifications++);
|
||||
clock.pause();
|
||||
off();
|
||||
const before = notifications;
|
||||
await clock.play();
|
||||
assert.equal(notifications, before);
|
||||
clock.destroy();
|
||||
clock.destroy();
|
||||
tick(2000);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
||||
export function resolveImageryHost({ viewer, tileset }) {
|
||||
if (viewer?.scene?.globe?.show === true)
|
||||
return { collection: viewer.imageryLayers, kind: 'globe' };
|
||||
if (tileset?.imageryLayers)
|
||||
return { collection: tileset.imageryLayers, kind: 'tileset' };
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
|
||||
/** Report imagery suspension without changing or detaching the resolved host. */
|
||||
export function imageryHostStatus(host) {
|
||||
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveImageryHost,
|
||||
imageryHostStatus,
|
||||
NO_IMAGERY_HOST,
|
||||
} from './imageryHost.js';
|
||||
|
||||
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
|
||||
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
|
||||
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
|
||||
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
|
||||
});
|
||||
|
||||
test('visible globe takes precedence over a tileset', () => {
|
||||
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
|
||||
{ collection: viewer.imageryLayers, kind: 'globe' },
|
||||
);
|
||||
});
|
||||
test('hidden globe uses the supplied tileset imagery collection', () => {
|
||||
const tileset = { imageryLayers: {} };
|
||||
const viewer = { scene: { globe: { show: false } } };
|
||||
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
|
||||
collection: tileset.imageryLayers,
|
||||
kind: 'tileset',
|
||||
});
|
||||
});
|
||||
test('hidden globe without an imagery-capable tileset has no host', () => {
|
||||
for (const tileset of [null, {}])
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } } },
|
||||
tileset,
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
const priorities = new WeakMap();
|
||||
|
||||
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
|
||||
export function orderWeatherImagery(collection, layer, priority) {
|
||||
priorities.set(layer, priority);
|
||||
if (typeof collection.raiseToTop !== 'function') return;
|
||||
const weather = [];
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (priorities.has(item)) weather.push(item);
|
||||
}
|
||||
const ordered = [...weather].sort(
|
||||
(a, b) => priorities.get(a) - priorities.get(b),
|
||||
);
|
||||
if (weather.every((item, i) => item === ordered[i])) return;
|
||||
for (const item of ordered) collection.raiseToTop(item);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { orderWeatherImagery } from './imageryOrder.js';
|
||||
|
||||
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
|
||||
const base = { name: 'basemap' },
|
||||
radar = { name: 'radar' },
|
||||
cloud = { name: 'cloud' },
|
||||
wind = { name: 'wind' },
|
||||
lightning = { name: 'lightning' };
|
||||
const items = [base];
|
||||
const collection = {
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
get: (i) => items[i],
|
||||
raiseToTop(layer) {
|
||||
items.push(...items.splice(items.indexOf(layer), 1));
|
||||
},
|
||||
};
|
||||
for (const [layer, priority] of [
|
||||
[radar, 2],
|
||||
[cloud, 1],
|
||||
[lightning, 3],
|
||||
[wind, 0],
|
||||
]) {
|
||||
items.push(layer);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
}
|
||||
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
|
||||
const next = { name: 'new radar history frame' };
|
||||
items.push(next);
|
||||
orderWeatherImagery(collection, next, 2);
|
||||
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
|
||||
});
|
||||
|
||||
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
|
||||
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
|
||||
const tileset = { imageryLayers: new ImageryLayerCollection() };
|
||||
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
|
||||
for (const priority of [3, 2, 0, 1]) {
|
||||
tileset.imageryLayers.add(layers[priority]);
|
||||
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
|
||||
}
|
||||
assert.deepEqual(
|
||||
layers.map((_, i) => tileset.imageryLayers.get(i)),
|
||||
layers,
|
||||
);
|
||||
tileset.imageryLayers.destroy();
|
||||
});
|
||||
|
||||
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
|
||||
const items = [];
|
||||
let raises = 0;
|
||||
const collection = {
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
get: (i) => items[i],
|
||||
raiseToTop(layer) {
|
||||
raises++;
|
||||
items.push(...items.splice(items.indexOf(layer), 1));
|
||||
},
|
||||
};
|
||||
for (const priority of [0, 1, 1, 2, 3]) {
|
||||
const layer = {};
|
||||
items.push(layer);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
}
|
||||
assert.equal(raises, 0);
|
||||
});
|
||||
@@ -0,0 +1,727 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import {
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
[20, '#0000f6'],
|
||||
[30, '#00c800'],
|
||||
[40, '#ffff00'],
|
||||
[50, '#ff9000'],
|
||||
[60, '#dc0000'],
|
||||
[70, '#ff00ff'],
|
||||
[80, '#05ede0'],
|
||||
];
|
||||
const LIGHTNING_STOPS = [
|
||||
['0.1', '#FFFFCC'],
|
||||
['1', '#FFA400'],
|
||||
['5', '#FF4500'],
|
||||
['10', '#FF0000'],
|
||||
['50', '#FF00FF'],
|
||||
['100', '#4000C0'],
|
||||
['200', '#00C7FF'],
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
export function createWeatherLayer({
|
||||
feed,
|
||||
clock,
|
||||
id = 'weather-radar',
|
||||
cesium = Cesium,
|
||||
createRendering = createWeatherRendering,
|
||||
documentRef = globalThis.document,
|
||||
eventTarget = globalThis.window,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Weather requires a snapshot source');
|
||||
const radar = id === 'weather-radar';
|
||||
const lightning = id === 'weather-lightning';
|
||||
const satellite = !radar && !lightning;
|
||||
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
|
||||
let opacity = 'strong';
|
||||
let infrared = 'filtered';
|
||||
let viewer = null,
|
||||
rendering = null,
|
||||
manifest = null,
|
||||
request = null,
|
||||
listener = null;
|
||||
let enabled = false,
|
||||
loading = false,
|
||||
playing = false,
|
||||
followLatest = true;
|
||||
let generation = 0,
|
||||
timer = null,
|
||||
error = null,
|
||||
removeCamera = null;
|
||||
let motion = null;
|
||||
let runNavigation = null;
|
||||
let imageryHost = null;
|
||||
let hostCollection;
|
||||
let hostHidden = false;
|
||||
let hostStatus = null;
|
||||
let unregisterClock = null;
|
||||
let frameRequest = null;
|
||||
let noFrame = false;
|
||||
const maxGap = () =>
|
||||
radar
|
||||
? RADAR_MAX_GAP_MS
|
||||
: lightning
|
||||
? LIGHTNING_MAX_GAP_MS
|
||||
: product === 'clouds'
|
||||
? GLOBAL_INFRARED_MAX_GAP_MS
|
||||
: REGIONAL_INFRARED_MAX_GAP_MS;
|
||||
const isLatest = () =>
|
||||
clock ? clock.getState().mode === 'latest' : followLatest;
|
||||
const getHost = () =>
|
||||
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
|
||||
const notify = () => listener?.();
|
||||
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
|
||||
const unsubscribeClock = clock?.subscribe(() => {
|
||||
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
|
||||
notify();
|
||||
});
|
||||
const observationDelayed = () =>
|
||||
manifest?.latest &&
|
||||
Date.now() - Date.parse(manifest.latest) >
|
||||
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
|
||||
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();
|
||||
notify();
|
||||
};
|
||||
|
||||
function checkHost(resume = true) {
|
||||
const host = getHost();
|
||||
const status = imageryHostStatus(host);
|
||||
const changed = host.collection !== hostCollection || hostStatus !== status;
|
||||
hostCollection = host.collection;
|
||||
hostStatus = status;
|
||||
hostHidden = status !== null;
|
||||
// Keep playback intent and the displayed time while the host is unavailable.
|
||||
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();
|
||||
}
|
||||
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);
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = true;
|
||||
rendering?.setHidden(true);
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
function registerClock() {
|
||||
if (!clock || unregisterClock || !rendering || !enabled) return;
|
||||
unregisterClock = clock.register({
|
||||
id,
|
||||
get maxGapMs() {
|
||||
return maxGap();
|
||||
},
|
||||
getTimes: () => manifest?.times ?? [],
|
||||
getShownTime: shownTime,
|
||||
apply: applyClockTime,
|
||||
isSuspended: suspended,
|
||||
});
|
||||
}
|
||||
async function warmNext(time) {
|
||||
if (
|
||||
!enabled ||
|
||||
suspended() ||
|
||||
!(clock ? clock.getState().playing : playing)
|
||||
)
|
||||
return;
|
||||
const times = manifest?.times ?? [];
|
||||
const next = times[(times.indexOf(time) + 1) % times.length];
|
||||
if (!next || next === time) return;
|
||||
try {
|
||||
await rendering.prefetch?.(manifest, next, { infrared });
|
||||
} catch {
|
||||
// Speculative work must not change the displayed frame or playback state.
|
||||
}
|
||||
}
|
||||
async function show(time, signal) {
|
||||
checkHost(false);
|
||||
if (!enabled || hostHidden || !manifest?.times?.includes(time))
|
||||
return false;
|
||||
signal?.throwIfAborted();
|
||||
frameRequest?.abort();
|
||||
const controller = new AbortController();
|
||||
frameRequest = controller;
|
||||
const abort = () => controller.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const owner = ++generation;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
const ok = await rendering.setFrame(manifest, time, {
|
||||
signal: controller.signal,
|
||||
infrared,
|
||||
});
|
||||
if (owner !== generation || !enabled || controller.signal.aborted)
|
||||
return false;
|
||||
if (ok) {
|
||||
noFrame = false;
|
||||
rendering.setHidden?.(false);
|
||||
void warmNext(time);
|
||||
}
|
||||
error = ok ? null : 'Frame unavailable; previous observation retained';
|
||||
if (!ok) stop();
|
||||
return ok;
|
||||
} catch {
|
||||
if (owner === generation && !controller.signal.aborted) {
|
||||
error = 'Weather imagery unavailable';
|
||||
stop();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (frameRequest === controller) frameRequest = null;
|
||||
if (owner === generation) {
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
notify();
|
||||
if (clock && isLatest()) void clock.refresh();
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
}
|
||||
const layer = {
|
||||
id,
|
||||
name: radar
|
||||
? 'Rain radar'
|
||||
: lightning
|
||||
? 'Lightning density'
|
||||
: 'Satellite clouds',
|
||||
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
|
||||
source: 'NOAA nowCOAST · OBSERVED',
|
||||
updateInterval: lightning ? 600_000 : 120_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange: notify,
|
||||
});
|
||||
checkHost(false);
|
||||
eventTarget?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
onMapStackChanged,
|
||||
);
|
||||
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
|
||||
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
motion?.addEventListener?.('change', onVisibility);
|
||||
documentRef?.addEventListener?.('visibilitychange', onVisibility);
|
||||
registerClock();
|
||||
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
|
||||
checkHost();
|
||||
if (enabled) notify();
|
||||
});
|
||||
},
|
||||
attachShellServices(services) {
|
||||
imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
checkHost();
|
||||
runNavigation =
|
||||
typeof services?.runNavigation === 'function'
|
||||
? services.runNavigation
|
||||
: null;
|
||||
notify();
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
registerClock();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
unregisterClock?.();
|
||||
unregisterClock = null;
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = false;
|
||||
request?.abort();
|
||||
request = null;
|
||||
stop();
|
||||
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();
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({
|
||||
product,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (snapshot.unavailable) {
|
||||
error = 'Weather source unavailable; previous observation retained';
|
||||
stop();
|
||||
return true;
|
||||
}
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (clock) {
|
||||
await clock.refresh();
|
||||
if (
|
||||
isLatest() &&
|
||||
request === controller &&
|
||||
!controller.signal.aborted
|
||||
)
|
||||
await show(snapshot.latest, controller.signal);
|
||||
return (
|
||||
!controller.signal.aborted && request === controller && enabled
|
||||
);
|
||||
}
|
||||
const time =
|
||||
followLatest || !snapshot.times.includes(shownTime())
|
||||
? snapshot.latest
|
||||
: shownTime();
|
||||
if (
|
||||
shownTime() !== time ||
|
||||
(satellite && rendering.getDiagnostics().infrared !== infrared)
|
||||
)
|
||||
await show(time, controller.signal);
|
||||
return !controller.signal.aborted && request === controller && enabled;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Weather unavailable';
|
||||
stop();
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = Boolean(rendering?.getDiagnostics().loading);
|
||||
notify();
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
const infraredChanged =
|
||||
satellite &&
|
||||
['filtered', 'full'].includes(params.infrared) &&
|
||||
params.infrared !== infrared;
|
||||
if (infraredChanged) infrared = params.infrared;
|
||||
if (['light', 'strong'].includes(params.opacity)) {
|
||||
opacity = params.opacity;
|
||||
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
|
||||
}
|
||||
if (
|
||||
satellite &&
|
||||
['clouds', 'clouds-regional'].includes(params.product) &&
|
||||
params.product !== product
|
||||
) {
|
||||
product = params.product;
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = false;
|
||||
request?.abort();
|
||||
request = null;
|
||||
stop();
|
||||
manifest = null;
|
||||
error = null;
|
||||
loading = false;
|
||||
followLatest = true;
|
||||
rendering?.clear();
|
||||
if (clock) void clock.refresh();
|
||||
if (enabled) void layer.update(viewer);
|
||||
}
|
||||
if (infraredChanged && enabled && manifest) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
if (clock && !isLatest()) void clock.refresh();
|
||||
else void show(shownTime() || manifest.latest);
|
||||
}
|
||||
if (
|
||||
params.focus === true &&
|
||||
enabled &&
|
||||
manifest?.bounds &&
|
||||
runNavigation
|
||||
) {
|
||||
const b = lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds;
|
||||
runNavigation(() =>
|
||||
viewer.camera.flyTo({
|
||||
destination: cesium.Rectangle.fromDegrees(
|
||||
b.west,
|
||||
b.south,
|
||||
b.east,
|
||||
b.north,
|
||||
),
|
||||
duration: motion?.matches ? 0 : 1.4,
|
||||
}),
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
return radar
|
||||
? { opacity }
|
||||
: satellite
|
||||
? { product, opacity, infrared }
|
||||
: { product, opacity };
|
||||
},
|
||||
getRowControls() {
|
||||
const shared = clock?.getState();
|
||||
const followLatest = isLatest();
|
||||
const missing =
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
||||
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
|
||||
: null;
|
||||
const time = shownTime();
|
||||
const relation =
|
||||
shared?.mode === 'history' && time
|
||||
? Date.parse(time) === Date.parse(shared.target)
|
||||
? ' · synced'
|
||||
: Date.parse(time) < Date.parse(shared.target)
|
||||
? ' · nearest'
|
||||
: ''
|
||||
: '';
|
||||
const times = manifest?.times || [];
|
||||
const index = times.indexOf(time);
|
||||
const current = index >= 0;
|
||||
const age = time
|
||||
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
|
||||
: null;
|
||||
const lag =
|
||||
age === null
|
||||
? ''
|
||||
: age >= 60
|
||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
||||
: `${age}m ago`;
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const point =
|
||||
canvas &&
|
||||
viewer.camera.pickEllipsoid?.(
|
||||
new cesium.Cartesian2(
|
||||
canvas.clientWidth / 2,
|
||||
canvas.clientHeight / 2,
|
||||
),
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
const camera = point
|
||||
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
|
||||
: null;
|
||||
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
|
||||
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
|
||||
const outside =
|
||||
b &&
|
||||
camera &&
|
||||
(lon < b.west ||
|
||||
lon > b.east ||
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: radar
|
||||
? 'Rain radar · US'
|
||||
: lightning
|
||||
? 'Lightning density · 15 min'
|
||||
: 'Satellite clouds',
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? 'Americas + Pacific'
|
||||
: product === 'clouds'
|
||||
? 'Global · 60°S–60°N'
|
||||
: 'North America',
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail: time
|
||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
||||
: missing
|
||||
? 'Observation unavailable'
|
||||
: 'Waiting for observation',
|
||||
status:
|
||||
missing ||
|
||||
hostStatus ||
|
||||
error ||
|
||||
diagnostic?.error ||
|
||||
(observationDelayed()
|
||||
? 'Source observations delayed'
|
||||
: manifest?.stale
|
||||
? 'Stale source'
|
||||
: loading
|
||||
? 'Loading next frame…'
|
||||
: outside
|
||||
? 'Map center outside coverage'
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
? [
|
||||
['clouds-regional', 'N. America'],
|
||||
['clouds', 'Global'],
|
||||
].map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
active: product === value,
|
||||
params: { product: value },
|
||||
title:
|
||||
value === 'clouds'
|
||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
||||
: 'GOES regional clouds; approximately 5-minute updates',
|
||||
}))
|
||||
: []),
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'filtered',
|
||||
label: 'Clouds only',
|
||||
active: infrared === 'filtered',
|
||||
params: { infrared: 'filtered' },
|
||||
title:
|
||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||
},
|
||||
{
|
||||
id: 'full',
|
||||
label: 'Full',
|
||||
active: infrared === 'full',
|
||||
params: { infrared: 'full' },
|
||||
title: 'The complete infrared image at the chosen opacity',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...['light', 'strong'].map((value) => ({
|
||||
id: `opacity-${value}`,
|
||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
||||
active: opacity === value,
|
||||
params: { opacity: value },
|
||||
title: 'Image opacity; does not alter the observed values',
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? 'View US radar'
|
||||
: lightning
|
||||
? 'View Americas & Pacific'
|
||||
: 'View coverage',
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
],
|
||||
legend:
|
||||
radar || lightning
|
||||
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
||||
: `${label} dBZ radar reflectivity`,
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
? hostStatus
|
||||
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
|
||||
infoTitle: lightning
|
||||
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
|
||||
: radar
|
||||
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
|
||||
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'region',
|
||||
label: 'REGION',
|
||||
chips: controls.chips.filter(({ params }) => params.product),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'OPACITY',
|
||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||
},
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'coverage',
|
||||
);
|
||||
return controls;
|
||||
},
|
||||
setRowControlsListener(value) {
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? 'Observed' : 'History',
|
||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
stale: Boolean(manifest?.stale || observationDelayed()),
|
||||
source: 'NOAA nowCOAST',
|
||||
observedAt: shownTime(),
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
const shared = clock?.getState();
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
playing: shared ? shared.playing : playing && !hostHidden,
|
||||
followLatest: isLatest(),
|
||||
...(shared
|
||||
? {
|
||||
clock: {
|
||||
mode: shared.mode,
|
||||
target: shared.target,
|
||||
playing: shared.playing,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
historyFrames: manifest?.times?.length || 0,
|
||||
timerActive: timer !== null,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
removeCamera?.();
|
||||
removeCamera = null;
|
||||
motion?.removeEventListener?.('change', onVisibility);
|
||||
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
|
||||
eventTarget?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
onMapStackChanged,
|
||||
);
|
||||
runNavigation = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
rendering = null;
|
||||
listener = null;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const INFRARED_ALPHA_T0 = 0.4;
|
||||
export const INFRARED_ALPHA_T1 = 0.7;
|
||||
|
||||
/** Return display pixels without changing RGB or the source RGBA bytes. */
|
||||
export function infraredAlpha(rgba, mode = 'filtered') {
|
||||
const pixels = new Uint8ClampedArray(rgba);
|
||||
if (mode === 'full') return pixels;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
|
||||
const linearMax =
|
||||
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(linearMax - INFRARED_ALPHA_T0) /
|
||||
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
|
||||
),
|
||||
);
|
||||
pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
infraredAlpha,
|
||||
INFRARED_ALPHA_T0,
|
||||
INFRARED_ALPHA_T1,
|
||||
} from './infraredAlpha.js';
|
||||
|
||||
test('filtered infrared preserves RGB and input bytes, scales existing alpha, and matches the old linear threshold', () => {
|
||||
assert.equal(INFRARED_ALPHA_T0, 0.4);
|
||||
assert.equal(INFRARED_ALPHA_T1, 0.7);
|
||||
const bytes = new Uint8ClampedArray([
|
||||
0, 0, 0, 255, 168, 12, 50, 255, 12, 194, 50, 200, 12, 50, 217, 128, 255,
|
||||
255, 255, 0,
|
||||
]);
|
||||
const original = bytes.slice();
|
||||
const result = infraredAlpha(bytes);
|
||||
assert.deepEqual(bytes, original);
|
||||
for (let i = 0; i < bytes.length; i += 4)
|
||||
assert.deepEqual(result.slice(i, i + 3), bytes.slice(i, i + 3));
|
||||
assert.deepEqual(
|
||||
[result[3], result[7], result[11], result[15], result[19]],
|
||||
[0, 0, 98, 128, 0],
|
||||
);
|
||||
// Cesium's old 0.55 hard cut falls between byte values 194 and 195.
|
||||
assert.ok((194 / 255) ** 2.2 < 0.55);
|
||||
assert.ok((195 / 255) ** 2.2 > 0.55);
|
||||
const ramp = [];
|
||||
for (let value = 0; value < 256; value++) {
|
||||
ramp.push(infraredAlpha([value, value, value, 255])[3]);
|
||||
}
|
||||
assert.ok(ramp[194] < 127.5 && ramp[195] > 127.5);
|
||||
assert.ok(ramp.every((value, i) => !i || value >= ramp[i - 1]));
|
||||
assert.ok(Math.max(...ramp.slice(1).map((v, i) => v - ramp[i])) <= 8);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
(0.55 - INFRARED_ALPHA_T0) / (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0) -
|
||||
0.5,
|
||||
) < 1e-12,
|
||||
);
|
||||
});
|
||||
|
||||
test('full infrared preserves every channel including partial and zero alpha', () => {
|
||||
const pixels = new Uint8ClampedArray([
|
||||
12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0,
|
||||
]);
|
||||
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import { weatherImageUrl } from './source.js';
|
||||
import { infraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Apply the display transfer once to a decoded image. */
|
||||
export function processInfraredImage(image, mode, createCanvas) {
|
||||
const canvas = createCanvas();
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
pixels.data.set(infraredAlpha(pixels.data, mode));
|
||||
context.putImageData(pixels, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Decode locally, releasing the object URL on success, failure or cancellation. */
|
||||
export function decodeInfraredImage(blob, signal) {
|
||||
signal.throwIfAborted();
|
||||
if (typeof globalThis.createImageBitmap === 'function')
|
||||
return globalThis.createImageBitmap(blob);
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
image.onload = image.onerror = null;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(signal.reason);
|
||||
};
|
||||
image.onload = () => {
|
||||
cleanup();
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('Infrared decode failed'));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
|
||||
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
|
||||
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
|
||||
* image is always released. */
|
||||
export async function acquireWeatherImage(
|
||||
product,
|
||||
time,
|
||||
{
|
||||
signal,
|
||||
mode,
|
||||
size,
|
||||
bbox = null,
|
||||
maxBytes,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage = decodeInfraredImage,
|
||||
now = () => performance.now(),
|
||||
onFetched = () => {},
|
||||
},
|
||||
) {
|
||||
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
|
||||
const bytes = await readResponseBytesCapped(response, maxBytes);
|
||||
signal.throwIfAborted();
|
||||
onFetched();
|
||||
const started = now();
|
||||
const image = await decodeImage(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
if (image.width !== size.width || image.height !== size.height)
|
||||
throw new Error('Invalid weather image dimensions');
|
||||
let texture;
|
||||
if (product === 'clouds' || product === 'clouds-regional')
|
||||
texture = processInfraredImage(image, mode, createCanvas);
|
||||
else {
|
||||
texture = createCanvas();
|
||||
texture.width = image.width;
|
||||
texture.height = image.height;
|
||||
texture.getContext('2d').drawImage(image, 0, 0);
|
||||
}
|
||||
return { texture, decodeMs: now() - started };
|
||||
} finally {
|
||||
image.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire one capped global mosaic before adding any imagery layer. */
|
||||
export function acquireInfraredMosaic(time, options) {
|
||||
return acquireWeatherImage('clouds', time, {
|
||||
...options,
|
||||
size: { width: 2048, height: 1024 },
|
||||
maxBytes: MAX_MOSAIC_BYTES,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
decodeInfraredImage,
|
||||
processInfraredImage,
|
||||
MAX_MOSAIC_BYTES,
|
||||
} from './infraredImage.js';
|
||||
|
||||
function canvas() {
|
||||
const output = {};
|
||||
output.getContext = () => ({
|
||||
drawImage: (image) => {
|
||||
output.image = image;
|
||||
},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
|
||||
putImageData: (pixels) => {
|
||||
output.pixels = pixels.data;
|
||||
},
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
test('decoded pixels are processed once on a same-sized canvas', () => {
|
||||
const image = { width: 1, height: 1 };
|
||||
const output = processInfraredImage(image, 'filtered', canvas);
|
||||
assert.equal(output.width, 1);
|
||||
assert.equal(output.height, 1);
|
||||
assert.equal(output.image, image);
|
||||
assert.deepEqual([...output.pixels], [194, 0, 0, 98]);
|
||||
assert.deepEqual(
|
||||
[...processInfraredImage(image, 'full', canvas).pixels],
|
||||
[194, 0, 0, 200],
|
||||
);
|
||||
});
|
||||
|
||||
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
|
||||
const controller = new AbortController();
|
||||
let closes = 0,
|
||||
fetches = 0,
|
||||
clock = 10,
|
||||
fetched = false;
|
||||
const options = {
|
||||
signal: controller.signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: canvas,
|
||||
now: () => clock,
|
||||
onFetched: () => {
|
||||
fetched = true;
|
||||
},
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
fetches++;
|
||||
assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/);
|
||||
assert.equal(signal, controller.signal);
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
decodeImage: async (blob) => {
|
||||
assert.equal(fetched, true);
|
||||
assert.equal(blob.size, 3);
|
||||
assert.equal(blob.type, 'image/png');
|
||||
clock = 25;
|
||||
return {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close() {
|
||||
closes++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await acquireInfraredMosaic(
|
||||
'2026-09-15T20:00:00.000Z',
|
||||
options,
|
||||
);
|
||||
assert.equal(fetches, 1);
|
||||
assert.equal(closes, 1);
|
||||
assert.equal(result.decodeMs, 15);
|
||||
assert.equal(result.texture.width, 2048);
|
||||
for (const response of [
|
||||
new Response('', {
|
||||
headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) },
|
||||
}),
|
||||
new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('2026-09-15', {
|
||||
...options,
|
||||
fetchImpl: async () => response,
|
||||
decodeImage: () => assert.fail('oversized image must not decode'),
|
||||
}),
|
||||
{ code: 'RESPONSE_TOO_LARGE' },
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('2026-09-15', {
|
||||
...options,
|
||||
decodeImage: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close() {
|
||||
closes++;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(closes, 2);
|
||||
});
|
||||
|
||||
test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => {
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'createImageBitmap',
|
||||
);
|
||||
t.after(() => {
|
||||
if (original)
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', original);
|
||||
else delete globalThis.createImageBitmap;
|
||||
});
|
||||
const bitmap = {};
|
||||
globalThis.createImageBitmap = async () => bitmap;
|
||||
assert.equal(
|
||||
await decodeInfraredImage(new Blob(), new AbortController().signal),
|
||||
bitmap,
|
||||
);
|
||||
globalThis.createImageBitmap = undefined;
|
||||
const images = [],
|
||||
revoked = [];
|
||||
const originalImage = globalThis.Image;
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
images.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
if (originalImage) globalThis.Image = originalImage;
|
||||
else delete globalThis.Image;
|
||||
});
|
||||
t.mock.method(URL, 'createObjectURL', () => 'blob:test');
|
||||
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
|
||||
for (const action of ['load', 'error', 'abort']) {
|
||||
const controller = new AbortController();
|
||||
const pending = decodeInfraredImage(new Blob(), controller.signal);
|
||||
const image = images.at(-1);
|
||||
assert.equal(image.src, 'blob:test');
|
||||
if (action === 'load') {
|
||||
image.onload();
|
||||
assert.equal(await pending, image);
|
||||
} else if (action === 'error') {
|
||||
image.onerror();
|
||||
await assert.rejects(pending, /decode failed/);
|
||||
} else {
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(image.src, '');
|
||||
}
|
||||
assert.equal(image.onload, null);
|
||||
assert.equal(image.onerror, null);
|
||||
}
|
||||
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Tile a bounded decoded canvas without network requests or scene state. */
|
||||
export function createRasterTileProvider({
|
||||
cesium,
|
||||
texture,
|
||||
credit,
|
||||
createCanvas,
|
||||
rectangle = cesium.Rectangle.MAX_VALUE,
|
||||
tilingScheme = new cesium.GeographicTilingScheme({ rectangle }),
|
||||
maximumLevel = 2,
|
||||
tileSize = 256,
|
||||
}) {
|
||||
return {
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: tileSize,
|
||||
tileHeight: tileSize,
|
||||
minimumLevel: 0,
|
||||
maximumLevel,
|
||||
ready: true,
|
||||
tileDiscardPolicy: undefined,
|
||||
credit,
|
||||
errorEvent: new cesium.Event(),
|
||||
hasAlphaChannel: true,
|
||||
getTileCredits: () => undefined,
|
||||
pickFeatures: () => undefined,
|
||||
requestImage(x, y, level) {
|
||||
const tile = createCanvas();
|
||||
tile.width = tile.height = tileSize;
|
||||
const ctx = tile.getContext('2d');
|
||||
const width =
|
||||
texture.width / tilingScheme.getNumberOfXTilesAtLevel(level);
|
||||
const height =
|
||||
texture.height / tilingScheme.getNumberOfYTilesAtLevel(level);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(
|
||||
texture,
|
||||
x * width,
|
||||
y * height,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
tileSize,
|
||||
tileSize,
|
||||
);
|
||||
// ImageryLayer consumes requestImage results as promises.
|
||||
return Promise.resolve(tile);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createFieldRaster } from '../wind/fields.js';
|
||||
|
||||
// A small canvas double exposes the uploaded raster and exact drawImage crop.
|
||||
// Verify crop coordinates and source-cell selection independently of canvas smoothing.
|
||||
function createCanvas() {
|
||||
const canvas = { width: 0, height: 0 };
|
||||
const context = {
|
||||
createImageData: (width, height) => ({
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
}),
|
||||
putImageData(pixels) {
|
||||
canvas.rgba = pixels.data;
|
||||
},
|
||||
drawImage(source, ...args) {
|
||||
canvas.source = source;
|
||||
canvas.crop = args;
|
||||
},
|
||||
getImageData(x, y) {
|
||||
const [sx, sy, sw, sh, dx, dy, dw, dh] = canvas.crop;
|
||||
const col = Math.floor(sx + ((x - dx + 0.5) * sw) / dw);
|
||||
const row = Math.floor(sy + ((y - dy + 0.5) * sh) / dh);
|
||||
const offset = (row * canvas.source.width + col) * 4;
|
||||
return { data: canvas.source.rgba.slice(offset, offset + 4) };
|
||||
},
|
||||
};
|
||||
canvas.getContext = () => context;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
test('real Cesium geographic provider crops level 0/1 tiles from a decoded canvas with preserved pixels', async () => {
|
||||
const raster = createFieldRaster(
|
||||
{
|
||||
nx: 8,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 45,
|
||||
dy: 90,
|
||||
u: Float32Array.from([
|
||||
0, 0, 10, 10, 20, 20, 30, 30, 0, 0, 10, 10, 20, 20, 30, 30, 30, 30, 20,
|
||||
20, 10, 10, 0, 0,
|
||||
]),
|
||||
v: new Float32Array(24),
|
||||
},
|
||||
'speed',
|
||||
720,
|
||||
362,
|
||||
);
|
||||
const credit = new Cesium.Credit('NOAA GFS');
|
||||
const texture = {
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
rgba: raster.rgba,
|
||||
};
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
credit,
|
||||
createCanvas,
|
||||
});
|
||||
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
|
||||
assert.equal(provider.rectangle, Cesium.Rectangle.MAX_VALUE);
|
||||
assert.equal(provider.minimumLevel, 0);
|
||||
assert.equal(provider.maximumLevel, 2);
|
||||
assert.equal(provider.tileWidth, 256);
|
||||
assert.equal(provider.tileHeight, 256);
|
||||
assert.equal(provider.ready, true);
|
||||
assert.equal(provider.hasAlphaChannel, true);
|
||||
assert.equal(provider.credit, credit);
|
||||
assert.ok(provider.errorEvent instanceof Cesium.Event);
|
||||
assert.equal(provider.tileDiscardPolicy, undefined);
|
||||
assert.equal(provider.getTileCredits(0, 0, 0), undefined);
|
||||
assert.equal(provider.pickFeatures(0, 0, 0, 0, 0), undefined);
|
||||
for (const [x, y, level, crop, cell] of [
|
||||
[0, 0, 0, [0, 0, 360, 362, 0, 0, 256, 256], [90, 91]],
|
||||
[1, 0, 0, [360, 0, 360, 362, 0, 0, 256, 256], [450, 91]],
|
||||
[0, 0, 1, [0, 0, 180, 181, 0, 0, 256, 256], [45, 45]],
|
||||
[3, 1, 1, [540, 181, 180, 181, 0, 0, 256, 256], [585, 226]],
|
||||
]) {
|
||||
const pending = provider.requestImage(x, y, level);
|
||||
assert.ok(pending instanceof Promise, 'ImageryLayer consumes a promise');
|
||||
const tile = await pending;
|
||||
assert.equal(tile.width, 256);
|
||||
assert.equal(tile.height, 256);
|
||||
assert.equal(tile.getContext('2d').imageSmoothingEnabled, true);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
const offset = (cell[1] * 720 + cell[0]) * 4;
|
||||
assert.deepEqual(
|
||||
tile.getContext('2d').getImageData(64, 64).data,
|
||||
raster.rgba.slice(offset, offset + 4),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('bounded decoded mosaic crops both roots and higher levels without resampling its extent', async () => {
|
||||
const texture = { width: 2048, height: 1024 };
|
||||
const rectangle = Cesium.Rectangle.fromDegrees(-180, -60, 180, 60);
|
||||
const scheme = new Cesium.GeographicTilingScheme({
|
||||
rectangle,
|
||||
numberOfLevelZeroTilesX: 2,
|
||||
numberOfLevelZeroTilesY: 1,
|
||||
});
|
||||
const credit = new Cesium.Credit('NOAA');
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
rectangle,
|
||||
tilingScheme: scheme,
|
||||
maximumLevel: 3,
|
||||
credit,
|
||||
createCanvas,
|
||||
});
|
||||
assert.equal(provider.rectangle, rectangle);
|
||||
assert.equal(provider.maximumLevel, 3);
|
||||
assert.equal(provider.credit, credit);
|
||||
assert.equal(provider.hasAlphaChannel, true);
|
||||
for (const [x, y, level, crop] of [
|
||||
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 256, 256]],
|
||||
[1, 0, 0, [1024, 0, 1024, 1024, 0, 0, 256, 256]],
|
||||
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 256, 256]],
|
||||
]) {
|
||||
const pending = provider.requestImage(x, y, level);
|
||||
assert.ok(pending instanceof Promise);
|
||||
const tile = await pending;
|
||||
assert.equal(tile.source, texture);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
}
|
||||
});
|
||||
|
||||
test('512-pixel crops preserve source extent at root and clamped draping levels', async () => {
|
||||
const texture = { width: 2048, height: 1024 };
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
createCanvas,
|
||||
tileSize: 512,
|
||||
maximumLevel: 3,
|
||||
});
|
||||
assert.equal(provider.tileWidth, 512);
|
||||
assert.equal(provider.tileHeight, 512);
|
||||
for (const [x, y, level, crop] of [
|
||||
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 512, 512]],
|
||||
[3, 1, 1, [1536, 512, 512, 512, 0, 0, 512, 512]],
|
||||
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 512, 512]],
|
||||
]) {
|
||||
const tile = await provider.requestImage(x, y, level);
|
||||
assert.equal(tile.width, 512);
|
||||
assert.equal(tile.height, 512);
|
||||
assert.equal(tile.source, texture);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import { weatherTileUrl } from './source.js';
|
||||
import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
processInfraredImage,
|
||||
} from './infraredImage.js';
|
||||
// Bounded display detail for the hourly, approximately 3 km global product.
|
||||
const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
|
||||
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
|
||||
const MAX_MOSAICS = 6;
|
||||
const MAX_PREFETCH_TILES = 8;
|
||||
|
||||
/** Globe imagery: own at most a displayed and a staging frame. Use native Cesium
|
||||
* tile scheduling, projection and texture disposal; the application clock is never touched. */
|
||||
function createGlobeRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange,
|
||||
timeoutMs,
|
||||
now,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
createCanvas,
|
||||
}) {
|
||||
let current = null;
|
||||
let incoming = null;
|
||||
const retiring = new Set();
|
||||
let alpha = 0.7;
|
||||
let frameHidden = false;
|
||||
let lastError = null;
|
||||
const mosaics = new Map();
|
||||
let prefetchJob = null;
|
||||
let prefetchedKey = null;
|
||||
|
||||
function profile(product) {
|
||||
return {
|
||||
tileSize: 256,
|
||||
maximumLevel: product === 'clouds' ? GLOBAL_TILE_MAXIMUM_LEVEL : 6,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelPrefetch() {
|
||||
clearTimeout(prefetchJob?.timeout);
|
||||
prefetchJob?.controller.abort();
|
||||
prefetchJob = null;
|
||||
prefetchedKey = null;
|
||||
}
|
||||
|
||||
async function mosaic(time, mode, signal, onFetched) {
|
||||
signal.throwIfAborted();
|
||||
const key = `${time}|${mode}`;
|
||||
const texture = mosaics.get(key);
|
||||
if (texture) {
|
||||
mosaics.delete(key);
|
||||
mosaics.set(key, texture);
|
||||
return { texture, decodeMs: 0, cached: true };
|
||||
}
|
||||
const result = await acquireInfraredMosaic(time, {
|
||||
signal,
|
||||
mode,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
now,
|
||||
onFetched,
|
||||
});
|
||||
// An aborted decode may still finish; never repopulate a cleared instance.
|
||||
signal.throwIfAborted();
|
||||
mosaics.delete(key);
|
||||
mosaics.set(key, result.texture);
|
||||
while (mosaics.size > MAX_MOSAICS)
|
||||
mosaics.delete(mosaics.keys().next().value);
|
||||
return { ...result, cached: false };
|
||||
}
|
||||
|
||||
function prefetchTiles(snapshot, time) {
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const bounds = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
const view = viewer.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
const coverage = view
|
||||
? cesium.Rectangle.intersection(bounds, view)
|
||||
: bounds;
|
||||
if (!coverage) return [];
|
||||
const scheme = new cesium.GeographicTilingScheme();
|
||||
const template = weatherTileUrl(snapshot.product, time, {
|
||||
size: profile(snapshot.product).tileSize,
|
||||
});
|
||||
const urls = [];
|
||||
for (let z = 0; z <= 1; z++) {
|
||||
for (let y = 0; y < scheme.getNumberOfYTilesAtLevel(z); y++) {
|
||||
for (let x = 0; x < scheme.getNumberOfXTilesAtLevel(z); x++) {
|
||||
if (
|
||||
!cesium.Rectangle.intersection(
|
||||
coverage,
|
||||
scheme.tileXYToRectangle(x, y, z),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
urls.push(
|
||||
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
|
||||
);
|
||||
if (urls.length === MAX_PREFETCH_TILES) return urls;
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function remove(frame) {
|
||||
if (!frame) return;
|
||||
frame.closed = true;
|
||||
frame.controller.abort();
|
||||
frame.offRetire?.();
|
||||
frame.offInstall?.();
|
||||
retiring.delete(frame);
|
||||
frame.offError?.();
|
||||
frame.offAbort?.();
|
||||
frame.offRender?.();
|
||||
frame.offCamera?.();
|
||||
clearTimeout(frame.timeout);
|
||||
for (const request of frame.requests) request.cancel?.();
|
||||
frame.requests.clear();
|
||||
frame.retries.clear();
|
||||
if (!frame.layer) return;
|
||||
const collection = frame.collection;
|
||||
if (
|
||||
collection &&
|
||||
!collection.isDestroyed?.() &&
|
||||
collection.contains(frame.layer)
|
||||
)
|
||||
collection.remove(frame.layer, true);
|
||||
else if (!frame.layer.isDestroyed?.()) frame.layer.destroy?.();
|
||||
}
|
||||
function cancelIncoming() {
|
||||
if (!incoming) return;
|
||||
const previous = incoming;
|
||||
incoming = null;
|
||||
remove(previous);
|
||||
previous.resolve(false);
|
||||
}
|
||||
function rehome() {
|
||||
const host = getHost();
|
||||
const { collection } = host;
|
||||
const hidden = frameHidden || imageryHostStatus(host) !== null;
|
||||
const changed =
|
||||
(current && current.collection !== collection) ||
|
||||
(incoming && incoming.collection !== collection);
|
||||
const visibilityChanged = current && current.layer.show === hidden;
|
||||
if (changed || imageryHostStatus(host) !== null) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
for (const frame of retiring) remove(frame);
|
||||
}
|
||||
if (current) {
|
||||
if (!hidden && current.layer.alpha !== alpha) current.layer.alpha = alpha;
|
||||
if (visibilityChanged) current.layer.show = !hidden;
|
||||
}
|
||||
if (current && current.collection !== collection) {
|
||||
current.collection?.remove(current.layer, false);
|
||||
current.collection = collection;
|
||||
if (collection) {
|
||||
collection.add(current.layer);
|
||||
orderWeatherImagery(collection, current.layer, current.priority);
|
||||
}
|
||||
}
|
||||
if (changed || visibilityChanged) viewer.scene.requestRender();
|
||||
return Boolean(changed || visibilityChanged);
|
||||
}
|
||||
return {
|
||||
rehome,
|
||||
cancelPrefetch,
|
||||
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
if (
|
||||
frameHidden ||
|
||||
incoming ||
|
||||
imageryHostStatus(getHost()) ||
|
||||
!snapshot.times.includes(time)
|
||||
)
|
||||
return false;
|
||||
const global = snapshot.product === 'clouds';
|
||||
const urls = global ? [] : prefetchTiles(snapshot, time);
|
||||
const key = global ? `${time}|${infrared}` : urls.join('|');
|
||||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||||
cancelPrefetch();
|
||||
job = { key, controller: new AbortController() };
|
||||
prefetchJob = job;
|
||||
const { signal } = job.controller;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
if (global) await mosaic(time, infrared, signal);
|
||||
else
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const response = await fetchImpl(url, { signal });
|
||||
if (!response.ok) throw new Error('Weather prefetch unavailable');
|
||||
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
|
||||
}),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (prefetchJob === job) prefetchedKey = key;
|
||||
return true;
|
||||
} catch {
|
||||
job?.controller.abort();
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(job?.timeout);
|
||||
if (job && prefetchJob === job) prefetchJob = null;
|
||||
}
|
||||
},
|
||||
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
cancelPrefetch();
|
||||
rehome();
|
||||
cancelIncoming();
|
||||
const host = getHost();
|
||||
const { collection, kind } = host;
|
||||
if (imageryHostStatus(host)) return false;
|
||||
const nextProfile = profile(snapshot.product);
|
||||
if (
|
||||
current?.time === time &&
|
||||
current.product === snapshot.product &&
|
||||
current.kind === kind &&
|
||||
current.infrared === infrared
|
||||
)
|
||||
return true;
|
||||
lastError = null;
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
const global = snapshot.product === 'clouds';
|
||||
const frame = {
|
||||
snapshot,
|
||||
time,
|
||||
infrared,
|
||||
mosaic: global
|
||||
? { fetched: false, decodeMs: null, cached: false }
|
||||
: undefined,
|
||||
controller: new AbortController(),
|
||||
collection,
|
||||
kind,
|
||||
priority:
|
||||
snapshot.product === 'lightning'
|
||||
? 3
|
||||
: snapshot.product === 'radar'
|
||||
? 2
|
||||
: 1,
|
||||
product: snapshot.product,
|
||||
requests: new Set(),
|
||||
retries: new Map(),
|
||||
deferred: new Set(),
|
||||
pending: 0,
|
||||
loaded: 0,
|
||||
lastActivity: now(),
|
||||
startedAt: now(),
|
||||
closed: false,
|
||||
failed: false,
|
||||
resolve: null,
|
||||
};
|
||||
incoming = frame;
|
||||
const result = new Promise((resolve) => {
|
||||
frame.resolve = resolve;
|
||||
});
|
||||
const finish = (ok) => {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
frame.offRender?.();
|
||||
frame.offRender = null;
|
||||
frame.offCamera?.();
|
||||
frame.offCamera = null;
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
if (ok) {
|
||||
lastError = null;
|
||||
const previous = current;
|
||||
current = frame;
|
||||
frame.loadMs = now() - frame.startedAt;
|
||||
frame.layer.alpha = alpha;
|
||||
frame.layer.show = !frameHidden;
|
||||
viewer.scene.requestRender();
|
||||
if (previous) {
|
||||
retiring.add(previous);
|
||||
previous.offRetire = viewer.scene.postRender.addEventListener(
|
||||
() => {
|
||||
remove(previous);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
remove(frame);
|
||||
}
|
||||
frame.resolve(ok);
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
};
|
||||
const abort = () => {
|
||||
if (incoming === frame) cancelIncoming();
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
frame.offAbort = () => signal?.removeEventListener('abort', abort);
|
||||
frame.timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
const install = (texture) => {
|
||||
if (frame.closed) return;
|
||||
// Keep at most two installed layers during rapid successive selections.
|
||||
if (retiring.size) {
|
||||
frame.offInstall = viewer.scene.postRender.addEventListener(() => {
|
||||
frame.offInstall();
|
||||
frame.offInstall = null;
|
||||
try {
|
||||
install(texture);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
const tilingScheme = new cesium.GeographicTilingScheme(
|
||||
global
|
||||
? {
|
||||
rectangle,
|
||||
numberOfLevelZeroTilesX: 2,
|
||||
numberOfLevelZeroTilesY: 1,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const credit = new cesium.Credit(
|
||||
snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
|
||||
false,
|
||||
);
|
||||
const provider = global
|
||||
? createRasterTileProvider({
|
||||
cesium,
|
||||
texture,
|
||||
rectangle,
|
||||
tilingScheme,
|
||||
maximumLevel: GLOBAL_TILE_MAXIMUM_LEVEL,
|
||||
tileSize: nextProfile.tileSize,
|
||||
credit,
|
||||
createCanvas,
|
||||
})
|
||||
: new cesium.UrlTemplateImageryProvider({
|
||||
url: weatherTileUrl(snapshot.product, time, {
|
||||
size: nextProfile.tileSize,
|
||||
}),
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: nextProfile.tileSize,
|
||||
tileHeight: nextProfile.tileSize,
|
||||
maximumLevel: nextProfile.maximumLevel,
|
||||
enablePickFeatures: false,
|
||||
credit,
|
||||
});
|
||||
const requestImage = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (frame.closed) return undefined;
|
||||
const result = requestImage(x, y, level, request);
|
||||
const tileKey = `${level}/${x}/${y}`;
|
||||
if (!result) {
|
||||
// Scheduler admission is part of readiness, not a successful tile.
|
||||
frame.deferred.add(tileKey);
|
||||
frame.lastActivity = now();
|
||||
return result;
|
||||
}
|
||||
frame.deferred.delete(tileKey);
|
||||
frame.pending++;
|
||||
frame.lastActivity = now();
|
||||
if (request) frame.requests.add(request);
|
||||
return Promise.resolve(result)
|
||||
.then((image) => {
|
||||
if (!frame.closed && snapshot.product === 'clouds-regional')
|
||||
image = processInfraredImage(image, infrared, createCanvas);
|
||||
frame.retries.delete(tileKey);
|
||||
frame.loaded++;
|
||||
return image;
|
||||
})
|
||||
.finally(() => {
|
||||
frame.pending--;
|
||||
frame.lastActivity = now();
|
||||
frame.requests.delete(request);
|
||||
if (!frame.closed) viewer.scene.requestRender();
|
||||
});
|
||||
};
|
||||
frame.offError = provider.errorEvent.addEventListener((error) => {
|
||||
if (frame.closed) return;
|
||||
const status = error?.error?.statusCode;
|
||||
// Cesium retries synchronously after this event; no delay hook is exposed.
|
||||
const tileKey = `${error.level}/${error.x}/${error.y}`;
|
||||
const retries = frame.retries.get(tileKey) ?? 0;
|
||||
error.retry = false;
|
||||
if ((status === 429 || status === 503) && retries < 3) {
|
||||
frame.retries.set(tileKey, retries + 1);
|
||||
error.retry = true;
|
||||
return;
|
||||
}
|
||||
frame.failed = true;
|
||||
lastError = 'Some weather tiles unavailable';
|
||||
onChange();
|
||||
});
|
||||
// A shown, transparent layer lets Cesium request staging tiles while the last
|
||||
// complete observation stays visible underneath it.
|
||||
frame.layer = collection.addImageryProvider(provider);
|
||||
frame.layer.alpha = 0;
|
||||
orderWeatherImagery(collection, frame.layer, frame.priority);
|
||||
let settled = 0;
|
||||
frame.offCamera = viewer.camera?.moveEnd?.addEventListener(() => {
|
||||
// Tiles abandoned by a previous viewport are no longer admission work.
|
||||
frame.deferred.clear();
|
||||
frame.lastActivity = now();
|
||||
settled = 0;
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
frame.offRender = viewer.scene.postRender.addEventListener(() => {
|
||||
if (frame.failed) return finish(false);
|
||||
// Unrelated terrain/basemap work must not indefinitely hold a ready
|
||||
// observation. Require successful own tiles and a quiet scheduling
|
||||
// interval before admitting a frame when the rest of the globe is busy.
|
||||
const ownReady =
|
||||
frame.loaded > 0 &&
|
||||
frame.deferred.size === 0 &&
|
||||
now() - frame.lastActivity >= 200;
|
||||
if (
|
||||
((frame.kind === 'globe' && viewer.scene.globe.tilesLoaded) ||
|
||||
ownReady) &&
|
||||
frame.pending === 0
|
||||
) {
|
||||
if (++settled >= 2) finish(true);
|
||||
else viewer.scene.requestRender();
|
||||
} else settled = 0;
|
||||
if (incoming === frame && frame.pending === 0 && frame.loaded > 0)
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
};
|
||||
if (global) {
|
||||
void mosaic(time, infrared, frame.controller.signal, () => {
|
||||
frame.mosaic.fetched = true;
|
||||
})
|
||||
.then(({ texture, decodeMs, cached }) => {
|
||||
frame.mosaic.decodeMs = decodeMs;
|
||||
frame.mosaic.cached = cached;
|
||||
install(texture);
|
||||
})
|
||||
.catch(() => finish(false));
|
||||
} else {
|
||||
try {
|
||||
install();
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
return result;
|
||||
},
|
||||
setHidden(value) {
|
||||
frameHidden = Boolean(value);
|
||||
if (frameHidden) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
for (const frame of retiring) remove(frame);
|
||||
}
|
||||
rehome();
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
if (current && current.layer.show) current.layer.alpha = alpha;
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
clear() {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
remove(current);
|
||||
for (const frame of retiring) remove(frame);
|
||||
current = null;
|
||||
frameHidden = false;
|
||||
lastError = null;
|
||||
mosaics.clear();
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
host: 'globe',
|
||||
cache: { mosaics: mosaics.size, prefetching: !!prefetchJob },
|
||||
imageryCount:
|
||||
Number(!!current) + Number(!!incoming?.layer) + retiring.size,
|
||||
mosaic: (incoming || current)?.mosaic,
|
||||
infrared: (incoming || current)?.infrared ?? 'filtered',
|
||||
loading: !!incoming,
|
||||
time: frameHidden ? null : (current?.time ?? null),
|
||||
hidden: frameHidden,
|
||||
product: current?.product ?? null,
|
||||
pendingTiles: incoming?.pending ?? 0,
|
||||
deferredTiles: incoming?.deferred.size ?? 0,
|
||||
loadedTiles: (incoming || current)?.loaded ?? 0,
|
||||
frameLoadMs: current?.loadMs ?? null,
|
||||
error: imageryHostStatus(getHost()) || lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Globe hosts keep draped imagery; 3D Tiles hosts get a raised shell per product.
|
||||
* A host switch tears one renderer down and restages the last frame on the other. */
|
||||
export function createWeatherRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost = () => ({ collection: viewer.imageryLayers, kind: 'globe' }),
|
||||
onChange = () => {},
|
||||
timeoutMs = 25_000,
|
||||
now = () => performance.now(),
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
decodeImage,
|
||||
createCanvas = () => document.createElement('canvas'),
|
||||
createShell = createWeatherShell,
|
||||
}) {
|
||||
const shared = {
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange,
|
||||
timeoutMs,
|
||||
now,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
createCanvas,
|
||||
};
|
||||
const globe = createGlobeRendering(shared);
|
||||
let shell = null;
|
||||
let active = null;
|
||||
let alpha = 0.7;
|
||||
let hidden = false;
|
||||
let shown = null;
|
||||
let restaging = null;
|
||||
|
||||
// A host without imagery keeps the active renderer, which retains its frame.
|
||||
const wanted = () => {
|
||||
const { kind } = getHost();
|
||||
return kind === 'tileset'
|
||||
? 'shell'
|
||||
: kind === 'globe'
|
||||
? 'globe'
|
||||
: (active ?? 'globe');
|
||||
};
|
||||
const renderer = () => (active === 'shell' ? shell : globe);
|
||||
function shellFor(product) {
|
||||
if (shell?.product === product) return shell;
|
||||
shell?.clear();
|
||||
shell = createShell({ ...shared, product });
|
||||
shell.setAlpha(alpha);
|
||||
shell.setHidden(hidden);
|
||||
return shell;
|
||||
}
|
||||
function restage(frame) {
|
||||
const target =
|
||||
active === 'shell' ? shellFor(frame.snapshot.product) : globe;
|
||||
restaging = frame;
|
||||
const settle = (ok) => {
|
||||
if (restaging !== frame) return;
|
||||
restaging = null;
|
||||
if (!ok) onChange();
|
||||
};
|
||||
target
|
||||
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
|
||||
.then(settle, () => settle(false));
|
||||
}
|
||||
function switchHost() {
|
||||
const next = wanted();
|
||||
if (next === active) return false;
|
||||
const previous = active;
|
||||
active = next;
|
||||
if (previous === null) return false;
|
||||
restaging = null;
|
||||
if (previous === 'shell') {
|
||||
shell?.clear();
|
||||
shell = null;
|
||||
} else globe.clear();
|
||||
if (active === 'globe') {
|
||||
globe.setAlpha(alpha);
|
||||
if (hidden) globe.setHidden(true);
|
||||
}
|
||||
if (shown && !hidden) restage(shown);
|
||||
viewer.scene.requestRender();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
rehome() {
|
||||
const switched = switchHost();
|
||||
if (active === 'shell' && !shell) return switched;
|
||||
return renderer().rehome() || switched;
|
||||
},
|
||||
cancelPrefetch() {
|
||||
renderer()?.cancelPrefetch();
|
||||
},
|
||||
prefetch(snapshot, time, options) {
|
||||
switchHost();
|
||||
if (active === 'shell' && shell?.product !== snapshot.product)
|
||||
return Promise.resolve(false);
|
||||
return renderer().prefetch(snapshot, time, options);
|
||||
},
|
||||
async setFrame(snapshot, time, options = {}) {
|
||||
options.signal?.throwIfAborted();
|
||||
switchHost();
|
||||
restaging = null;
|
||||
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
|
||||
const ok = await target.setFrame(snapshot, time, options);
|
||||
if (ok)
|
||||
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
|
||||
return ok;
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) restaging = null;
|
||||
renderer()?.setHidden(hidden);
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
renderer()?.setAlpha(value);
|
||||
},
|
||||
clear() {
|
||||
restaging = null;
|
||||
shown = null;
|
||||
hidden = false;
|
||||
shell?.clear();
|
||||
shell = null;
|
||||
globe.clear();
|
||||
},
|
||||
getDiagnostics() {
|
||||
const diagnostics =
|
||||
active === 'shell' && shell
|
||||
? shell.getDiagnostics()
|
||||
: active === 'shell'
|
||||
? {
|
||||
host: 'shell',
|
||||
cache: { mosaics: 0, bytes: 0, prefetching: false },
|
||||
imageryCount: 0,
|
||||
infrared: 'filtered',
|
||||
loading: false,
|
||||
time: null,
|
||||
hidden,
|
||||
product: null,
|
||||
frameLoadMs: null,
|
||||
error: imageryHostStatus(getHost()),
|
||||
}
|
||||
: globe.getDiagnostics();
|
||||
// A host switch restages the retained frame; keep reporting its time.
|
||||
return restaging && !hidden && diagnostics.time === null
|
||||
? { ...diagnostics, time: restaging.time }
|
||||
: diagnostics;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Test double for the Cesium classes a weather shell constructs. */
|
||||
export function createShellCesium({ maximumTextureSize = 0 } = {}) {
|
||||
const templates = new Map();
|
||||
const created = { materials: [], primitives: [], appearances: [] };
|
||||
class Material {
|
||||
constructor(options) {
|
||||
const template = templates.get(options.fabric.type);
|
||||
this.options = options;
|
||||
this.uniforms = {
|
||||
...template.fabric.uniforms,
|
||||
imageDimensions: { type: 'ivec3', x: 1, y: 1 },
|
||||
detailDimensions: { type: 'ivec3', x: 1, y: 1 },
|
||||
};
|
||||
this.destroyed = false;
|
||||
created.materials.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
Material.DefaultImageId = Cesium.Material.DefaultImageId;
|
||||
Material._materialCache = {
|
||||
getMaterial: (type) => templates.get(type),
|
||||
addMaterial: (type, template) => templates.set(type, template),
|
||||
};
|
||||
class Primitive {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.appearance = options.appearance;
|
||||
this.show = options.show ?? true;
|
||||
this.ready = false;
|
||||
this.destroyed = false;
|
||||
created.primitives.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class EllipsoidSurfaceAppearance {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.material = options.material;
|
||||
created.appearances.push(this);
|
||||
}
|
||||
}
|
||||
EllipsoidSurfaceAppearance.VERTEX_FORMAT =
|
||||
Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT;
|
||||
class RectangleGeometry {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
class GeometryInstance {
|
||||
constructor(options) {
|
||||
this.geometry = options.geometry;
|
||||
}
|
||||
}
|
||||
return {
|
||||
Material,
|
||||
Primitive,
|
||||
EllipsoidSurfaceAppearance,
|
||||
RectangleGeometry,
|
||||
GeometryInstance,
|
||||
BlendingState: Cesium.BlendingState,
|
||||
Cartesian4: Cesium.Cartesian4,
|
||||
Math: Cesium.Math,
|
||||
Rectangle: Cesium.Rectangle,
|
||||
ContextLimits: { maximumTextureSize },
|
||||
created,
|
||||
templates,
|
||||
};
|
||||
}
|
||||
|
||||
/** A scene with an ordered primitive collection that destroys on remove. */
|
||||
export function createShellScene() {
|
||||
const items = [];
|
||||
const listeners = new Set();
|
||||
let renders = 0;
|
||||
return {
|
||||
primitives: {
|
||||
items,
|
||||
add(primitive) {
|
||||
items.push(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
const index = items.indexOf(primitive);
|
||||
if (index < 0) return false;
|
||||
items.splice(index, 1);
|
||||
primitive.destroy();
|
||||
return true;
|
||||
},
|
||||
contains: (primitive) => items.includes(primitive),
|
||||
get: (index) => items[index],
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
lowerToBottom(primitive) {
|
||||
const index = items.indexOf(primitive);
|
||||
if (index >= 0) items.unshift(...items.splice(index, 1));
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
},
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
emit() {
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
renders++;
|
||||
},
|
||||
get renders() {
|
||||
return renders;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Render: finish asynchronous geometry, upload each shown material's images.
|
||||
* An image uniform back at the default binds Cesium's 1×1 texture. */
|
||||
export function renderShells(cesium, scene, count = 4) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (const primitive of cesium.created.primitives) {
|
||||
if (primitive.destroyed || !primitive.show) continue;
|
||||
primitive.ready = true;
|
||||
const { uniforms } = primitive.appearance.material;
|
||||
for (const name of ['image', 'detail']) {
|
||||
const dimensions = uniforms[`${name}Dimensions`];
|
||||
if (!dimensions) continue;
|
||||
const bound =
|
||||
typeof uniforms[name] === 'object'
|
||||
? uniforms[name]
|
||||
: { width: 1, height: 1 };
|
||||
dimensions.x = bound.width;
|
||||
dimensions.y = bound.height;
|
||||
}
|
||||
}
|
||||
scene.postRender.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
import { acquireWeatherImage } from './infraredImage.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||||
|
||||
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||||
wind: 5_000,
|
||||
clouds: 5_500,
|
||||
'clouds-regional': 5_800,
|
||||
radar: 6_200,
|
||||
lightning: 6_600,
|
||||
});
|
||||
// Decoded canvases per renderer, full-extent and detail images alike: the shown
|
||||
// frame and the warmed next frame, each full-extent and detail (four 4096×2048
|
||||
// canvases); nothing older survives them. Cesium holds the shown canvases
|
||||
// through the material uniforms in any case.
|
||||
export const WEATHER_SHELL_CACHE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
const MATERIAL_TYPE = 'WeatherFrame';
|
||||
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
|
||||
const UPLOAD_FRAMES = 3;
|
||||
// An empty detail window: the full-extent image shows everywhere.
|
||||
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
|
||||
// Detail windows, in degrees.
|
||||
const DETAIL_MIN_WIDTH = 6;
|
||||
const DETAIL_GRID = 0.5;
|
||||
const shellHeights = new WeakMap();
|
||||
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
|
||||
// draw nothing until the full-extent image has arrived, and sample the detail
|
||||
// image only once it has arrived. The window is west, south, east, north in the
|
||||
// full-extent image's texture coordinates.
|
||||
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
vec2 st = materialInput.st;
|
||||
vec4 coarse = texture(image, st);
|
||||
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
|
||||
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
|
||||
float useDetail = inside * step(1.5, float(detailDimensions.x));
|
||||
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
|
||||
material.diffuse = czm_gammaCorrect(c.rgb);
|
||||
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
function registerMaterial(cesium) {
|
||||
const cache = cesium.Material._materialCache;
|
||||
if (cache.getMaterial(MATERIAL_TYPE)) return;
|
||||
// A plain template: building the first material from a fabric would make that
|
||||
// instance Cesium's shared template and keep its last image alive.
|
||||
cache.addMaterial(MATERIAL_TYPE, {
|
||||
fabric: {
|
||||
type: MATERIAL_TYPE,
|
||||
uniforms: {
|
||||
image: cesium.Material.DefaultImageId,
|
||||
detail: cesium.Material.DefaultImageId,
|
||||
alpha: 1,
|
||||
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
|
||||
},
|
||||
// Diffuse and alpha share these samples, so one source, not components.
|
||||
source: MATERIAL_SOURCE,
|
||||
},
|
||||
translucent: false,
|
||||
});
|
||||
}
|
||||
|
||||
const sameEdges = (a, b) =>
|
||||
a.west === b.west &&
|
||||
a.south === b.south &&
|
||||
a.east === b.east &&
|
||||
a.north === b.north;
|
||||
|
||||
/** Keep weather shells first in the primitive collection, in ascending height:
|
||||
* higher shells draw over lower ones and other opaque content draws over both. */
|
||||
export function orderWeatherShells(primitives, primitive, height) {
|
||||
shellHeights.set(primitive, height);
|
||||
if (typeof primitives.lowerToBottom !== 'function') return;
|
||||
const shells = [];
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const item = primitives.get(i);
|
||||
if (shellHeights.has(item)) shells.push(item);
|
||||
}
|
||||
const ordered = [...shells].sort(
|
||||
(a, b) => shellHeights.get(a) - shellHeights.get(b),
|
||||
);
|
||||
if (ordered.every((item, i) => primitives.get(i) === item)) return;
|
||||
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
|
||||
}
|
||||
|
||||
/** One raised rectangle drawing one full-extent image and, inside a window of
|
||||
* it, an optional detail image. The owner calls destroy(). */
|
||||
export function createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
rectangle,
|
||||
height,
|
||||
onSettled = () => {},
|
||||
}) {
|
||||
registerMaterial(cesium);
|
||||
const scene = viewer.scene;
|
||||
const primitives = scene.primitives;
|
||||
const material = new cesium.Material({
|
||||
fabric: { type: MATERIAL_TYPE },
|
||||
translucent: false,
|
||||
});
|
||||
// Flat shading keeps the product colours independent of the viewing angle.
|
||||
const appearance = new cesium.EllipsoidSurfaceAppearance({
|
||||
aboveGround: true,
|
||||
flat: true,
|
||||
translucent: false,
|
||||
material,
|
||||
});
|
||||
// Cesium orders its translucent pass itself (weighted OIT or distance), which
|
||||
// cannot honour stacked heights. Draw in the opaque pass in scene.primitives
|
||||
// order, alpha-blended and without depth writes, so higher shells draw over
|
||||
// lower ones and nothing is hidden or picked through a shell.
|
||||
appearance.getRenderState = () => ({
|
||||
depthTest: { enabled: true },
|
||||
depthMask: false,
|
||||
blending: cesium.BlendingState.ALPHA_BLEND,
|
||||
});
|
||||
const primitive = new cesium.Primitive({
|
||||
geometryInstances: new cesium.GeometryInstance({
|
||||
geometry: new cesium.RectangleGeometry({
|
||||
rectangle,
|
||||
height,
|
||||
granularity: cesium.Math.toRadians(0.5),
|
||||
vertexFormat: cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance,
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
});
|
||||
primitives.add(primitive);
|
||||
orderWeatherShells(primitives, primitive, height);
|
||||
let image = null;
|
||||
let frames = 0;
|
||||
// The detail image, its own upload count and a window waiting for it to draw.
|
||||
let detail = null;
|
||||
let detailFrames = 0;
|
||||
let pendingWindow = null;
|
||||
let offRender = null;
|
||||
let destroyed = false;
|
||||
|
||||
const fits = (size, value) =>
|
||||
!size || (size.x === value.width && size.y === value.height);
|
||||
const uploaded = () =>
|
||||
image !== null && fits(material.uniforms.imageDimensions, image);
|
||||
const detailDrawn = () =>
|
||||
detail !== null &&
|
||||
detailFrames === 0 &&
|
||||
fits(material.uniforms.detailDimensions, detail);
|
||||
const drawn = () =>
|
||||
primitive.ready &&
|
||||
uploaded() &&
|
||||
frames === 0 &&
|
||||
(detail === null || (detailDrawn() && pendingWindow === null));
|
||||
function appliedWindow() {
|
||||
const { x, y, z, w } = material.uniforms.window;
|
||||
return z < x ? null : { west: x, south: y, east: z, north: w };
|
||||
}
|
||||
function setWindow(rect) {
|
||||
const next = rect ?? NO_WINDOW;
|
||||
const { x, y, z, w } = material.uniforms.window;
|
||||
if (
|
||||
x === next.west &&
|
||||
y === next.south &&
|
||||
z === next.east &&
|
||||
w === next.north
|
||||
)
|
||||
return;
|
||||
material.uniforms.window = new cesium.Cartesian4(
|
||||
next.west,
|
||||
next.south,
|
||||
next.east,
|
||||
next.north,
|
||||
);
|
||||
scene.requestRender();
|
||||
}
|
||||
// The render governor may idle the scene; request frames only until the
|
||||
// asynchronous geometry is ready and the latest images have been drawn.
|
||||
function tick() {
|
||||
// Cesium updates a material only while its primitive is shown, so hidden
|
||||
// frames upload nothing and count for nothing.
|
||||
if (primitive.ready && primitive.show) {
|
||||
if (frames > 0) frames--;
|
||||
if (detailFrames > 0) detailFrames--;
|
||||
// A texture is never shown at another window's place.
|
||||
if (pendingWindow && detailDrawn()) {
|
||||
setWindow(pendingWindow);
|
||||
pendingWindow = null;
|
||||
}
|
||||
}
|
||||
if (primitive.show && !drawn()) {
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
offRender?.();
|
||||
offRender = null;
|
||||
if (drawn()) onSettled();
|
||||
}
|
||||
function wake() {
|
||||
if (destroyed || !primitive.show || image === null || drawn()) return;
|
||||
offRender ??= scene.postRender.addEventListener(tick);
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
return {
|
||||
setImage(next) {
|
||||
if (destroyed || next === image) return;
|
||||
image = next;
|
||||
material.uniforms.image = next;
|
||||
frames = UPLOAD_FRAMES;
|
||||
wake();
|
||||
},
|
||||
setAlpha(value) {
|
||||
if (destroyed || material.uniforms.alpha === value) return;
|
||||
material.uniforms.alpha = value;
|
||||
scene.requestRender();
|
||||
},
|
||||
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
|
||||
* image's texture coordinates, 0–1); null clears it. In the same window only
|
||||
* the image changes, and Cesium keeps drawing the previous texture until
|
||||
* this one is uploaded. A different window applies once its image has
|
||||
* drawn; until then the full-extent image covers it. */
|
||||
setDetail(next, rect) {
|
||||
if (destroyed) return;
|
||||
if (!next) {
|
||||
if (detail === null) return;
|
||||
detail = null;
|
||||
detailFrames = 0;
|
||||
pendingWindow = null;
|
||||
material.uniforms.detail = cesium.Material.DefaultImageId;
|
||||
setWindow(null);
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
if (next !== detail) {
|
||||
detail = next;
|
||||
material.uniforms.detail = next;
|
||||
detailFrames = UPLOAD_FRAMES;
|
||||
}
|
||||
const applied = appliedWindow();
|
||||
if (applied && sameEdges(applied, rect)) {
|
||||
pendingWindow = null;
|
||||
} else {
|
||||
setWindow(null);
|
||||
pendingWindow = rect;
|
||||
}
|
||||
wake();
|
||||
},
|
||||
/** Returns whether visibility changed. */
|
||||
setShow(value) {
|
||||
const show = Boolean(value);
|
||||
if (destroyed || primitive.show === show) return false;
|
||||
primitive.show = show;
|
||||
wake();
|
||||
scene.requestRender();
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
offRender?.();
|
||||
offRender = null;
|
||||
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
|
||||
primitives.remove(primitive);
|
||||
if (!primitive.isDestroyed()) primitive.destroy();
|
||||
// Primitive.destroy leaves the material and its texture alive.
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
image = null;
|
||||
detail = null;
|
||||
pendingWindow = null;
|
||||
scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
height,
|
||||
ready: Boolean(primitive.ready),
|
||||
uploaded: uploaded(),
|
||||
show: primitive.show,
|
||||
alpha: material.uniforms.alpha,
|
||||
rendering: offRender !== null,
|
||||
// `uploaded` once the detail image has drawn; the window applies after.
|
||||
detail: { uploaded: detailDrawn(), window: appliedWindow() },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fitTexture(cesium, { width, height }) {
|
||||
const limit = cesium.ContextLimits?.maximumTextureSize;
|
||||
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
|
||||
while (limit > 0 && width > limit && width > 1024) {
|
||||
width /= 2;
|
||||
height /= 2;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** The detail window for a view footprint over product bounds (both in degrees;
|
||||
* a footprint across the antimeridian has west > east), or null when the
|
||||
* full-extent image is already the best available. The window is centred on
|
||||
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
|
||||
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
|
||||
* half the product's longitude extent and when the footprint overlaps the
|
||||
* product. `previous` is returned unchanged while the footprint centre stays in
|
||||
* its inner half and the wanted width is within 50 % of its width. */
|
||||
export function detailWindow(footprint, bounds, previous = null) {
|
||||
if (!footprint || !bounds) return null;
|
||||
const across = footprint.east < footprint.west;
|
||||
const span = footprint.east - footprint.west + (across ? 360 : 0);
|
||||
let lon = footprint.west + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
const lat = (footprint.south + footprint.north) / 2;
|
||||
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
|
||||
if (previous) {
|
||||
const width = previous.east - previous.west;
|
||||
const height = previous.north - previous.south;
|
||||
if (
|
||||
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
|
||||
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
|
||||
Math.abs(wanted - width) <= width / 2
|
||||
)
|
||||
return previous;
|
||||
}
|
||||
const inner = {
|
||||
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
|
||||
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
|
||||
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
|
||||
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
|
||||
};
|
||||
// Whole degrees keep both the edges and the 2:1 height on the grid; the
|
||||
// tolerance absorbs radian round trips.
|
||||
const width = Math.ceil(wanted - 1e-9);
|
||||
const height = width / 2;
|
||||
const overlaps =
|
||||
footprint.south < bounds.north &&
|
||||
footprint.north > bounds.south &&
|
||||
(across
|
||||
? footprint.west < bounds.east || footprint.east > bounds.west
|
||||
: footprint.west < bounds.east && footprint.east > bounds.west);
|
||||
if (
|
||||
!overlaps ||
|
||||
width >= (bounds.east - bounds.west) / 2 ||
|
||||
width > inner.east - inner.west ||
|
||||
height > inner.north - inner.south
|
||||
)
|
||||
return null;
|
||||
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
|
||||
const west = Math.min(
|
||||
Math.max(snap(lon - width / 2), inner.west),
|
||||
inner.east - width,
|
||||
);
|
||||
const south = Math.min(
|
||||
Math.max(snap(lat - height / 2), inner.south),
|
||||
inner.north - height,
|
||||
);
|
||||
const next = { west, south, east: west + width, north: south + height };
|
||||
return previous && sameEdges(previous, next) ? previous : next;
|
||||
}
|
||||
|
||||
/** Observed weather on 3D Tiles: one raised shell per product and one
|
||||
* full-extent image per frame. The previous image stays until the next is ready.
|
||||
* Inside a window around the view the same surface samples a sharper image of
|
||||
* that area, so the two images share one mesh and never blend. */
|
||||
export function createWeatherShell({
|
||||
viewer,
|
||||
cesium,
|
||||
product,
|
||||
height = WEATHER_SHELL_HEIGHTS[product],
|
||||
getHost = () => ({ collection: null, kind: 'tileset' }),
|
||||
onChange = () => {},
|
||||
timeoutMs = 25_000,
|
||||
now = () => performance.now(),
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
decodeImage,
|
||||
createCanvas = () => document.createElement('canvas'),
|
||||
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
|
||||
}) {
|
||||
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
|
||||
throw new TypeError('Unknown weather product');
|
||||
const scene = viewer.scene;
|
||||
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
|
||||
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
|
||||
const infrared = product === 'clouds' || product === 'clouds-regional';
|
||||
// Global infrared contrast depends on the requested extent, so a window would
|
||||
// not match the image around it; the globe host also shows one mosaic.
|
||||
const detailed = product !== 'clouds';
|
||||
let surface = null;
|
||||
let current = null;
|
||||
let incoming = null;
|
||||
let alpha = 0.7;
|
||||
let hidden = false;
|
||||
let lastError = null;
|
||||
const images = new Map();
|
||||
let imageBytes = 0;
|
||||
let prefetchJob = null;
|
||||
let prefetchedKey = null;
|
||||
// The detail window for the shown bounds, the image drawn in it and its fetch.
|
||||
let view = null;
|
||||
let viewBounds = null;
|
||||
let detail = null;
|
||||
let detailJob = null;
|
||||
let offCamera = null;
|
||||
|
||||
const suspended = () => imageryHostStatus(getHost()) !== null;
|
||||
const visible = () => !hidden && !suspended();
|
||||
const boundsKey = ({ west, south, east, north }) =>
|
||||
`${west},${south},${east},${north}`;
|
||||
const cacheKey = (time, mode, box = null) =>
|
||||
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
|
||||
const wantedDetail = () =>
|
||||
current && view ? cacheKey(current.time, current.infrared, view) : null;
|
||||
|
||||
function evict(keep = []) {
|
||||
const kept = new Set([...keep, current?.key, detail?.key]);
|
||||
for (const [key, entry] of images) {
|
||||
if (imageBytes <= cacheBytes) return;
|
||||
if (kept.has(key)) continue;
|
||||
images.delete(key);
|
||||
imageBytes -= entry.bytes;
|
||||
}
|
||||
}
|
||||
async function acquire(
|
||||
time,
|
||||
mode,
|
||||
signal,
|
||||
{ box = null, keep = [], onFetched } = {},
|
||||
) {
|
||||
signal.throwIfAborted();
|
||||
const key = cacheKey(time, mode, box);
|
||||
const entry = images.get(key);
|
||||
if (entry) {
|
||||
images.delete(key);
|
||||
images.set(key, entry);
|
||||
return { texture: entry.image, decodeMs: 0, cached: true };
|
||||
}
|
||||
const result = await acquireWeatherImage(product, time, {
|
||||
signal,
|
||||
mode,
|
||||
size: box ? detailSize : size,
|
||||
bbox: box,
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
now,
|
||||
onFetched,
|
||||
});
|
||||
// An aborted decode may still finish; never repopulate a cleared instance.
|
||||
signal.throwIfAborted();
|
||||
const previous = images.get(key);
|
||||
if (previous) imageBytes -= previous.bytes;
|
||||
images.delete(key);
|
||||
const bytes = result.texture.width * result.texture.height * 4;
|
||||
images.set(key, { image: result.texture, bytes });
|
||||
imageBytes += bytes;
|
||||
evict([key, ...keep]);
|
||||
return { ...result, cached: false };
|
||||
}
|
||||
/** The detail box in the full-extent image's texture coordinates. */
|
||||
function detailRect() {
|
||||
const b = current.extent;
|
||||
const w = detail.box;
|
||||
const x = b.east - b.west;
|
||||
const y = b.north - b.south;
|
||||
return {
|
||||
west: (w.west - b.west) / x,
|
||||
south: (w.south - b.south) / y,
|
||||
east: (w.east - b.west) / x,
|
||||
north: (w.north - b.south) / y,
|
||||
};
|
||||
}
|
||||
function syncDetail() {
|
||||
if (detail && current) surface?.setDetail(detail.image, detailRect());
|
||||
else surface?.setDetail(null);
|
||||
}
|
||||
function applyVisibility() {
|
||||
return surface?.setShow(visible()) ?? false;
|
||||
}
|
||||
function cancelDetail() {
|
||||
clearTimeout(detailJob?.timeout);
|
||||
detailJob?.controller.abort();
|
||||
detailJob = null;
|
||||
}
|
||||
function dropDetail() {
|
||||
detail = null;
|
||||
// The full-extent image covers the window in the same turn.
|
||||
surface?.setDetail(null);
|
||||
}
|
||||
function installDetail(job, image) {
|
||||
if (detailJob !== job) return;
|
||||
clearTimeout(job.timeout);
|
||||
detailJob = null;
|
||||
detail = { box: job.box, key: job.key, image, stale: false };
|
||||
syncDetail();
|
||||
evict();
|
||||
scene.requestRender();
|
||||
}
|
||||
function failDetail(job) {
|
||||
if (detailJob !== job) return;
|
||||
clearTimeout(job.timeout);
|
||||
detailJob = null;
|
||||
if (detail?.stale) dropDetail();
|
||||
scene.requestRender();
|
||||
}
|
||||
/** Bring the detail to the shown frame and window. The previous detail image
|
||||
* stays until the next is decoded unless the window itself moved, and over at
|
||||
* most one newer frame (see commit). */
|
||||
function refreshDetail() {
|
||||
const key = wantedDetail();
|
||||
if (!key) {
|
||||
cancelDetail();
|
||||
dropDetail();
|
||||
return;
|
||||
}
|
||||
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
|
||||
syncDetail();
|
||||
if (detail?.key === key) {
|
||||
detail.stale = false;
|
||||
cancelDetail();
|
||||
return;
|
||||
}
|
||||
// Another frame's detail: the next commit drops it unless replaced first.
|
||||
if (detail) detail.stale = true;
|
||||
if (!visible()) {
|
||||
cancelDetail();
|
||||
return;
|
||||
}
|
||||
if (detailJob?.key === key) return;
|
||||
cancelDetail();
|
||||
const job = { key, box: view, controller: new AbortController() };
|
||||
detailJob = job;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
void acquire(current.time, current.infrared, job.controller.signal, {
|
||||
box: view,
|
||||
})
|
||||
.then(({ texture }) => installDetail(job, texture))
|
||||
.catch(() => failDetail(job));
|
||||
}
|
||||
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
|
||||
function computeView() {
|
||||
const rectangle = detailed
|
||||
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
|
||||
: null;
|
||||
const degrees = cesium.Math.toDegrees;
|
||||
const footprint = rectangle && {
|
||||
west: degrees(rectangle.west),
|
||||
south: degrees(rectangle.south),
|
||||
east: degrees(rectangle.east),
|
||||
north: degrees(rectangle.north),
|
||||
};
|
||||
const next = detailWindow(
|
||||
footprint,
|
||||
current.extent,
|
||||
viewBounds === current.bounds ? view : null,
|
||||
);
|
||||
viewBounds = current.bounds;
|
||||
if (next === view) return false;
|
||||
view = next;
|
||||
return true;
|
||||
}
|
||||
function watchCamera() {
|
||||
if (offCamera || !detailed) return;
|
||||
offCamera =
|
||||
viewer.camera?.moveEnd?.addEventListener(() => {
|
||||
if (current && computeView()) refreshDetail();
|
||||
}) ?? null;
|
||||
}
|
||||
function cancelPrefetch() {
|
||||
clearTimeout(prefetchJob?.timeout);
|
||||
prefetchJob?.controller.abort();
|
||||
prefetchJob = null;
|
||||
prefetchedKey = null;
|
||||
}
|
||||
function watch(frame, signal) {
|
||||
if (!signal) return;
|
||||
frame.owned = true;
|
||||
const abort = () => {
|
||||
if (incoming === frame) cancelIncoming();
|
||||
scene.requestRender();
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
frame.offAbort = () => signal.removeEventListener('abort', abort);
|
||||
}
|
||||
function close(frame) {
|
||||
frame.closed = true;
|
||||
frame.controller.abort();
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
frame.surface?.destroy();
|
||||
frame.surface = null;
|
||||
}
|
||||
function cancelIncoming() {
|
||||
if (!incoming) return;
|
||||
const frame = incoming;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
frame.resolve(false);
|
||||
}
|
||||
function fail(frame) {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
frame.resolve(false);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
}
|
||||
function commit(frame) {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
if (frame.surface) {
|
||||
surface?.destroy();
|
||||
surface = frame.surface;
|
||||
frame.surface = null;
|
||||
}
|
||||
const { west, south, east, north } = frame.snapshot.bounds;
|
||||
current = {
|
||||
time: frame.time,
|
||||
product: frame.product,
|
||||
infrared: frame.infrared,
|
||||
bounds: frame.bounds,
|
||||
extent: { west, south, east, north },
|
||||
key: frame.key,
|
||||
mosaic: frame.mosaic,
|
||||
loadMs: now() - frame.startedAt,
|
||||
};
|
||||
surface.setAlpha(alpha);
|
||||
applyVisibility();
|
||||
lastError = null;
|
||||
evict();
|
||||
// The full-extent image swaps first; the detail follows for the same time.
|
||||
watchCamera();
|
||||
if (viewBounds !== current.bounds) computeView();
|
||||
// An older frame's detail stays over at most one newer frame.
|
||||
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
|
||||
refreshDetail();
|
||||
frame.resolve(true);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
}
|
||||
function install(frame, image) {
|
||||
if (frame.closed || incoming !== frame) return;
|
||||
if (surface?.bounds === frame.bounds) {
|
||||
// Cesium keeps drawing the previous texture until this one is uploaded.
|
||||
surface.setImage(image);
|
||||
commit(frame);
|
||||
return;
|
||||
}
|
||||
// First frame or a new extent: stage an invisible surface and keep the
|
||||
// previous one until the new geometry and texture are drawable.
|
||||
const { west, south, east, north } = frame.snapshot.bounds;
|
||||
const next = createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
height,
|
||||
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
|
||||
onSettled: () => commit(frame),
|
||||
});
|
||||
next.bounds = frame.bounds;
|
||||
frame.surface = next;
|
||||
next.setAlpha(0);
|
||||
next.setImage(image);
|
||||
}
|
||||
|
||||
return {
|
||||
product,
|
||||
rehome() {
|
||||
if (suspended()) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
}
|
||||
const changed = applyVisibility();
|
||||
if (current && !suspended()) {
|
||||
computeView();
|
||||
refreshDetail();
|
||||
}
|
||||
if (changed) scene.requestRender();
|
||||
return changed;
|
||||
},
|
||||
cancelPrefetch,
|
||||
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
if (
|
||||
hidden ||
|
||||
incoming ||
|
||||
suspended() ||
|
||||
snapshot.product !== product ||
|
||||
!snapshot.times.includes(time)
|
||||
)
|
||||
return false;
|
||||
// Warm the next frame's detail window too while the window stays put.
|
||||
const box =
|
||||
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
|
||||
const coarse = cacheKey(time, mode);
|
||||
const key = box ? cacheKey(time, mode, box) : coarse;
|
||||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||||
cancelPrefetch();
|
||||
job = { key, controller: new AbortController() };
|
||||
prefetchJob = job;
|
||||
const { signal } = job.controller;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
// Both images at once: one after the other would outlast playback's step.
|
||||
await Promise.all([
|
||||
acquire(time, mode, signal, { keep: box ? [key] : [] }),
|
||||
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
if (prefetchJob === job) prefetchedKey = key;
|
||||
return true;
|
||||
} catch {
|
||||
job?.controller.abort();
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(job?.timeout);
|
||||
if (job && prefetchJob === job) prefetchJob = null;
|
||||
}
|
||||
},
|
||||
async setFrame(
|
||||
snapshot,
|
||||
time,
|
||||
{ signal, infrared: mode = 'filtered' } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
cancelPrefetch();
|
||||
const bounds = boundsKey(snapshot.bounds);
|
||||
const same = (frame) =>
|
||||
frame?.time === time &&
|
||||
frame.product === snapshot.product &&
|
||||
frame.infrared === mode &&
|
||||
frame.bounds === bounds;
|
||||
// A request for the frame a host switch is restaging joins that work.
|
||||
if (same(incoming) && !incoming.owned && !suspended()) {
|
||||
watch(incoming, signal);
|
||||
return incoming.result;
|
||||
}
|
||||
cancelIncoming();
|
||||
applyVisibility();
|
||||
if (suspended()) return false;
|
||||
if (same(current)) return true;
|
||||
lastError = null;
|
||||
const frame = {
|
||||
snapshot,
|
||||
time,
|
||||
infrared: mode,
|
||||
bounds,
|
||||
product: snapshot.product,
|
||||
key: cacheKey(time, mode),
|
||||
mosaic: { fetched: false, decodeMs: null, cached: false },
|
||||
controller: new AbortController(),
|
||||
startedAt: now(),
|
||||
surface: null,
|
||||
owned: false,
|
||||
closed: false,
|
||||
resolve: null,
|
||||
};
|
||||
incoming = frame;
|
||||
frame.result = new Promise((resolve) => {
|
||||
frame.resolve = resolve;
|
||||
});
|
||||
watch(frame, signal);
|
||||
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
|
||||
void acquire(time, mode, frame.controller.signal, {
|
||||
onFetched: () => {
|
||||
frame.mosaic.fetched = true;
|
||||
},
|
||||
})
|
||||
.then(({ texture, decodeMs, cached }) => {
|
||||
frame.mosaic.decodeMs = decodeMs;
|
||||
frame.mosaic.cached = cached;
|
||||
install(frame, texture);
|
||||
})
|
||||
.catch(() => fail(frame));
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
return frame.result;
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
}
|
||||
applyVisibility();
|
||||
if (!hidden) refreshDetail();
|
||||
scene.requestRender();
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
surface?.setAlpha(alpha);
|
||||
scene.requestRender();
|
||||
},
|
||||
clear() {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
offCamera?.();
|
||||
offCamera = null;
|
||||
dropDetail();
|
||||
surface?.destroy();
|
||||
surface = null;
|
||||
current = null;
|
||||
view = null;
|
||||
viewBounds = null;
|
||||
hidden = false;
|
||||
lastError = null;
|
||||
images.clear();
|
||||
imageBytes = 0;
|
||||
scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
const drawn = surface?.getDiagnostics();
|
||||
const applied = drawn?.detail.window;
|
||||
return {
|
||||
host: 'shell',
|
||||
height,
|
||||
imageSize: { ...size },
|
||||
shell: drawn
|
||||
? {
|
||||
...drawn,
|
||||
detail: {
|
||||
bbox: view
|
||||
? [view.west, view.south, view.east, view.north]
|
||||
: null,
|
||||
size: { ...detailSize },
|
||||
// The wanted window is applied and its image drawn.
|
||||
ready: Boolean(
|
||||
detail &&
|
||||
current &&
|
||||
detail.key === wantedDetail() &&
|
||||
drawn.ready &&
|
||||
drawn.show &&
|
||||
drawn.detail.uploaded &&
|
||||
applied &&
|
||||
sameEdges(applied, detailRect()),
|
||||
),
|
||||
enabled: view !== null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
cache: {
|
||||
mosaics: images.size,
|
||||
bytes: imageBytes,
|
||||
prefetching: !!prefetchJob,
|
||||
},
|
||||
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
|
||||
mosaic: (incoming || current)?.mosaic,
|
||||
infrared: (incoming || current)?.infrared ?? 'filtered',
|
||||
loading: !!incoming,
|
||||
time: hidden ? null : (current?.time ?? null),
|
||||
hidden,
|
||||
product: current?.product ?? null,
|
||||
frameLoadMs: current?.loadMs ?? null,
|
||||
error: imageryHostStatus(getHost()) || lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
export const WEATHER_PRODUCTS = Object.freeze([
|
||||
'radar',
|
||||
'clouds',
|
||||
'clouds-regional',
|
||||
'lightning',
|
||||
]);
|
||||
|
||||
/** Only bounded, explicit observations may become imagery requests. */
|
||||
export function validateWeatherSnapshot(value, product) {
|
||||
if (!value || value.schemaVersion !== 1 || value.product !== product)
|
||||
throw new Error('Malformed weather manifest');
|
||||
if (value.unavailable) return value;
|
||||
const { bounds, times } = value;
|
||||
if (
|
||||
!bounds ||
|
||||
!['west', 'south', 'east', 'north'].every((key) =>
|
||||
Number.isFinite(bounds[key]),
|
||||
) ||
|
||||
bounds.west < -180 ||
|
||||
bounds.east > 180 ||
|
||||
bounds.south < -90 ||
|
||||
bounds.north > 90 ||
|
||||
bounds.west >= bounds.east ||
|
||||
bounds.south >= bounds.north ||
|
||||
!Array.isArray(times) ||
|
||||
times.length < 1 ||
|
||||
times.length > 13 ||
|
||||
times.some(
|
||||
(time, i) =>
|
||||
typeof time !== 'string' ||
|
||||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(time) ||
|
||||
!Number.isFinite(Date.parse(time)) ||
|
||||
new Date(time).toISOString() !== time ||
|
||||
(i > 0 && time <= times[i - 1]),
|
||||
) ||
|
||||
value.latest !== times.at(-1) ||
|
||||
value.tileSize !== 256 ||
|
||||
value.maxLevel !== 6 ||
|
||||
value.tilingScheme !== 'geographic'
|
||||
)
|
||||
throw new Error('Malformed weather manifest');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Largest whole-extent image per product; also the proxy default size. */
|
||||
export const WEATHER_IMAGE_SIZES = Object.freeze({
|
||||
radar: Object.freeze({ width: 4096, height: 2048 }),
|
||||
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
|
||||
clouds: Object.freeze({ width: 2048, height: 1024 }),
|
||||
lightning: Object.freeze({ width: 4096, height: 2048 }),
|
||||
});
|
||||
/** Largest detail-window image for every product; also the proxy default. */
|
||||
export const WEATHER_DETAIL_SIZE = Object.freeze({ width: 4096, height: 2048 });
|
||||
|
||||
/** A whole-extent image, or with `bbox` ({ west, south, east, north } degrees)
|
||||
* a detail window of the same product. */
|
||||
export function weatherImageUrl(
|
||||
product,
|
||||
time,
|
||||
{ width, height } = {},
|
||||
bbox = null,
|
||||
) {
|
||||
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
|
||||
throw new Error('Invalid weather frame');
|
||||
let box = '';
|
||||
if (bbox !== null) {
|
||||
const edges = [bbox.west, bbox.south, bbox.east, bbox.north];
|
||||
if (
|
||||
!edges.every(Number.isFinite) ||
|
||||
edges[0] >= edges[2] ||
|
||||
edges[1] >= edges[3]
|
||||
)
|
||||
throw new Error('Invalid weather window');
|
||||
box = `&bbox=${edges.join(',')}`;
|
||||
}
|
||||
const largest =
|
||||
bbox === null ? WEATHER_IMAGE_SIZES[product] : WEATHER_DETAIL_SIZE;
|
||||
let size = '';
|
||||
if (width !== undefined || height !== undefined) {
|
||||
if (
|
||||
![1024, 2048, 4096].includes(width) ||
|
||||
height !== width / 2 ||
|
||||
width > largest.width
|
||||
)
|
||||
throw new Error('Invalid weather image size');
|
||||
// The largest size is the proxy default: one frame has one URL.
|
||||
if (width !== largest.width) size = `&size=${width}x${height}`;
|
||||
}
|
||||
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${box}${size}`;
|
||||
}
|
||||
|
||||
export function weatherTileUrl(product, time, { size } = {}) {
|
||||
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
|
||||
throw new Error('Invalid weather frame');
|
||||
if (size !== undefined && ![256, 512, 1024].includes(size))
|
||||
throw new Error('Invalid weather tile size');
|
||||
// Construct locally; never accept a manifest-provided host or template.
|
||||
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
|
||||
}
|
||||
|
||||
/** Acquisition is lazy and shares the application's existing source contract. */
|
||||
export function createWeatherSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ product = 'radar', signal } = {}) {
|
||||
if (!WEATHER_PRODUCTS.includes(product))
|
||||
throw new Error('Unknown weather product');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Weather request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/weather/manifest?product=${product}`,
|
||||
{ signal: controller.signal, cache: 'no-store', redirect: 'error' },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
|
||||
return validateWeatherSnapshot(
|
||||
await readResponseJsonCapped(response, 16_384, controller.signal),
|
||||
product,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWeatherSource,
|
||||
validateWeatherSnapshot,
|
||||
weatherImageUrl,
|
||||
weatherTileUrl,
|
||||
WEATHER_DETAIL_SIZE,
|
||||
WEATHER_IMAGE_SIZES,
|
||||
} from './source.js';
|
||||
const time = '2026-09-16T02:00:00.000Z';
|
||||
const snapshot = () => ({
|
||||
schemaVersion: 1,
|
||||
product: 'radar',
|
||||
bounds: { west: -130, south: 20, east: -60, north: 55 },
|
||||
times: [time],
|
||||
latest: time,
|
||||
tileSize: 256,
|
||||
maxLevel: 6,
|
||||
tilingScheme: 'geographic',
|
||||
});
|
||||
test('observed source accepts bounded exact times and refuses malformed or unsorted frames', () => {
|
||||
assert.equal(validateWeatherSnapshot(snapshot(), 'radar').latest, time);
|
||||
for (const bad of [
|
||||
{ times: [time, time] },
|
||||
{ product: 'other' },
|
||||
{ times: Array(14).fill(time) },
|
||||
{ latest: 'tomorrow' },
|
||||
{ maxLevel: 20 },
|
||||
{ bounds: { west: -999, south: 0, east: 1, north: 1 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => validateWeatherSnapshot({ ...snapshot(), ...bad }, 'radar'),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('weather tile URLs are same origin and ignore upstream templates', async () => {
|
||||
const calls = [];
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json({
|
||||
...snapshot(),
|
||||
tileTemplate: 'https://example.invalid/{z}',
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshot({ product: 'radar' });
|
||||
assert.match(
|
||||
weatherTileUrl(result.product, result.latest),
|
||||
/^\/api\/weather\/tile\?product=radar&time=2026/,
|
||||
);
|
||||
assert.equal(calls[0].options.redirect, 'error');
|
||||
assert.equal(calls[0].url, '/api/weather/manifest?product=radar');
|
||||
});
|
||||
test('source aborts before acquisition and caps streamed manifest bytes', async () => {
|
||||
let calls = 0;
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return new Response(' '.repeat(17000));
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(source.getSnapshot(), /too large/);
|
||||
});
|
||||
|
||||
test('weather tile URLs carry only supported optional pixel sizes', () => {
|
||||
assert.equal(
|
||||
new URL(
|
||||
weatherTileUrl('radar', time),
|
||||
'https://example.test',
|
||||
).searchParams.has('size'),
|
||||
false,
|
||||
);
|
||||
for (const size of [256, 512, 1024]) {
|
||||
const url = new URL(
|
||||
weatherTileUrl('lightning', time, { size }),
|
||||
'https://example.test',
|
||||
);
|
||||
assert.equal(url.searchParams.get('size'), String(size));
|
||||
assert.equal(url.searchParams.get('time'), time);
|
||||
}
|
||||
for (const size of [0, 257, 2048, '1024', null])
|
||||
assert.throws(() => weatherTileUrl('radar', time, { size }), /tile size/);
|
||||
});
|
||||
|
||||
test('whole-extent image URLs are same origin and omit the default largest size', () => {
|
||||
for (const [product, width] of [
|
||||
['radar', 4096],
|
||||
['clouds-regional', 4096],
|
||||
['clouds', 2048],
|
||||
['lightning', 4096],
|
||||
]) {
|
||||
assert.deepEqual(WEATHER_IMAGE_SIZES[product], {
|
||||
width,
|
||||
height: width / 2,
|
||||
});
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time),
|
||||
`/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`,
|
||||
);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width, height: width / 2 }),
|
||||
weatherImageUrl(product, time),
|
||||
);
|
||||
assert.equal(
|
||||
new URL(
|
||||
weatherImageUrl(product, time, { width: 1024, height: 512 }),
|
||||
'https://example.test',
|
||||
).searchParams.get('size'),
|
||||
'1024x512',
|
||||
);
|
||||
}
|
||||
for (const size of [
|
||||
{ width: 8192, height: 4096 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 512, height: 256 },
|
||||
{ width: '1024', height: 512 },
|
||||
])
|
||||
assert.throws(() => weatherImageUrl('lightning', time, size), /size/);
|
||||
assert.throws(
|
||||
() => weatherImageUrl('clouds', time, { width: 4096, height: 2048 }),
|
||||
/size/,
|
||||
);
|
||||
assert.throws(() => weatherImageUrl('other', time), /frame/);
|
||||
assert.throws(() => weatherImageUrl('radar', 'latest'), /frame/);
|
||||
});
|
||||
|
||||
test('detail-window image URLs carry the bbox and omit the default detail size', () => {
|
||||
const box = { west: -102, south: 34, east: -96, north: 37 };
|
||||
assert.deepEqual(WEATHER_DETAIL_SIZE, { width: 4096, height: 2048 });
|
||||
for (const product of ['radar', 'clouds-regional', 'lightning', 'clouds']) {
|
||||
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=-102,34,-96,37`;
|
||||
assert.equal(weatherImageUrl(product, time, {}, box), url);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width: 4096, height: 2048 }, box),
|
||||
url,
|
||||
'every product has a 4096×2048 detail default',
|
||||
);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width: 2048, height: 1024 }, box),
|
||||
`${url}&size=2048x1024`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
weatherImageUrl('radar', time, {}, { ...box, west: -97.5, east: -91.5 }),
|
||||
`/api/weather/image?product=radar&time=${encodeURIComponent(time)}&bbox=-97.5,34,-91.5,37`,
|
||||
);
|
||||
for (const bad of [
|
||||
{ ...box, west: -96 },
|
||||
{ ...box, north: 34 },
|
||||
{ ...box, east: Number.NaN },
|
||||
{ west: -102, south: 34, east: -96 },
|
||||
])
|
||||
assert.throws(() => weatherImageUrl('radar', time, {}, bad), /window/);
|
||||
assert.throws(
|
||||
() => weatherImageUrl('radar', time, { width: 8192, height: 4096 }, box),
|
||||
/size/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user