fix(wind): decode in bounded virtual storage and test real canvas retention

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 02:30:29 -05:00
1 parent 26354d5ae1
commit 430dcd4c55
8 files changed
+89 -47

No files matched your search

-1
View File
@@ -901,4 +901,3 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09 ## [0.1.0] — 2026-02-09
- Initial project version. - Initial project version.
-1
View File
@@ -201,4 +201,3 @@ Douglas-Peucker simplification, 6-decimal rounding).
## In-app attribution ## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
+3
View File
@@ -1667,6 +1667,9 @@
"src/annotations/resolver.js", "src/annotations/resolver.js",
"src/app/catalog.js", "src/app/catalog.js",
"src/app/constructCatalog.js", "src/app/constructCatalog.js",
"src/layers/wind/index.js",
"src/layers/wind/model.js",
"src/layers/wind/rendering.js",
"src/app/layers/aisLiveVessels.js", "src/app/layers/aisLiveVessels.js",
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
+44
View File
@@ -0,0 +1,44 @@
/** Real-canvas wind regression. Run against this candidate's dev server. */
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), headless: false, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
try {
const page = await browser.newPage();
await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' });
const result = await page.evaluate(async () => {
const { createWindRendering } = await import('/src/layers/wind/rendering.js');
const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame;
const callbacks = new Map(); let sequence = 0;
window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; };
window.cancelAnimationFrame = id => callbacks.delete(id);
const container = document.createElement('div');
container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px;z-index:9999;background:#102030';
document.body.appendChild(container);
const sceneCanvas = { clientWidth: 800, clientHeight: 600 };
const viewer = { scene: { canvas: sceneCanvas, camera: {} }, isDestroyed: () => false };
const cesium = { Cartesian3: { fromDegrees: (lon,lat) => ({ lon,lat }) }, SceneTransforms: { worldToWindowCoordinates: (scene, point) => ({ x: 400 + point.lon, y: 300 + point.lat }) } };
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
try {
rendering.attach(); rendering.start();
const idleCallbacks = callbacks.size;
rendering.setField({ grid: { nx:1,ny:1,lo1:0,la1:90,dx:360,dy:180 }, u: new Float32Array([10]), v: new Float32Array([0]) });
const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d');
const tick = time => { const [id, callback] = callbacks.entries().next().value; callbacks.delete(id); callback(time); };
tick(16); // First camera signature clears the surface.
context.fillStyle = '#ff0000'; context.fillRect(10,10,5,5);
tick(32);
const alpha = context.getImageData(12,12,1,1).data[3];
const count = rendering.getParticleCount();
sceneCanvas.clientWidth = 320; sceneCanvas.clientHeight = 240;
tick(48);
const resizedCount = rendering.getParticleCount();
rendering.stop(); rendering.clear();
return { idleCallbacks, alpha, count, resizedCount, pending: callbacks.size, afterClear: context.getImageData(12,12,1,1).data[3] };
} finally { rendering.destroy(); container.remove(); window.requestAnimationFrame = raf; window.cancelAnimationFrame = caf; }
});
assert.equal(result.idleCallbacks, 0);
assert.ok(result.alpha > 200, 'unchanged canvas dimensions preserve existing trail pixels');
assert.ok(result.resizedCount < result.count);
assert.equal(result.pending, 0); assert.equal(result.afterClear, 0);
console.log(JSON.stringify(result));
} finally { await browser.close(); }
+19 -41
View File
@@ -1,51 +1,29 @@
import os from 'node:os'; /** Lazy shared ecCodes WASM instance, created only for an active wind request. */
import path from 'node:path';
import { promises as fs } from 'node:fs';
/**
* Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm`
* module is only imported on first use so unit tests can inject a fake
* decoder without loading the WASM (which requires Node >= 24).
* @type {?Promise<object>}
*/
let eccodesPromise = null; let eccodesPromise = null;
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
function loadEccodes() { function loadEccodes() {
eccodesPromise ??= (async () => { eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm').then(module => module.createEccodes()).catch(error => {
const module = await import('@meri-imperiumi/eccodes-wasm'); eccodesPromise = null;
return module.createEccodes(); throw error;
})(); });
return eccodesPromise; return eccodesPromise;
} }
/** /** Decode one bounded message in WASM memory without mounting the host filesystem. */
* Decode one GRIB2 wind message into its grid metadata and values. export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
* if (buffer.byteLength > 8 * 1024 * 1024) throw new Error('Wind GRIB exceeds byte budget');
* The WASM decoder reads from its own mounted filesystem, so the message is const eccodes = eccodesModule ?? await loadEccodes();
* written to a temp file under `mountDir` and removed in `finally`. // Everything after this await is synchronous. A shared virtual file is safe
* // across U/V and model requests and its storage is released after each decode.
* @param {Buffer} buffer - One GRIB2 message (already byte-range extracted). const file = '/gev-wind.grib2';
* @param {{ eccodesModule?: object, mountDir?: string }} [options]
* @returns {Promise<{ni: number, nj: number, lo1: number, la1: number,
* di: number, dj: number, values: ArrayLike<number>, shortName: string,
* level: number, units: string}>}
*/
export async function decodeWindGribMessage(
buffer,
{ eccodesModule, mountDir = os.tmpdir() } = {},
) {
const eccodes = eccodesModule ?? (await loadEccodes());
const directory = await fs.mkdtemp(path.join(mountDir, 'gev-wind-'));
const file = path.join(directory, 'wind.grib2');
await fs.writeFile(file, buffer, { mode: 0o600 });
let handle = null; let handle = null;
try { try {
eccodes.mountFilesystem(directory); eccodes.writeFile(file, buffer);
handle = eccodes.openGrib(file); handle = eccodes.openGrib(file);
const ni = handle.getLong('Ni');
const nj = handle.getLong('Nj');
if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000) throw new Error('Invalid wind GRIB geometry');
return { return {
ni: handle.getLong('Ni'), ni, nj,
nj: handle.getLong('Nj'),
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'), lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'), la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
di: handle.getDouble('iDirectionIncrementInDegrees'), di: handle.getDouble('iDirectionIncrementInDegrees'),
@@ -56,7 +34,7 @@ export async function decodeWindGribMessage(
units: handle.getString('units'), units: handle.getString('units'),
}; };
} finally { } finally {
if (handle) handle.delete(); handle?.delete();
await fs.rm(directory, { recursive: true, force: true }).catch(() => {}); eccodes.writeFile(file, new Uint8Array(0));
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js'; import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js'; import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js'; import { createWindSource } from '../layers/wind/source.js';
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+13 -3
View File
@@ -226,11 +226,18 @@ export class LayerPanel {
const chips = controls?.chips || []; const chips = controls?.chips || [];
const legend = controls?.legend || []; const legend = controls?.legend || [];
this._syncRowList(listContainer, controls?.list || null); this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0 && !controls?.info; container.hidden =
chips.length === 0 && legend.length === 0 && !controls?.info;
for (const node of [...container.children]) { for (const node of [...container.children]) {
if ( if (
String(node.className).split(/\s+/).some((name) => ['data-toggle-legend-item', 'data-toggle-controls-info'].includes(name)) String(node.className)
.split(/\s+/)
.some((name) =>
['data-toggle-legend-item', 'data-toggle-controls-info'].includes(
name,
),
)
) )
node.remove(); node.remove();
} }
@@ -267,7 +274,10 @@ export class LayerPanel {
swatch.className = 'data-toggle-legend-swatch'; swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color; swatch.style.background = item.color;
const text = document.createElement('span'); const text = document.createElement('span');
text.textContent = item.count == null ? String(item.label) : `${item.label} ${this._formatCount(item.count)}`; text.textContent =
item.count == null
? String(item.label)
: `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text); entry.append(swatch, text);
container.appendChild(entry); container.appendChild(entry);
} }