From 430dcd4c5525dff9b9ed66c4a27981cce284bb8b Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:30:29 -0500 Subject: [PATCH] fix(wind): decode in bounded virtual storage and test real canvas retention --- CHANGELOG.md | 1 - DATA_SOURCES.md | 1 - scripts/package-boundaries.json | 3 ++ scripts/qa-wind-canvas.mjs | 44 ++++++++++++++++++++++++ server/providers/wind/decode.js | 60 +++++++++++---------------------- src/data/wind.js | 2 +- src/data/windDecode.test.mjs | 9 +++++ src/ui/layerPanel.js | 16 +++++++-- 8 files changed, 89 insertions(+), 47 deletions(-) create mode 100644 scripts/qa-wind-canvas.mjs create mode 100644 src/data/windDecode.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8892711cd..74d2412c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -901,4 +901,3 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. - diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8364515b5..73bbf4b84 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -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. - diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 534d643be..369dbda15 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-wind-canvas.mjs b/scripts/qa-wind-canvas.mjs new file mode 100644 index 000000000..f29f60e3e --- /dev/null +++ b/scripts/qa-wind-canvas.mjs @@ -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(); } diff --git a/server/providers/wind/decode.js b/server/providers/wind/decode.js index 7f137ae68..359368791 100644 --- a/server/providers/wind/decode.js +++ b/server/providers/wind/decode.js @@ -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} - */ +/** Lazy shared ecCodes WASM instance, created only for an active wind request. */ let eccodesPromise = null; - -/** @returns {Promise} 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, 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)); } } diff --git a/src/data/wind.js b/src/data/wind.js index 2a7a80894..2529003fb 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -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'; diff --git a/src/data/windDecode.test.mjs b/src/data/windDecode.test.mjs new file mode 100644 index 000000000..173ff97eb --- /dev/null +++ b/src/data/windDecode.test.mjs @@ -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); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ed96d9713..a7be82176 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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); }