feat(wind): add keyless NOAA GFS wind layer with animated particles

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.
This commit is contained in:
Gustavo Beneduzi committed 2026-09-14 12:59:18 -03:00
1 parent 844c25212c
commit deb34ebe35
28 files changed
+1122 -3

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
/**
* Resample a GRIB wind grid into a compact equirectangular grid.
*
* Source grids are north-to-south (`la1` at the top, `dj` increasing toward the
* south) and start at `lo1`. Longitude wraps across the seam; latitude clamps.
*
* @param {{u: ArrayLike<number>, v: ArrayLike<number>, ni: number, nj: number,
* lo1: number, la1: number, di: number, dj: number, dx?: number, dy?: number}} input
* @returns {{nx: number, ny: number, lo1: number, la1: number, dx: number,
* dy: number, u: Float32Array, v: Float32Array}}
*/
export function resampleWindGrid({
u,
v,
ni,
nj,
lo1,
la1,
di,
dj,
dx = 1,
dy = 1,
}) {
const nx = Math.round(360 / dx);
const ny = Math.round(180 / dy) + 1;
const outU = new Float32Array(nx * ny);
const outV = new Float32Array(nx * ny);
const sample = (source, lon, lat) => {
const x = ((lon - lo1) / di + ni) % ni;
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
const x0 = Math.floor(x);
const x1 = (x0 + 1) % ni;
const y0 = Math.floor(y);
const y1 = Math.min(nj - 1, y0 + 1);
const fx = x - x0;
const fy = y - y0;
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
return top * (1 - fy) + bottom * fy;
};
for (let y = 0; y < ny; y += 1) {
for (let x = 0; x < nx; x += 1) {
const index = y * nx + x;
const lon = x * dx;
const lat = 90 - y * dy;
outU[index] = sample(u, lon, lat);
outV[index] = sample(v, lon, lat);
}
}
return { nx, ny, lo1: 0, la1: 90, dx, dy, u: outU, v: outV };
}