mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
fix(wind): decode in bounded virtual storage and test real canvas retention
This commit is contained in:
1 parent
26354d5ae1
commit
430dcd4c55
8 files changed
+89
-47
No files matched your search
@@ -901,4 +901,3 @@ represent previously published GitHub Releases.
|
||||
## [0.1.0] — 2026-02-09
|
||||
|
||||
- Initial project version.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user