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
- Initial project version.
-1
View File
@@ -201,4 +201,3 @@ Douglas-Peucker simplification, 6-decimal rounding).
## 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.
+3
View File
@@ -1667,6 +1667,9 @@
"src/annotations/resolver.js",
"src/app/catalog.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/alprCameras.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';
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>}
*/
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
let eccodesPromise = null;
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
function loadEccodes() {
eccodesPromise ??= (async () => {
const module = await import('@meri-imperiumi/eccodes-wasm');
return module.createEccodes();
})();
eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm').then(module => module.createEccodes()).catch(error => {
eccodesPromise = null;
throw error;
});
return eccodesPromise;
}
/**
* Decode one GRIB2 wind message into its grid metadata and values.
*
* The WASM decoder reads from its own mounted filesystem, so the message is
* written to a temp file under `mountDir` and removed in `finally`.
*
* @param {Buffer} buffer - One GRIB2 message (already byte-range extracted).
* @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 });
/** Decode one bounded message in WASM memory without mounting the host filesystem. */
export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
if (buffer.byteLength > 8 * 1024 * 1024) throw new Error('Wind GRIB exceeds byte budget');
const eccodes = eccodesModule ?? await loadEccodes();
// 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.
const file = '/gev-wind.grib2';
let handle = null;
try {
eccodes.mountFilesystem(directory);
eccodes.writeFile(file, buffer);
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 {
ni: handle.getLong('Ni'),
nj: handle.getLong('Nj'),
ni, nj,
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
di: handle.getDouble('iDirectionIncrementInDegrees'),
@@ -56,7 +34,7 @@ export async function decodeWindGribMessage(
units: handle.getString('units'),
};
} finally {
if (handle) handle.delete();
await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
handle?.delete();
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 { 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 legend = controls?.legend || [];
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]) {
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();
}
@@ -267,7 +274,10 @@ export class LayerPanel {
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
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);
container.appendChild(entry);
}