mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
|
## [0.1.0] — 2026-02-09
|
||||||
|
|
||||||
- Initial project version.
|
- Initial project version.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
/** 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
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user