mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Add a Wind layer animating the global 10 m wind field from NOAA GFS. The /api/wind proxy selects the latest available 0.25° cycle, reads its .idx inventory, byte-range fetches only the UGRD/VGRD 10 m GRIB2 messages (~1 MB each instead of the whole ~500 MB file), decodes them with ecCodes (WASM), resamples to a compact grid (1° default), and serves a manifest plus a Float32 U/V payload cached per cycle for an hour (single-flight, last-good on failure). The client renders nullschool-style particles in a canvas overlay: bilinear field sampling with longitude wrap, per-frame advection, Cesium camera projection, and globe-occlusion culling via EllipsoidalOccluder. Validated end-to-end against the real 2026-09-14 06z cycle on Node 25 (180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|). The WASM decoder requires Node >=24, which the repo already requires. Registered as `wind` (token k) with attribution and docs. Verified: npm test (3412 pass, 0 fail), npm run check:boundaries.
64 lines
2.2 KiB
JavaScript
64 lines
2.2 KiB
JavaScript
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { promises as fs } from 'node:fs';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
|
|
function loadEccodes() {
|
|
eccodesPromise ??= (async () => {
|
|
const module = await import('@meri-imperiumi/eccodes-wasm');
|
|
return module.createEccodes();
|
|
})();
|
|
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 name = `wind-${randomUUID()}.grib2`;
|
|
const file = path.join(mountDir, name);
|
|
await fs.writeFile(file, buffer);
|
|
let handle = null;
|
|
try {
|
|
eccodes.mountFilesystem(mountDir);
|
|
handle = eccodes.openGrib(file);
|
|
return {
|
|
ni: handle.getLong('Ni'),
|
|
nj: handle.getLong('Nj'),
|
|
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
|
|
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
|
|
di: handle.getDouble('iDirectionIncrementInDegrees'),
|
|
dj: handle.getDouble('jDirectionIncrementInDegrees'),
|
|
values: handle.getDoubleArray('values'),
|
|
shortName: handle.getString('shortName'),
|
|
level: handle.getLong('level'),
|
|
units: handle.getString('units'),
|
|
};
|
|
} finally {
|
|
if (handle) handle.delete();
|
|
await fs.unlink(file).catch(() => {});
|
|
}
|
|
}
|