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.
171 lines
5.2 KiB
JavaScript
171 lines
5.2 KiB
JavaScript
import {
|
|
GFS_BUCKET,
|
|
gfsObjectKey,
|
|
selectLatestGfsCycle,
|
|
} from './wind/catalog.js';
|
|
import {
|
|
fetchText,
|
|
fetchRange,
|
|
parseGfsIdx,
|
|
windMessageRanges,
|
|
} from './wind/gfs.js';
|
|
import { decodeWindGribMessage } from './wind/decode.js';
|
|
import { resampleWindGrid } from './wind/grid.js';
|
|
|
|
/**
|
|
* NOAA GFS 10 m wind proxy.
|
|
*
|
|
* Selects the latest available GFS cycle, reads its `.idx` inventory, byte-range
|
|
* fetches only the UGRD/VGRD 10 m GRIB2 messages, decodes them with ecCodes
|
|
* (WASM), resamples to a compact grid, and serves a manifest plus a Float32
|
|
* grid (U values then V values). Keyless; cached per cycle for an hour.
|
|
*
|
|
* Routes (mounted at `/api/wind`):
|
|
* GET /api/wind/manifest → manifest JSON
|
|
* GET /api/wind/grid/<id>.bin → Float32 U…V payload
|
|
* GET /api/wind/status → manifest without `gridUrl`
|
|
*
|
|
* @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function,
|
|
* targetDx?: number, ttlMs?: number}} [options]
|
|
* @returns {import('vite').Plugin}
|
|
*/
|
|
export function windProxy({
|
|
fetchImpl = fetch,
|
|
now = () => Date.now(),
|
|
decodeImpl = decodeWindGribMessage,
|
|
targetDx = 1,
|
|
ttlMs = 3600_000,
|
|
} = {}) {
|
|
/** @type {?{id: string, idGrid: string, fetchedAt: number, manifest: object, grid: ?object}} */
|
|
let cached = null;
|
|
/** @type {?Promise<object>} */
|
|
let loading = null;
|
|
|
|
const sendJson = (res, value, status = 200) => {
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(value));
|
|
};
|
|
|
|
const cycleRunIso = (cycle) =>
|
|
`${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`;
|
|
|
|
async function refresh() {
|
|
const cycle = selectLatestGfsCycle(now());
|
|
const id = `${cycle.date}-${cycle.hour}`;
|
|
if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs)
|
|
return cached;
|
|
if (loading) return loading;
|
|
loading = (async () => {
|
|
try {
|
|
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
|
|
const index = await fetchText({ url: `${base}.idx`, fetchImpl });
|
|
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
|
const [uBuffer, vBuffer] = await Promise.all([
|
|
fetchRange({ url: base, ...ranges.u, fetchImpl }),
|
|
fetchRange({ url: base, ...ranges.v, fetchImpl }),
|
|
]);
|
|
const [u, v] = await Promise.all([
|
|
decodeImpl(uBuffer),
|
|
decodeImpl(vBuffer),
|
|
]);
|
|
const grid = resampleWindGrid({
|
|
u: u.values,
|
|
v: v.values,
|
|
ni: u.ni,
|
|
nj: u.nj,
|
|
lo1: u.lo1,
|
|
la1: u.la1,
|
|
di: u.di,
|
|
dj: u.dj,
|
|
dx: targetDx,
|
|
dy: targetDx,
|
|
});
|
|
const idGrid = `${id}-${targetDx}`;
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
model: 'gfs',
|
|
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) },
|
|
fetchedAt: now(),
|
|
level: '10 m above ground',
|
|
units: 'm/s',
|
|
grid: {
|
|
nx: grid.nx,
|
|
ny: grid.ny,
|
|
lo1: grid.lo1,
|
|
la1: grid.la1,
|
|
dx: grid.dx,
|
|
dy: grid.dy,
|
|
},
|
|
stale: false,
|
|
unavailable: false,
|
|
reason: null,
|
|
gridUrl: `/api/wind/grid/${idGrid}.bin`,
|
|
};
|
|
cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid };
|
|
return cached;
|
|
} catch (error) {
|
|
if (cached) {
|
|
cached = {
|
|
...cached,
|
|
manifest: { ...cached.manifest, stale: true, reason: error.message },
|
|
};
|
|
} else {
|
|
cached = {
|
|
id: null,
|
|
idGrid: null,
|
|
fetchedAt: now(),
|
|
grid: null,
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
model: 'gfs',
|
|
stale: true,
|
|
unavailable: true,
|
|
reason: error.message,
|
|
},
|
|
};
|
|
}
|
|
return cached;
|
|
} finally {
|
|
loading = null;
|
|
}
|
|
})();
|
|
return loading;
|
|
}
|
|
|
|
const handler = async (req, res) => {
|
|
const path = new URL(req.url, 'http://localhost').pathname;
|
|
const state = await refresh();
|
|
if (path === '/status') {
|
|
const { gridUrl, ...manifest } = state.manifest;
|
|
return sendJson(res, manifest);
|
|
}
|
|
if (path.startsWith('/grid/')) {
|
|
if (!state.grid || path !== `/grid/${state.idGrid}.bin`)
|
|
return sendJson(res, { error: 'unknown_grid' }, 404);
|
|
const payload = Buffer.concat([
|
|
Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength),
|
|
Buffer.from(state.grid.v.buffer, state.grid.v.byteOffset, state.grid.v.byteLength),
|
|
]);
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Cache-Control': 'public, max-age=3600, immutable',
|
|
});
|
|
return res.end(payload);
|
|
}
|
|
return sendJson(res, state.manifest);
|
|
};
|
|
|
|
return {
|
|
name: 'wind',
|
|
configureServer({ middlewares }) {
|
|
middlewares.use('/api/wind', handler);
|
|
},
|
|
configurePreviewServer({ middlewares }) {
|
|
middlewares.use('/api/wind', handler);
|
|
},
|
|
};
|
|
}
|