Merge pull request #1665 from bilawalsidhu/sameh/weather-frame-source

feat(weather): a host can supply decoded frames to the weather layers
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-10 22:04:58 -08:00
commit 9bca228340
13 files changed
+1072 -31

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- A weather feed can supply decoded frames to the weather layers. With an
optional `getFrameImage` or `getFrameTile`, Core draws the images and tiles
the feed returns instead of reading the image and tile routes; a feed
without them is unchanged (samehkhamis, #1665).
+7
View File
@@ -188,6 +188,13 @@ state restoration, and release them after the consumers stop. The catalog
constructor (`application/layers`) also requires `wind`, `weather` and
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the
observed-history clock those layers share for the catalog's lifetime.
A `weather` source may also supply decoded frames: an optional
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
detail image and an optional `getFrameTile({ product, time, z, x, y, size,
signal })` returns one tile or null for a transparent tile, each as an
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
size, which the layer draws once and closes. Without them the layers read the
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
`application/services` accepts boundary, terrain, regional-context, weather and
summary services; its weather service supplies cockpit local conditions, not the
+3
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { weatherFrameSource } from './source.js';
import { imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import {
@@ -66,6 +67,7 @@ export function createWeatherLayer({
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Weather requires a snapshot source');
// A feed may also supply decoded frames; see weatherFrameSource.
const radar = id === 'weather-radar';
const lightning = id === 'weather-lightning';
const satellite = !radar && !lightning;
@@ -288,6 +290,7 @@ export function createWeatherLayer({
cesium,
getHost,
onChange: notify,
frameSource: weatherFrameSource(feed),
});
checkHost(false);
eventTarget?.addEventListener?.(
+56 -3
View File
@@ -1,6 +1,11 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { msg } from '../../i18n/index.js';
import { weatherImageUrl } from './source.js';
import {
weatherImageUrl,
weatherPixelSize,
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
@@ -20,10 +25,35 @@ export function processInfraredImage(
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
applyInfraredTransfer(context, canvas, mode);
return canvas;
}
function applyInfraredTransfer(context, canvas, mode) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
return canvas;
}
/** Draw pixels a frame source supplied into a new canvas, with the display
* transfer for infrared products, and release them. They must be `size`. */
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
try {
signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
if (width !== size.width || height !== size.height)
throw new Error('Invalid weather image dimensions');
const texture = createCanvas();
texture.width = width;
texture.height = height;
const context = texture.getContext('2d');
drawWeatherPixels(context, pixels);
if (product === 'clouds' || product === 'clouds-regional')
applyInfraredTransfer(context, texture, mode);
return texture;
} finally {
closeWeatherPixels(pixels);
}
}
/** Decode locally, releasing the object URL on success, failure or cancellation. */
@@ -60,7 +90,8 @@ export function decodeInfraredImage(blob, signal) {
/** 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. */
* image is always released. With a `frameSource` that has `getImage`, the host
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
export async function acquireWeatherImage(
product,
time,
@@ -75,8 +106,30 @@ export async function acquireWeatherImage(
decodeImage = decodeInfraredImage,
now = () => performance.now(),
onFetched = () => {},
frameSource = null,
},
) {
if (frameSource?.getImage) {
signal.throwIfAborted();
const started = now();
const pixels = await frameSource.getImage({
product,
time,
size,
bbox,
signal,
});
onFetched();
const texture = drawSuppliedImage(
pixels,
product,
mode,
size,
signal,
createCanvas,
);
return { texture, decodeMs: now() - started };
}
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
signal,
});
+152
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
acquireInfraredMosaic,
acquireWeatherImage,
decodeInfraredImage,
processInfraredImage,
MAX_MOSAIC_BYTES,
@@ -183,3 +184,154 @@ test('bitmap decoding and Image fallback release resources on success, error and
}
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
});
const pixels = (width, height) => ({
width,
height,
closes: 0,
close() {
this.closes++;
},
});
test('a frame source supplies the image: nothing is fetched or decoded and the pixels are released', async () => {
const controller = new AbortController();
const requests = [];
const bitmap = pixels(2048, 1024);
let clock = 5,
fetched = false;
const result = await acquireInfraredMosaic('2026-09-15T20:00:00.000Z', {
signal: controller.signal,
mode: 'filtered',
createCanvas: canvas,
now: () => clock,
onFetched: () => {
fetched = true;
},
fetchImpl: () => assert.fail('a supplied frame is not fetched'),
decodeImage: () => assert.fail('a supplied frame is not decoded'),
frameSource: {
getImage: async (request) => {
requests.push(request);
clock = 12;
return bitmap;
},
},
});
assert.deepEqual(requests, [
{
product: 'clouds',
time: '2026-09-15T20:00:00.000Z',
size: { width: 2048, height: 1024 },
bbox: null,
signal: controller.signal,
},
]);
assert.equal(fetched, true);
assert.equal(result.decodeMs, 7);
assert.equal(result.texture.width, 2048);
assert.equal(result.texture.height, 1024);
assert.equal(result.texture.image, bitmap);
assert.deepEqual(
[...result.texture.pixels],
[194, 0, 0, 98],
'infrared frames get the display transfer',
);
assert.equal(bitmap.closes, 1);
});
test('supplied detail windows carry their bbox; ImageData is put, radar keeps its pixels', async (t) => {
const had = Object.hasOwn(globalThis, 'ImageData');
const previous = globalThis.ImageData;
globalThis.ImageData = class ImageData {
width = 4096;
height = 2048;
data = new Uint8ClampedArray([1, 2, 3, 4]);
};
t.after(() => {
if (had) globalThis.ImageData = previous;
else delete globalThis.ImageData;
});
const data = new globalThis.ImageData();
const bbox = { west: -102, south: 34, east: -96, north: 37 };
let request;
const { texture } = await acquireWeatherImage('radar', 't', {
signal: new AbortController().signal,
size: { width: 4096, height: 2048 },
bbox,
createCanvas: canvas,
fetchImpl: () => assert.fail('no fetch'),
frameSource: {
getImage: async (value) => {
request = value;
return data;
},
},
});
assert.equal(request.bbox, bbox);
assert.equal(
texture.pixels,
data.data,
'ImageData is put as it is; radar has no transfer',
);
assert.equal(texture.image, undefined, 'ImageData is not drawn as an image');
assert.equal(texture.width, 4096);
});
test('a supplied frame of the wrong size is refused and still released', async () => {
const wrong = pixels(1024, 512);
await assert.rejects(
acquireInfraredMosaic('t', {
signal: new AbortController().signal,
mode: 'filtered',
createCanvas: canvas,
frameSource: { getImage: async () => wrong },
}),
/Invalid weather image dimensions/,
);
assert.equal(wrong.closes, 1);
await assert.rejects(
acquireInfraredMosaic('t', {
signal: new AbortController().signal,
mode: 'filtered',
createCanvas: canvas,
frameSource: { getImage: async () => undefined },
}),
/Invalid weather image dimensions/,
);
});
test('cancellation reaches the frame source, and a frame that arrives after it is released', async () => {
const controller = new AbortController();
const late = pixels(2048, 1024);
let seen;
const pending = acquireInfraredMosaic('t', {
signal: controller.signal,
mode: 'filtered',
createCanvas: () => assert.fail('a cancelled frame is not drawn'),
frameSource: {
getImage: async ({ signal }) => {
seen = signal;
await flushMicrotasks();
return late;
},
},
});
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(seen, controller.signal);
assert.equal(late.closes, 1);
const aborted = new AbortController();
aborted.abort();
await assert.rejects(
acquireInfraredMosaic('t', {
signal: aborted.signal,
frameSource: { getImage: () => assert.fail('already cancelled') },
}),
{ name: 'AbortError' },
);
});
async function flushMicrotasks() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
+98
View File
@@ -1,3 +1,9 @@
import {
weatherPixelSize,
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
/** Tile a bounded decoded canvas without network requests or scene state. */
export function createRasterTileProvider({
cesium,
@@ -48,3 +54,95 @@ export function createRasterTileProvider({
},
};
}
/**
* Tile one product time from a host frame source. Every resolved tile is a
* canvas: supplied pixels are drawn into a new one and released, and a null
* tile is one shared transparent canvas. Beyond `maxConcurrent` tiles in
* flight requestImage returns undefined, Cesium's signal to ask again later. A
* failed tile rejects, which Cesium reports through errorEvent. Aborting
* `signal` cancels every tile in flight.
*/
export function createFrameTileProvider({
cesium,
frameSource,
product,
time,
tilingScheme,
rectangle = cesium.Rectangle.MAX_VALUE,
maximumLevel,
credit,
createCanvas,
tileSize = 256,
maxConcurrent = 6,
signal,
}) {
let inFlight = 0;
let empty = null;
const canvas = () => {
const tile = createCanvas();
tile.width = tile.height = tileSize;
return tile;
};
async function load(x, y, level, request) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
try {
signal?.throwIfAborted();
const pixels = await frameSource.getTile({
product,
time,
z: level,
x,
y,
size: tileSize,
signal: controller.signal,
});
if (pixels === null) {
controller.signal.throwIfAborted();
return (empty ??= canvas());
}
try {
controller.signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
if (width !== tileSize || height !== tileSize)
throw new Error('Invalid weather tile dimensions');
const tile = canvas();
drawWeatherPixels(tile.getContext('2d'), pixels);
return tile;
} finally {
closeWeatherPixels(pixels);
}
} catch (error) {
// A cancelled tile is unloaded, not failed, so Cesium may ask again.
if (controller.signal.aborted && request && cesium.RequestState)
request.state = cesium.RequestState.CANCELLED;
throw error;
} finally {
signal?.removeEventListener('abort', abort);
}
}
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, request) {
if (inFlight >= maxConcurrent) return undefined;
inFlight++;
return load(x, y, level, request).finally(() => {
inFlight--;
});
},
};
}
+162 -1
View File
@@ -1,7 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRasterTileProvider } from './rasterTiles.js';
import {
createRasterTileProvider,
createFrameTileProvider,
} from './rasterTiles.js';
import { createFieldRaster } from '../wind/fields.js';
// A small canvas double exposes the uploaded raster and exact drawImage crop.
@@ -154,3 +157,161 @@ test('512-pixel crops preserve source extent at root and clamped draping levels'
assert.deepEqual(tile.crop, crop);
}
});
const time = '2026-09-15T20:00:00.000Z';
function tileCanvas() {
const canvas = { width: 0, height: 0, drawn: [] };
canvas.getContext = () => ({
drawImage: (source, ...args) => canvas.drawn.push([source, ...args]),
putImageData: (source, ...args) => canvas.drawn.push([source, ...args]),
});
return canvas;
}
const tilePixels = (size = 256) => ({
width: size,
height: size,
closes: 0,
close() {
this.closes++;
},
});
const deferredTile = () => {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
};
function frameProvider(getTile, options = {}) {
return createFrameTileProvider({
cesium: Cesium,
frameSource: { getTile },
product: 'radar',
time,
tilingScheme: new Cesium.GeographicTilingScheme(),
rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
maximumLevel: 6,
credit: new Cesium.Credit('NOAA'),
createCanvas: tileCanvas,
...options,
});
}
test('a frame tile provider draws each supplied tile into its own canvas and releases it', async () => {
const requests = [];
const supplied = tilePixels();
const provider = frameProvider(async (request) => {
requests.push(request);
return supplied;
});
assert.equal(provider.tileWidth, 256);
assert.equal(provider.maximumLevel, 6);
assert.equal(provider.hasAlphaChannel, true);
const tile = await provider.requestImage(3, 1, 2);
assert.equal(tile.width, 256);
assert.equal(tile.height, 256);
assert.deepEqual(tile.drawn, [[supplied, 0, 0]]);
assert.equal(supplied.closes, 1);
assert.equal(requests.length, 1);
const { signal, ...rest } = requests[0];
assert.deepEqual(rest, {
product: 'radar',
time,
z: 2,
x: 3,
y: 1,
size: 256,
});
assert.ok(signal instanceof AbortSignal);
});
test('a null tile is one shared transparent canvas', async () => {
const provider = frameProvider(async () => null);
const first = await provider.requestImage(0, 0, 0);
const second = await provider.requestImage(1, 0, 0);
assert.equal(first, second);
assert.equal(first.width, 256);
assert.deepEqual(first.drawn, [], 'nothing is drawn on it');
});
test('beyond the concurrency limit a tile is deferred to Cesium', async () => {
const gates = [];
const provider = frameProvider(
() => {
const gate = deferredTile();
gates.push(gate);
return gate.promise;
},
{ maxConcurrent: 2 },
);
const first = provider.requestImage(0, 0, 1);
const second = provider.requestImage(1, 0, 1);
assert.equal(provider.requestImage(2, 0, 1), undefined);
gates[0].resolve(null);
await first;
const third = provider.requestImage(2, 0, 1);
assert.ok(third instanceof Promise, 'a finished tile frees its slot');
gates[1].resolve(null);
gates[2].resolve(null);
await Promise.all([second, third]);
});
test('a failed tile rejects, which Cesium reports through errorEvent', async () => {
const provider = frameProvider(async () => {
throw new Error('frame unavailable');
});
await assert.rejects(provider.requestImage(0, 0, 0), /frame unavailable/);
const wrong = tilePixels(512);
const sized = frameProvider(async () => wrong);
await assert.rejects(
sized.requestImage(0, 0, 0),
/Invalid weather tile dimensions/,
);
assert.equal(wrong.closes, 1, 'a refused tile is still released');
const layer = new Cesium.ImageryLayer(provider);
const reported = [];
provider.errorEvent.addEventListener((error) => {
reported.push(error);
error.retry = false;
});
const imagery = { x: 0, y: 0, level: 0, request: undefined };
layer._requestImagery(imagery);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(reported.length, 1);
assert.match(reported[0].error.message, /frame unavailable/);
assert.equal(imagery.state, Cesium.ImageryState.FAILED);
layer.destroy();
});
test('aborting the provider signal cancels tiles in flight and releases late pixels', async () => {
const controller = new AbortController();
const gate = deferredTile();
let seen;
const provider = frameProvider(
({ signal }) => {
seen = signal;
return gate.promise;
},
{ signal: controller.signal },
);
const request = new Cesium.Request();
const pending = provider.requestImage(0, 0, 0, request);
assert.equal(seen.aborted, false);
controller.abort();
assert.equal(seen.aborted, true, 'the abort reaches the frame source');
const late = tilePixels();
gate.resolve(late);
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(late.closes, 1);
assert.equal(
request.state,
Cesium.RequestState.CANCELLED,
'Cesium unloads a cancelled tile instead of failing it',
);
await assert.rejects(
provider.requestImage(1, 0, 0),
{ name: 'AbortError' },
'a closed frame asks the source for nothing more',
);
});
+78 -26
View File
@@ -1,7 +1,10 @@
import { weatherTileUrl } from './source.js';
import { weatherTileUrl, closeWeatherPixels } from './source.js';
import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import {
createRasterTileProvider,
createFrameTileProvider,
} from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { weatherDisplay, escapeCreditText } from './display.js';
import { msg } from '../../i18n/index.js';
@@ -30,6 +33,7 @@ function createGlobeRendering({
fetchImpl,
decodeImage,
createCanvas,
frameSource,
}) {
let current = null;
let incoming = null;
@@ -72,6 +76,7 @@ function createGlobeRendering({
decodeImage,
now,
onFetched,
frameSource,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
@@ -82,7 +87,8 @@ function createGlobeRendering({
return { ...result, cached: false };
}
function prefetchTiles(snapshot, time) {
/** Level 0–1 tiles of the visible product bounds, at most eight. */
function prefetchTiles(snapshot) {
const { west, south, east, north } = snapshot.bounds;
const bounds = cesium.Rectangle.fromDegrees(west, south, east, north);
const view = viewer.camera?.computeViewRectangle?.(
@@ -93,10 +99,7 @@ function createGlobeRendering({
: bounds;
if (!coverage) return [];
const scheme = new cesium.GeographicTilingScheme();
const template = weatherTileUrl(snapshot.product, time, {
size: profile(snapshot.product).tileSize,
});
const urls = [];
const tiles = [];
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++) {
@@ -107,14 +110,39 @@ function createGlobeRendering({
)
)
continue;
urls.push(
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
);
if (urls.length === MAX_PREFETCH_TILES) return urls;
tiles.push({ z, x, y });
if (tiles.length === MAX_PREFETCH_TILES) return tiles;
}
}
}
return urls;
return tiles;
}
function tileUrls(snapshot, time, tiles) {
const template = weatherTileUrl(snapshot.product, time, {
size: profile(snapshot.product).tileSize,
});
return tiles.map(({ z, x, y }) =>
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
);
}
/** Warm tiles through the frame source; the pixels themselves are released. */
function prefetchFrameTiles(snapshot, time, tiles, signal) {
const size = profile(snapshot.product).tileSize;
return Promise.all(
tiles.map(async ({ z, x, y }) =>
closeWeatherPixels(
await frameSource.getTile({
product: snapshot.product,
time,
z,
x,
y,
size,
signal,
}),
),
),
);
}
function creditText(snapshot) {
@@ -225,8 +253,14 @@ function createGlobeRendering({
)
return false;
const global = snapshot.product === 'clouds';
const urls = global ? [] : prefetchTiles(snapshot, time);
const key = global ? `${time}|${infrared}` : urls.join('|');
const tiles = global ? [] : prefetchTiles(snapshot);
const supplied = !global && Boolean(frameSource?.getTile);
const urls = global || supplied ? [] : tileUrls(snapshot, time, tiles);
const key = global
? `${time}|${infrared}`
: supplied
? `${snapshot.product}|${time}|${tiles.map(({ z, x, y }) => `${z}/${x}/${y}`).join('|')}`
: urls.join('|');
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
@@ -234,6 +268,8 @@ function createGlobeRendering({
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
if (global) await mosaic(time, infrared, signal);
else if (supplied)
await prefetchFrameTiles(snapshot, time, tiles, signal);
else
await Promise.all(
urls.map(async (url) => {
@@ -396,18 +432,32 @@ function createGlobeRendering({
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,
});
: frameSource?.getTile
? createFrameTileProvider({
cesium,
frameSource,
product: snapshot.product,
time,
tilingScheme,
rectangle,
maximumLevel: nextProfile.maximumLevel,
tileSize: nextProfile.tileSize,
credit,
createCanvas,
signal: frame.controller.signal,
})
: 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,
});
// Cesium reads a provider's credit every frame; a refreshed manifest
// can replace it without restaging the imagery.
Object.defineProperty(provider, 'credit', {
@@ -597,6 +647,7 @@ export function createWeatherRendering({
decodeImage,
createCanvas = () => document.createElement('canvas'),
createShell = createWeatherShell,
frameSource = null,
}) {
const shared = {
viewer,
@@ -608,6 +659,7 @@ export function createWeatherRendering({
fetchImpl,
decodeImage,
createCanvas,
frameSource,
};
const globe = createGlobeRendering(shared);
let shell = null;
+4 -1
View File
@@ -391,7 +391,8 @@ export function detailWindow(footprint, bounds, previous = null) {
/** 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. */
* that area, so the two images share one mesh and never blend. A `frameSource`
* with `getImage` supplies both images, the detail window through its `bbox`. */
export function createWeatherShell({
viewer,
cesium,
@@ -405,6 +406,7 @@ export function createWeatherShell({
decodeImage,
createCanvas = () => document.createElement('canvas'),
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
frameSource = null,
}) {
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
throw new TypeError('Unknown weather product');
@@ -475,6 +477,7 @@ export function createWeatherShell({
decodeImage,
now,
onFetched,
frameSource,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
@@ -1502,3 +1502,101 @@ test('global infrared keeps one full-extent image; detail images halve for small
});
small.shell.clear();
});
test('a frame source supplies the full-extent and detail images, the detail through its bbox', async () => {
const view = camera(-100, 35, -98, 36);
const requests = [];
const supplied = [];
const h = harness({
camera: view,
fetchImpl: () => assert.fail('supplied images are not fetched'),
decodeImage: () => assert.fail('supplied images are not decoded'),
frameSource: {
getImage: async (request) => {
requests.push(request);
const image = {
...request.size,
closed: 0,
close() {
this.closed++;
},
};
supplied.push(image);
return image;
},
},
});
const pending = h.shell.setFrame(snapshot(), times[0]);
await flush();
h.render();
assert.equal(await pending, true);
await flush();
assert.deepEqual(
requests.map(({ product, time, size, bbox }) => ({
product,
time,
size,
bbox,
})),
[
{
product: 'radar',
time: times[0],
size: { width: 4096, height: 2048 },
bbox: null,
},
{
product: 'radar',
time: times[0],
size: { width: 4096, height: 2048 },
bbox: box(-102, 34, -96, 37),
},
],
);
assert.ok(supplied.every(({ closed }) => closed === 1));
const [shell] = h.primitives();
const { uniforms } = shell.appearance.material;
assert.equal(uniforms.image.source, supplied[0]);
assert.equal(uniforms.detail.source, supplied[1]);
h.render();
assert.deepEqual(windowOf(shell), windowFor(box(-102, 34, -96, 37)));
h.shell.clear();
});
test('cancelling a supplied frame aborts the frame source request', async () => {
const gate = deferred();
let signal;
const h = harness({
frameSource: {
getImage: (request) => {
signal = request.signal;
return gate.promise;
},
},
});
const abort = new AbortController();
const pending = h.shell.setFrame(snapshot(), times[0], {
signal: abort.signal,
});
await flush();
abort.abort();
assert.equal(signal.aborted, true);
assert.equal(await pending, false);
const late = {
width: 4096,
height: 2048,
closed: 0,
close() {
this.closed++;
},
};
gate.resolve(late);
await flush();
assert.equal(
late.closed,
1,
'a frame arriving after cancellation is released',
);
assert.equal(h.primitives().length, 0);
h.shell.clear();
});
+69
View File
@@ -104,6 +104,75 @@ export function weatherTileUrl(product, time, { size } = {}) {
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
}
/**
* Decoded pixels a host hands the weather layer. The layer takes ownership:
* it draws them once and closes a VideoFrame or ImageBitmap afterwards, so a
* host returns a fresh object for every call.
* @typedef {ImageBitmap|ImageData|HTMLCanvasElement|OffscreenCanvas|VideoFrame} WeatherPixels
*/
/**
* A weather feed may also be a frame source: a host can supply decoded frames
* in place of the bounded image and tile routes. Both methods are optional and
* independent; a product or path without one keeps the routes.
*
* `getFrameImage` returns one image exactly `size` pixels, covering the
* product's whole extent, or with `bbox` ({ west, south, east, north }
* degrees) a detail window of it. `getFrameTile` returns one `size`-pixel tile
* of the geographic tiling scheme, or null for a transparent tile. Rows run
* top-down (north first), as an image does. Both honour `signal`.
* @typedef {object} WeatherFrameFeed
* @property {(request: { product: string, time: string,
* size: { width: number, height: number },
* bbox: { west: number, south: number, east: number, north: number } | null,
* signal: AbortSignal }) => Promise<WeatherPixels>} [getFrameImage]
* @property {(request: { product: string, time: string, z: number, x: number,
* y: number, size: number, signal: AbortSignal })
* => Promise<WeatherPixels | null>} [getFrameTile]
*/
/**
* The frame source a feed provides, or null when it has neither method. A
* missing method is left undefined, so that path keeps the routes.
* @param {WeatherFrameFeed | undefined} feed
* @returns {{ getImage?: Function, getTile?: Function } | null}
*/
export function weatherFrameSource(feed) {
const image = typeof feed?.getFrameImage === 'function';
const tile = typeof feed?.getFrameTile === 'function';
if (!image && !tile) return null;
return {
getImage: image
? ({ product, time, size, bbox = null, signal }) =>
feed.getFrameImage({ product, time, size, bbox, signal })
: undefined,
getTile: tile
? ({ product, time, z, x, y, size, signal }) =>
feed.getFrameTile({ product, time, z, x, y, size, signal })
: undefined,
};
}
/** Width and height of supplied pixels; a VideoFrame draws at its display size. */
export function weatherPixelSize(pixels) {
return {
width: pixels?.displayWidth ?? pixels?.width,
height: pixels?.displayHeight ?? pixels?.height,
};
}
/** Draw supplied pixels at the canvas origin, at their own size. */
export function drawWeatherPixels(context, pixels) {
if (typeof ImageData !== 'undefined' && pixels instanceof ImageData)
context.putImageData(pixels, 0, 0);
else context.drawImage(pixels, 0, 0);
}
/** Release supplied pixels that hold decoder or GPU memory. */
export function closeWeatherPixels(pixels) {
if (typeof pixels?.close === 'function') pixels.close();
}
/** Acquisition is lazy and shares the application's existing source contract. */
export function createWeatherSource({
fetchImpl = (...args) => globalThis.fetch(...args),
+104
View File
@@ -5,6 +5,10 @@ import {
validateWeatherSnapshot,
weatherImageUrl,
weatherTileUrl,
weatherFrameSource,
weatherPixelSize,
drawWeatherPixels,
closeWeatherPixels,
WEATHER_DETAIL_SIZE,
WEATHER_IMAGE_SIZES,
} from './source.js';
@@ -199,3 +203,103 @@ test('image frames are read through the bounded image route', async () => {
/Weather image HTTP 409/,
);
});
test('a feed without frame methods is no frame source', () => {
assert.equal(weatherFrameSource(undefined), null);
assert.equal(weatherFrameSource({ getSnapshot: async () => ({}) }), null);
assert.equal(weatherFrameSource({ getFrameImage: 'no' }), null);
});
test('a frame source forwards exactly the documented requests to its feed', async () => {
const calls = [];
const signal = new AbortController().signal;
const feed = {
getFrameImage: async (request) => {
calls.push(['image', request]);
return 'image';
},
getFrameTile: async (request) => {
calls.push(['tile', request]);
return null;
},
};
const source = weatherFrameSource(feed);
const size = { width: 4096, height: 2048 };
assert.equal(
await source.getImage({ product: 'radar', time: 't', size, signal }),
'image',
);
const bbox = { west: -102, south: 34, east: -96, north: 37 };
await source.getImage({ product: 'radar', time: 't', size, bbox, signal });
assert.equal(
await source.getTile({
product: 'lightning',
time: 't',
z: 1,
x: 2,
y: 0,
size: 256,
signal,
extra: true,
}),
null,
);
assert.deepEqual(calls, [
['image', { product: 'radar', time: 't', size, bbox: null, signal }],
['image', { product: 'radar', time: 't', size, bbox, signal }],
[
'tile',
{ product: 'lightning', time: 't', z: 1, x: 2, y: 0, size: 256, signal },
],
]);
const imagesOnly = weatherFrameSource({ getFrameImage: feed.getFrameImage });
assert.equal(typeof imagesOnly.getImage, 'function');
assert.equal(imagesOnly.getTile, undefined, 'tiles keep the tile route');
const tilesOnly = weatherFrameSource({ getFrameTile: feed.getFrameTile });
assert.equal(tilesOnly.getImage, undefined, 'images keep the image route');
});
test('supplied pixels report their drawn size, draw at the origin and close once', (t) => {
assert.deepEqual(weatherPixelSize({ width: 4, height: 2 }), {
width: 4,
height: 2,
});
assert.deepEqual(
weatherPixelSize({
codedWidth: 8,
codedHeight: 8,
displayWidth: 4,
displayHeight: 2,
}),
{ width: 4, height: 2 },
'a VideoFrame draws at its display size',
);
const had = Object.hasOwn(globalThis, 'ImageData');
const previous = globalThis.ImageData;
globalThis.ImageData = class ImageData {
width = 1;
height = 1;
};
t.after(() => {
if (had) globalThis.ImageData = previous;
else delete globalThis.ImageData;
});
const calls = [];
const context = {
drawImage: (...args) => calls.push(['drawImage', ...args]),
putImageData: (...args) => calls.push(['putImageData', ...args]),
};
const bitmap = { width: 1, height: 1 };
const data = new globalThis.ImageData();
drawWeatherPixels(context, bitmap);
drawWeatherPixels(context, data);
assert.deepEqual(calls, [
['drawImage', bitmap, 0, 0],
['putImageData', data, 0, 0],
]);
let closes = 0;
closeWeatherPixels({ close: () => closes++ });
closeWeatherPixels(data);
closeWeatherPixels(null);
assert.equal(closes, 1);
});
+237
View File
@@ -2400,3 +2400,240 @@ test('disabled weather releases its camera listener and re-enabling binds only o
h.layer.destroy();
assert.equal(h.moveEnd.size, 0);
});
const suppliedTile = (size = 256) => ({
width: size,
height: size,
closes: 0,
close() {
this.closes++;
},
});
test('a frame source supplies regional tiles in place of the tile route, through the readiness wrapper', async () => {
let now = 0;
const requests = [];
const supplied = [];
const h = renderingHarness({
now: () => now,
fetchImpl: () => assert.fail('supplied tiles are not fetched'),
frameSource: {
getTile: async (request) => {
requests.push(request);
const tile = suppliedTile();
supplied.push(tile);
return tile;
},
},
});
h.viewer.scene.globe.tilesLoaded = false;
const loaded = h.rendering.setFrame(snapshot, times[0]);
assert.equal(h.providers.length, 1);
const [provider] = h.providers;
assert.equal(provider.options, undefined, 'no URL template provider');
assert.equal(provider.maximumLevel, 6);
const tile = await provider.requestImage(1, 0, 1, {});
assert.equal(tile.width, 256);
assert.equal(tile.source, supplied[0]);
assert.equal(supplied[0].closes, 1);
assert.equal(requests[0].product, 'radar');
assert.equal(requests[0].time, times[0]);
assert.deepEqual(
[requests[0].z, requests[0].x, requests[0].y, requests[0].size],
[1, 1, 0, 256],
);
assert.equal(h.rendering.getDiagnostics().loadedTiles, 1);
now = 500;
h.settle();
assert.equal(await loaded, true);
const gate = deferred();
let signal;
const next = renderingHarness({
frameSource: {
getTile: (request) => {
signal = request.signal;
return gate.promise;
},
},
});
const staged = next.rendering.setFrame(snapshot, times[1]);
const pending = next.providers[0].requestImage(0, 0, 0, {});
next.rendering.clear();
assert.equal(signal.aborted, true, 'clearing a frame aborts its tiles');
const late = suppliedTile();
gate.resolve(late);
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(late.closes, 1);
assert.equal(await staged, false);
h.rendering.clear();
});
test('a failed supplied tile fails the staged frame through errorEvent', async () => {
const h = renderingHarness({
frameSource: {
getTile: async () => {
throw new Error('frame unavailable');
},
},
});
const staged = h.rendering.setFrame(snapshot, times[0]);
const [provider] = h.providers;
await assert.rejects(provider.requestImage(0, 0, 0, {}));
provider.errorEvent.raiseEvent({ x: 0, y: 0, level: 0, error: {} });
h.settle();
assert.equal(await staged, false);
assert.match(h.rendering.getDiagnostics().error, /Weather tiles unavailable/);
h.rendering.clear();
});
test('supplied regional infrared tiles are canvases, so they get the transfer without a flip', async () => {
const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap');
const previous = globalThis.ImageBitmap;
globalThis.ImageBitmap = class ImageBitmap {
width = 256;
height = 256;
close() {}
};
try {
const h = renderingHarness({
frameSource: { getTile: async () => new globalThis.ImageBitmap() },
});
void h.rendering.setFrame(
{ ...snapshot, product: 'clouds-regional' },
times[0],
);
const tile = await h.providers[0].requestImage(0, 0, 0, {});
assert.equal(tile.drawnWith, null, 'drawn upright, not flipped');
assert.ok(tile.pixels, 'the infrared transfer ran');
const empty = renderingHarness({
frameSource: { getTile: async () => null },
});
void empty.rendering.setFrame(
{ ...snapshot, product: 'clouds-regional' },
times[0],
);
const transparent = await empty.providers[0].requestImage(0, 0, 0, {});
assert.equal(transparent.width, 256, 'a null tile is still a canvas');
h.rendering.clear();
empty.rendering.clear();
} finally {
if (hadBitmap) globalThis.ImageBitmap = previous;
else delete globalThis.ImageBitmap;
}
});
test('tiled prefetch asks the frame source for the same level 0/1 tiles and releases them', async (t) => {
const requests = [];
const supplied = [];
const h = renderingHarness({
fetchImpl: () => assert.fail('supplied tiles are not fetched'),
frameSource: {
getTile: async (request) => {
requests.push(request);
if (request.z === 0) return null;
const tile = suppliedTile();
supplied.push(tile);
return tile;
},
},
});
t.after(() => h.rendering.clear());
h.viewer.camera = {
computeViewRectangle: () =>
Cesium.Rectangle.fromDegrees(-120, 25, -100, 50),
};
assert.equal(await h.rendering.prefetch(snapshot, times[1]), true);
assert.deepEqual(
requests.map(({ product, time, z, x, y, size }) => [
product,
time,
z,
x,
y,
size,
]),
[
['radar', times[1], 0, 0, 0, 256],
['radar', times[1], 1, 0, 0, 256],
],
);
assert.ok(requests.every(({ signal }) => signal instanceof AbortSignal));
assert.equal(supplied[0].closes, 1);
assert.equal(
await h.rendering.prefetch(snapshot, times[1]),
false,
'same completed work is not repeated',
);
assert.equal(requests.length, 2);
const gate = deferred();
let signal;
const slow = renderingHarness({
frameSource: {
getTile: ({ signal: value }) => {
signal = value;
return gate.promise;
},
},
});
t.after(() => slow.rendering.clear());
slow.viewer.camera = h.viewer.camera;
const warm = slow.rendering.prefetch(snapshot, times[1]);
slow.rendering.cancelPrefetch();
assert.equal(signal.aborted, true, 'cancelling a prefetch aborts its tiles');
gate.resolve(null);
assert.equal(await warm, false);
});
test('the global mosaic comes from the frame source image when present', async (t) => {
const requests = [];
const h = renderingHarness({
fetchImpl: () => assert.fail('a supplied mosaic is not fetched'),
decodeImage: () => assert.fail('a supplied mosaic is not decoded'),
frameSource: {
getImage: async (request) => {
requests.push(request);
return { width: 2048, height: 1024, close() {} };
},
},
});
t.after(() => h.rendering.clear());
await showMosaic(h, times[0]);
assert.equal(requests.length, 1);
assert.equal(requests[0].product, 'clouds');
assert.deepEqual(requests[0].size, { width: 2048, height: 1024 });
assert.equal(requests[0].bbox, null);
assert.equal(h.rendering.getDiagnostics().mosaic.fetched, true);
});
test('the layer hands rendering a frame source only when its feed is one', () => {
const seen = [];
const createRendering = (args) => {
seen.push(args.frameSource);
return {
setAlpha() {},
rehome() {},
clear() {},
getDiagnostics: () => ({}),
};
};
const options = {
documentRef: target({ hidden: false }),
eventTarget: target(),
matchMedia: () => target({ matches: false }),
createRendering,
};
createWeatherLayer({
...options,
feed: { getSnapshot: async () => snapshot },
}).init({});
const getFrameTile = async () => null;
createWeatherLayer({
...options,
feed: { getSnapshot: async () => snapshot, getFrameTile },
}).init({});
assert.equal(seen[0], null);
assert.equal(typeof seen[1].getTile, 'function');
assert.equal(seen[1].getImage, undefined);
});