mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
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:
13 files changed
+1072
-31
No files matched your search
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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--;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user