mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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,89 @@
|
||||
/**
|
||||
* Parse a GFS `.idx` inventory into ordered message records.
|
||||
*
|
||||
* Each line is `msgNumber:byteOffset:d=YYYYMMDDHH:VAR:LEVEL:...:`. Malformed
|
||||
* lines are skipped; a payload with no valid lines is rejected so callers never
|
||||
* treat an HTML error page as an empty inventory.
|
||||
*
|
||||
* @param {string} text - Raw `.idx` body.
|
||||
* @returns {{ messages: Array<{index: number, offset: number, variable: string, level: string}> }}
|
||||
*/
|
||||
export function parseGfsIdx(text) {
|
||||
const messages = [];
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const parts = line.split(':');
|
||||
if (
|
||||
parts.length < 6 ||
|
||||
!/^\d+$/.test(parts[0]) ||
|
||||
!/^\d+$/.test(parts[1]) ||
|
||||
!parts[3] ||
|
||||
!parts[4]
|
||||
)
|
||||
continue;
|
||||
messages.push({
|
||||
index: Number(parts[0]),
|
||||
offset: Number(parts[1]),
|
||||
variable: parts[3],
|
||||
level: parts[4],
|
||||
});
|
||||
}
|
||||
if (!messages.length) throw new Error('malformed GFS index');
|
||||
return { messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte ranges for the two 10 m wind components. A message runs from its own
|
||||
* offset to one byte before the next message in file order.
|
||||
*
|
||||
* @param {{messages: Array<object>}} parsed - {@link parseGfsIdx} result.
|
||||
* @param {{level?: string}} [options]
|
||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||
*/
|
||||
export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) {
|
||||
const range = (variable) => {
|
||||
const index = parsed.messages.findIndex(
|
||||
(message) => message.variable === variable && message.level === level,
|
||||
);
|
||||
const message = parsed.messages[index];
|
||||
const next = parsed.messages[index + 1];
|
||||
if (!message || !next) throw new Error(`missing ${variable} at ${level}`);
|
||||
return { start: message.offset, end: next.offset - 1 };
|
||||
};
|
||||
return { u: range('UGRD'), v: range('VGRD') };
|
||||
}
|
||||
|
||||
/** Fetch a bounded text response as a Buffer. */
|
||||
export async function fetchText({ url, fetchImpl = fetch, signal }) {
|
||||
const response = await fetchImpl(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one bounded byte range. Rejects oversized ranges, non-206 responses
|
||||
* (except a whole-object 200 starting at zero), and short bodies.
|
||||
*/
|
||||
export async function fetchRange({
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
fetchImpl = fetch,
|
||||
signal,
|
||||
maxBytes = 8 * 1024 * 1024,
|
||||
}) {
|
||||
if (end < start || end - start + 1 > maxBytes)
|
||||
throw new Error('range too large');
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
headers: { Range: `bytes=${start}-${end}` },
|
||||
});
|
||||
const validStatus =
|
||||
response.status === 206 || (response.status === 200 && start === 0);
|
||||
if (!validStatus || !response.ok)
|
||||
throw new Error(`invalid range response ${response.status}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length !== end - start + 1)
|
||||
throw new Error('invalid range length');
|
||||
return buffer;
|
||||
}
|
||||
Reference in new issue
Block a user