diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 8848601d1..d3ca6ffce 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index fb1447827..cab2d2b98 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -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?.( diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 0957f9ce4..de57ab3b9 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -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, }); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index 61e532564..fb06607e4 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -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(); +} diff --git a/src/layers/weather/rasterTiles.js b/src/layers/weather/rasterTiles.js index cba2b7a0c..e095345e6 100644 --- a/src/layers/weather/rasterTiles.js +++ b/src/layers/weather/rasterTiles.js @@ -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--; + }); + }, + }; +} diff --git a/src/layers/weather/rasterTiles.test.mjs b/src/layers/weather/rasterTiles.test.mjs index 5efcf11c0..6c654ff2a 100644 --- a/src/layers/weather/rasterTiles.test.mjs +++ b/src/layers/weather/rasterTiles.test.mjs @@ -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', + ); +}); diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 629101693..d7c2527ab 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -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; diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 9def82c43..fdcb8ca3b 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -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(); diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index ad65dcef2..f756fd7b9 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -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(); +}); diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index 5386bfad7..276e9bf2e 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -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} [getFrameImage] + * @property {(request: { product: string, time: string, z: number, x: number, + * y: number, size: number, signal: AbortSignal }) + * => Promise} [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), diff --git a/src/layers/weather/source.test.mjs b/src/layers/weather/source.test.mjs index 83ab24e76..ff69fce2f 100644 --- a/src/layers/weather/source.test.mjs +++ b/src/layers/weather/source.test.mjs @@ -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); +}); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a42485ed0..b86219ae4 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -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); +});