mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
844c25212c
commit
deb34ebe35
28 files changed
+1122
-3
No files matched your search
@@ -0,0 +1,60 @@
|
||||
/** Normalize a longitude into the half-open world interval. */
|
||||
export function normalizeLongitude(lon) {
|
||||
return ((lon + 180) % 360 + 360) % 360 - 180;
|
||||
}
|
||||
|
||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||
export function sampleWind(field, lon, lat) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return { u: 0, v: 0 };
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = (la1 - lat) / dy;
|
||||
const x0 = Math.floor(x);
|
||||
const tx = x - x0;
|
||||
const yClamped = Math.max(0, Math.min(ny - 1, y));
|
||||
const y0 = Math.floor(yClamped);
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const ty = yClamped - y0;
|
||||
const column = (index) => ((index % nx) + nx) % nx;
|
||||
const value = (array) => {
|
||||
const a = array[y0 * nx + column(x0)];
|
||||
const b = array[y0 * nx + column(x0 + 1)];
|
||||
const c = array[y1 * nx + column(x0)];
|
||||
const d = array[y1 * nx + column(x0 + 1)];
|
||||
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
};
|
||||
return { u: value(u), v: value(v) };
|
||||
}
|
||||
|
||||
/** Return metres represented by one longitude degree at a latitude. */
|
||||
export function metersPerDegreeLon(lat) {
|
||||
return 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** Calculate wind magnitude. */
|
||||
export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp. */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = ['#4a5568', '#7fe3ff', '#7dffb0', '#ffd166', '#ff7a59'];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
const fraction = position - index;
|
||||
if (t === 1) return stops.at(-1);
|
||||
const parse = (color) => color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||
const a = parse(stops[index]);
|
||||
const b = parse(stops[index + 1]);
|
||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
||||
}
|
||||
|
||||
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||
export function advectParticle(particle, wind, dtSeconds, { speedScale = 1 } = {}) {
|
||||
particle.lon = normalizeLongitude(
|
||||
particle.lon + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||
);
|
||||
particle.lat = Math.max(-89, Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320));
|
||||
return particle;
|
||||
}
|
||||
Reference in new issue
Block a user