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

+153
View File
@@ -0,0 +1,153 @@
import { advectParticle, sampleWind, windColor, windSpeed } from './model.js';
/**
* Canvas particle renderer for a global wind field.
*
* Particles are advected in lon/lat by the sampled field and projected to the
* screen through Cesium each frame. Particles hidden by the globe are skipped
* with an `EllipsoidalOccluder`, and trails fade into a translucent backdrop.
*
* @param {{cesium: object, container: HTMLElement, getViewer: Function}} options
*/
export function createWindRendering({ cesium, container, getViewer } = {}) {
let canvas = null;
let context = null;
let field = null;
let particles = [];
let frame = null;
let running = false;
let lastTime = 0;
let cssWidth = 1;
let cssHeight = 1;
const randomParticle = () => ({
lon: Math.random() * 360 - 180,
lat: Math.random() * 178 - 89,
age: Math.random() * 180,
});
const budget = () =>
Math.max(3000, Math.min(8000, Math.floor((cssWidth * cssHeight) / 1000)));
const seed = () => {
particles = [];
const limit = budget();
for (let i = 0; i < limit; i += 1) particles.push(randomParticle());
};
const resize = (viewer) => {
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
const sceneCanvas = viewer?.scene?.canvas;
cssWidth = sceneCanvas?.clientWidth || container?.clientWidth || 1;
cssHeight = sceneCanvas?.clientHeight || container?.clientHeight || 1;
canvas.width = Math.floor(cssWidth * ratio);
canvas.height = Math.floor(cssHeight * ratio);
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
};
/** Project lon/lat to CSS pixels, or null when hidden by the globe. */
const project = (scene, occluder, lon, lat) => {
const cartesian = cesium.Cartesian3.fromDegrees(lon, lat, 0);
if (occluder && !occluder.isPointVisible(cartesian)) return null;
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
};
const draw = (time) => {
frame = null;
if (!running) return;
const viewer = getViewer?.();
if (!viewer || viewer.isDestroyed?.()) return;
resize(viewer);
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
lastTime = time;
context.fillStyle = 'rgba(0, 0, 0, 0.08)';
context.fillRect(0, 0, cssWidth, cssHeight);
if (!field) {
frame = globalThis.requestAnimationFrame(draw);
return;
}
const scene = viewer.scene;
const occluder = cesium.EllipsoidalOccluder
? new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC)
: null;
for (const particle of particles) {
const wind = sampleWind(field, particle.lon, particle.lat);
const before = { lon: particle.lon, lat: particle.lat };
advectParticle(particle, wind, dt, { speedScale: 8 });
particle.age += dt;
const point = project(scene, occluder, particle.lon, particle.lat);
const offscreen =
!point ||
point.x < 0 ||
point.y < 0 ||
point.x > cssWidth ||
point.y > cssHeight;
if (particle.age > 240 || offscreen) {
Object.assign(particle, randomParticle());
continue;
}
const previous = project(scene, occluder, before.lon, before.lat);
if (!previous) continue;
context.strokeStyle = windColor(windSpeed(wind.u, wind.v));
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
}
frame = globalThis.requestAnimationFrame(draw);
};
return {
/** Create and append the overlay canvas. */
attach() {
if (canvas) return;
canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.inset = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '1';
context = canvas.getContext('2d');
container.appendChild(canvas);
},
/** Install a wind field and (re)seed particles. */
setField(next) {
field = next;
if (canvas) {
resize(getViewer?.());
seed();
}
},
/** Start the animation loop. Safe to call before a field is installed. */
start() {
if (running || !canvas) return;
running = true;
lastTime = 0;
frame = globalThis.requestAnimationFrame(draw);
},
/** Stop the animation loop. */
stop() {
running = false;
if (frame !== null) globalThis.cancelAnimationFrame(frame);
frame = null;
},
/** Clear the canvas and particles without removing the canvas. */
clear() {
particles = [];
context?.clearRect(0, 0, cssWidth, cssHeight);
},
/** Stop and remove the overlay canvas. Idempotent. */
destroy() {
this.stop();
this.clear();
canvas?.remove();
canvas = null;
context = null;
},
/** Test seam: the current particle count. */
getParticleCount() {
return particles.length;
},
};
}