merge: integrate GFS wind PR #459 into current catalog owners

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 02:16:35 -05:00
commit 93d4224fdf
28 files changed
+1192 -1

No files matched your search

+6
View File
@@ -227,6 +227,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
and are corrected with the calibration gizmo. `CCTV_CALGARY_MAX_SOURCES` sets
the cap and `CCTV_CALGARY_ENABLED=0` turns the pack off.
- Add a keyless **Wind** layer from NOAA GFS 10 m wind. The `/api/wind` proxy
byte-range fetches only the UGRD/VGRD GRIB2 messages from the public AWS bucket,
decodes them with ecCodes (WASM), and serves a compact Float32 U/V grid; the
client renders nullschool-style animated particles in a canvas overlay that
follows the Cesium camera and skips globe-occluded points. Forecast, not
observations. Requires Node ≥24 for the WASM decoder.
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
highway cameras, with server-registered still URLs and attribution.
- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh).
+15
View File
@@ -15,6 +15,7 @@ How to read this:
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **NOAA GFS (wind)** | Global 10 m wind field for the animated Wind layer | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
@@ -141,6 +142,20 @@ transaction quota. Requires a free `FIRMS_MAP_KEY`
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
### NOAA GFS wind
The optional **Wind** layer animates the global 10 m wind field from NOAA's
Global Forecast System (GFS). The `/api/wind` server-side 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° by
default), and serves a manifest plus a Float32 U/V payload. It is keyless, cached
per cycle for an hour, and is a **forecast, not an observation**: the particles
show model flow, not measured wind. Rendering is a 2D canvas particle overlay
advected by the field and projected through the Cesium camera; globe-occluded
particles are skipped. NOAA GFS data is U.S. public domain; the credit above is a
courtesy and does not imply endorsement.
### Natural Earth physical regions (`natural_earth/`)
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
+1
View File
@@ -2411,6 +2411,7 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
+19
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"mgrs": "^2.1.0",
@@ -1076,6 +1077,24 @@
"pbf": "^5.0.0"
}
},
"node_modules/@meri-imperiumi/eccodes-wasm": {
"version": "2.48.2",
"resolved": "https://registry.npmjs.org/@meri-imperiumi/eccodes-wasm/-/eccodes-wasm-2.48.2.tgz",
"integrity": "sha512-Z7rD3xInomPG7FS97ORD1UGKAdgeeRP9ofa9k4m8d+k2sDfDQeAjTzvs9UOndyaZovaSbEQeIoNX8gTmQNhiSA==",
"cpu": [
"x64",
"arm64"
],
"license": "Apache-2.0",
"os": [
"darwin",
"linux",
"win32"
],
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
"author": "Bilawal Sidhu",
@@ -47,6 +47,7 @@
},
"dependencies": {
"@mapbox/vector-tile": "^3.0.0",
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"mgrs": "^2.1.0",
+5
View File
@@ -1187,6 +1187,11 @@
"src/data/contactMatch.js",
"src/data/contextStore.js",
"src/data/dataCredits.js",
"src/data/wind.js",
"src/layers/wind/index.js",
"src/layers/wind/model.js",
"src/layers/wind/rendering.js",
"src/layers/wind/source.js",
"src/data/detection.js",
"src/data/detectionCohort.js",
"src/data/detectionDraw.js",
+2
View File
@@ -19,6 +19,7 @@ import { trackBackfillProxies } from './aircraft/tracks.js';
import { openAiRealtimeProxy } from './openai.js';
import { googlePlacesContextProxy } from './places.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { windProxy } from './wind.js';
/** Construct the local provider plugins in their established order. */
function localProviderPlugins() {
@@ -43,6 +44,7 @@ function localProviderPlugins() {
trackBackfillProxies(),
openAiRealtimeProxy(),
googlePlacesContextProxy(),
windProxy(),
keySetupEndpoint(),
];
}
+170
View File
@@ -0,0 +1,170 @@
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);
},
};
}
+16
View File
@@ -0,0 +1,16 @@
export const GFS_BUCKET = 'noaa-gfs-bdp-pds';
/** Build the public GFS object key. */
export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
const hh = String(hour).padStart(2, '0');
return `gfs.${date}/${hh}/atmos/gfs.t${hh}z.pgrb2.0p25.f${String(forecastHour).padStart(3, '0')}`;
}
/** Select the latest GFS cycle that should be available. */
export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } = {}) {
const d = new Date(nowMs - availabilityLagMs);
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const hour = Math.floor(d.getUTCHours() / 6) * 6;
date.setUTCHours(hour);
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
}
+63
View File
@@ -0,0 +1,63 @@
import os from 'node:os';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
/**
* Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm`
* module is only imported on first use so unit tests can inject a fake
* decoder without loading the WASM (which requires Node >= 24).
* @type {?Promise<object>}
*/
let eccodesPromise = null;
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
function loadEccodes() {
eccodesPromise ??= (async () => {
const module = await import('@meri-imperiumi/eccodes-wasm');
return module.createEccodes();
})();
return eccodesPromise;
}
/**
* Decode one GRIB2 wind message into its grid metadata and values.
*
* The WASM decoder reads from its own mounted filesystem, so the message is
* written to a temp file under `mountDir` and removed in `finally`.
*
* @param {Buffer} buffer - One GRIB2 message (already byte-range extracted).
* @param {{ eccodesModule?: object, mountDir?: string }} [options]
* @returns {Promise<{ni: number, nj: number, lo1: number, la1: number,
* di: number, dj: number, values: ArrayLike<number>, shortName: string,
* level: number, units: string}>}
*/
export async function decodeWindGribMessage(
buffer,
{ eccodesModule, mountDir = os.tmpdir() } = {},
) {
const eccodes = eccodesModule ?? (await loadEccodes());
const name = `wind-${randomUUID()}.grib2`;
const file = path.join(mountDir, name);
await fs.writeFile(file, buffer);
let handle = null;
try {
eccodes.mountFilesystem(mountDir);
handle = eccodes.openGrib(file);
return {
ni: handle.getLong('Ni'),
nj: handle.getLong('Nj'),
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
di: handle.getDouble('iDirectionIncrementInDegrees'),
dj: handle.getDouble('jDirectionIncrementInDegrees'),
values: handle.getDoubleArray('values'),
shortName: handle.getString('shortName'),
level: handle.getLong('level'),
units: handle.getString('units'),
};
} finally {
if (handle) handle.delete();
await fs.unlink(file).catch(() => {});
}
}
+89
View File
@@ -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;
}
+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 };
}
+3
View File
@@ -1,3 +1,4 @@
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -39,6 +40,7 @@ const SOURCE_METHODS = Object.freeze({
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
});
@@ -119,6 +121,7 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+7
View File
@@ -197,6 +197,13 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'wind-gfs',
html:
'Wind: NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
+1
View File
@@ -293,6 +293,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled-only' }),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
+13
View File
@@ -0,0 +1,13 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(createWindSource(), ['getSnapshot'], 'Wind source');
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { gfsObjectKey, selectLatestGfsCycle } from '../../server/providers/wind/catalog.js';
import { parseGfsIdx, windMessageRanges, fetchRange } from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }), 'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003');
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), { date: '20260914', hour: 18 });
const p = parseGfsIdx('1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z');
assert.deepEqual(windMessageRanges(p), { u: { start: 10, end: 19 }, v: { start: 20, end: 29 } });
const b = await fetchRange({ url: 'x', start: 0, end: 2, fetchImpl: async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }) });
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resampleWindGrid } from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({ ni: 4, nj: 3, lo1: 0, la1: 90, di: 90, dj: 90, u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], v: new Array(12).fill(1), dx: 90, dy: 90 });
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0); assert.equal(r.u[3], 3); assert.equal(r.u[4], 4);
});
+170
View File
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { windProxy } from '../../server/providers/wind.js';
/** Boot a mounted provider middleware without a real server. */
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
assert.equal(routes.size, 1);
const handler = [...routes.values()][0];
return async (url = '/', method = 'GET') => {
const res = {
statusCode: 200,
headers: {},
headersSent: false,
writeHead(status, headers) {
this.statusCode = status;
for (const [name, value] of Object.entries(headers || {}))
this.headers[name.toLowerCase()] = value;
this.headersSent = true;
},
end(body) {
this.body = body;
this.headersSent = true;
},
};
await handler({ url, method }, res);
return res;
};
}
const IDX = [
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
'3:10:d=2026091400:HGT:surface:anl:',
].join('\n');
const DECODED_U = {
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const DECODED_V = { ...DECODED_U, values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] };
/** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => {
if (counter) counter.calls += 1;
if (fail) throw new Error('gfs upstream down');
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
const length = Number(match[2]) - Number(match[1]) + 1;
return new Response(new Uint8Array(length), { status: 206 });
};
}
function proxy(options = {}) {
return windProxy({
now: () => Date.UTC(2026, 8, 14, 12),
targetDx: 90,
fetchImpl: makeFetch(),
decodeImpl: async (buffer) =>
buffer[0] === undefined ? DECODED_U : DECODED_U,
...options,
});
}
test('wind manifest describes the GFS cycle and resampled grid', async () => {
const request = install(proxy());
const res = await request('/');
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.body);
assert.equal(body.schemaVersion, 1);
assert.equal(body.model, 'gfs');
assert.equal(body.cycle.hour, 6);
assert.equal(body.cycle.date, '20260914');
assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 });
assert.match(body.gridUrl, /^\/api\/wind\/grid\/20260914-6-90\.bin$/);
assert.equal(body.stale, false);
});
test('wind grid route returns Float32 U then V', async () => {
let u = 0;
const request = install(
proxy({
decodeImpl: async () => {
u += 1;
return u === 1 ? DECODED_U : DECODED_V;
},
}),
);
const manifest = JSON.parse((await request('/')).body);
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-type'], 'application/octet-stream');
assert.equal(res.body.length, 4 * 3 * 4 * 2);
const floats = new Float32Array(
res.body.buffer,
res.body.byteOffset,
res.body.length / 4,
);
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
});
test('wind status omits the grid url', async () => {
const request = install(proxy());
await request('/');
const body = JSON.parse((await request('/status')).body);
assert.equal('gridUrl' in body, false);
assert.equal(body.model, 'gfs');
});
test('wind refresh is cached within the TTL and refetches after it', async () => {
const counter = { calls: 0 };
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
);
await request('/');
const afterFirst = counter.calls;
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
await request('/');
assert.equal(counter.calls, afterFirst, 'a second request inside the TTL must not refetch');
clock += 2 * 3600_000;
await request('/');
assert.equal(counter.calls, afterFirst + 3, 'a request past the TTL refetches');
});
test('wind serves last-good with stale on upstream failure', async () => {
let failing = false;
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({
now: () => clock,
fetchImpl: async (...args) => {
if (failing) throw new Error('gfs upstream down');
return makeFetch()(...args);
},
}),
);
const good = JSON.parse((await request('/')).body);
failing = true;
clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true);
assert.equal(degraded.reason, 'gfs upstream down');
const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
});
test('wind returns a JSON 404 for an unknown grid', async () => {
const request = install(proxy());
await request('/');
const res = await request('/grid/nope.bin');
assert.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
});
+50
View File
@@ -0,0 +1,50 @@
import * as Cesium from 'cesium';
import { createWindRendering } from './rendering.js';
/** Summarize a wind manifest for the layer status surface. */
export function windStats(manifest) {
return {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: manifest?.cycle?.runIso ? Date.parse(manifest.cycle.runIso) : manifest?.fetchedAt ?? null,
error: manifest?.unavailable ? manifest.reason || 'Wind unavailable' : manifest?.reason || null,
};
}
/** Create the GFS wind data layer. */
export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) {
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
let viewer = null;
let request = null;
let enabled = false;
let rendering = null;
let manifest = null;
let error = null;
const layer = {
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS', updateInterval: 3600_000,
init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); },
enable() { enabled = true; rendering?.start(); },
disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); },
async update(nextViewer, { signal } = {}) {
if (!enabled) return false;
request?.abort();
const controller = new AbortController();
request = controller;
try {
const snapshot = await feed.getSnapshot({ signal: signal || controller.signal });
if (!enabled || controller.signal.aborted || signal?.aborted) return false;
manifest = snapshot;
error = null;
if (!snapshot.unavailable) rendering.setField(snapshot);
return true;
} catch (cause) {
if (controller.signal.aborted || signal?.aborted) return false;
error = cause?.message || 'Wind source unavailable';
return true;
} finally { if (request === controller) request = null; }
},
destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; },
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; },
getParticleCount() { return rendering?.getParticleCount() || 0; },
};
return layer;
}
+60
View File
@@ -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 (bright from calm upward). */
export function windColor(speed, { max = 30 } = {}) {
const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171'];
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;
}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { advectParticle, sampleWind, windColor } from './model.js';
const field = { u: Float32Array.from([1, 2, 3, 4]), v: Float32Array.from([5, 6, 7, 8]), nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 90 };
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
});
test('advectParticle moves and constrains particles', () => {
const particle = { lon: 179, lat: 88 };
advectParticle(particle, { u: 10, v: 100 }, 3600);
assert.ok(particle.lon < 0);
assert.equal(particle.lat, 89);
});
test('windColor returns a monotonic ramp string', () => {
assert.equal(typeof windColor(0), 'string');
assert.notEqual(windColor(0), windColor(30));
});
+221
View File
@@ -0,0 +1,221 @@
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;
let warned = false;
const randomParticle = () => ({
lon: Math.random() * 360 - 180,
lat: Math.random() * 178 - 89,
age: Math.random() * 180,
});
/**
* Spawn a particle inside the current view. Prefers the camera's visible
* rectangle (robust at any zoom), then ellipsoid picking, then a whole-globe
* fallback. Seeding globally would leave almost nothing on screen at regional
* zoom, which reads as "the layer is broken".
*/
const spawnParticle = (scene) => {
const camera = scene?.camera;
if (camera?.computeViewRectangle && cesium.Ellipsoid && cesium.Math) {
try {
const rect = camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
if (rect) {
const west = cesium.Math.toDegrees(rect.west);
let east = cesium.Math.toDegrees(rect.east);
const south = cesium.Math.toDegrees(rect.south);
const north = cesium.Math.toDegrees(rect.north);
if (east < west) east += 360; // rectangle crosses the antimeridian
const lon = west + Math.random() * (east - west);
const lat = south + Math.random() * (north - south);
return {
lon: ((lon + 180) % 360 + 360) % 360 - 180,
lat: Math.max(-89, Math.min(89, lat)),
age: Math.random() * 30,
};
}
} catch {
/* fall through to the global fallback */
}
}
return randomParticle();
};
const budget = () =>
Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150)));
const seed = () => {
particles = [];
const limit = budget();
const scene = getViewer?.()?.scene;
for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene));
};
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) => {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
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;
try {
resize(viewer);
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
lastTime = time;
// Fade previous trails by ERASING them (destination-out). A translucent
// black fill would instead accumulate alpha and saturate the overlay.
context.globalCompositeOperation = 'destination-out';
context.fillStyle = 'rgba(0, 0, 0, 0.02)';
context.fillRect(0, 0, cssWidth, cssHeight);
if (!field) {
frame = globalThis.requestAnimationFrame(draw);
return;
}
const scene = viewer.scene;
// Real wind (m/s) is invisible at globe scale, so the advection is
// exaggerated in proportion to camera height: the same visual speed is
// legible whether the camera is at street level or over a whole hemisphere.
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
let occluder = null;
try {
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
occluder = new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC);
} catch {
occluder = null; // fall back to frustum-only culling
}
context.lineWidth = 1.8;
context.globalAlpha = 0.9;
// Normal (non-additive) blending: additive strokes saturate to white when
// many trails overlap over a dark globe.
context.globalCompositeOperation = 'source-over';
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 });
particle.age += dt;
// A NaN/Infinity position (polar divide-by-zero or a bad grid value)
// must never reach Cesium: it throws and would abort the whole frame.
if (!Number.isFinite(particle.lon) || !Number.isFinite(particle.lat)) {
Object.assign(particle, spawnParticle(scene));
continue;
}
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, spawnParticle(scene));
continue;
}
const previous = project(scene, occluder, before.lon, before.lat);
if (!previous) continue;
const speed = windSpeed(wind.u, wind.v);
context.strokeStyle = windColor(speed);
context.beginPath();
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
}
context.globalAlpha = 1;
} catch (error) {
if (!warned) {
console.warn('[Data:Wind] frame failed:', error?.message || error);
warned = true;
}
}
frame = globalThis.requestAnimationFrame(draw);
};
return {
/** Create and append the overlay canvas. */
attach() {
if (canvas) return;
canvas = document.createElement('canvas');
canvas.dataset.gevWind = '1';
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;
},
};
}
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindRendering } from './rendering.js';
/** Build a fake browser/Cesium environment for the overlay. */
function harness({ occluded = false } = {}) {
const strokes = [];
const context = {
setTransform() {},
clearRect() {},
fillRect() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {
strokes.push(true);
},
};
const canvas = {
style: {},
dataset: {},
width: 0,
height: 0,
clientWidth: 800,
clientHeight: 600,
getContext: () => context,
remove() {
this.removed = true;
},
};
const container = {
clientWidth: 800,
clientHeight: 600,
appendChild(node) {
this.node = node;
},
};
const callbacks = [];
globalThis.document = { createElement: () => canvas };
globalThis.requestAnimationFrame = (callback) => {
callbacks.push(callback);
return callbacks.length;
};
globalThis.cancelAnimationFrame = () => {
globalThis.cancelled = true;
};
const viewer = {
container,
scene: { canvas, camera: { positionWC: {} } },
isDestroyed: () => false,
};
const cesium = {
Cartesian3: { fromDegrees: (lon, lat) => ({ lon, lat }) },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class {
isPointVisible() {
return !occluded;
}
},
SceneTransforms: {
worldToWindowCoordinates: () => ({ x: 100, y: 100 }),
},
};
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
return { rendering, canvas, container, callbacks, strokes };
}
const FIELD = {
u: Float32Array.from([10]),
v: Float32Array.from([0]),
nx: 1,
ny: 1,
lo1: 0,
la1: 90,
dx: 360,
dy: 180,
};
test('wind rendering owns the canvas and particle lifecycle', () => {
const { rendering, canvas, callbacks, strokes } = harness();
rendering.attach();
assert.equal(typeof canvas.getContext('2d'), 'object');
rendering.setField(FIELD);
assert.ok(rendering.getParticleCount() >= 3000);
rendering.start();
callbacks.shift()(16);
assert.ok(strokes.length > 0, 'a driven frame draws trails');
rendering.stop();
assert.equal(globalThis.cancelled, true);
rendering.clear();
assert.equal(rendering.getParticleCount(), 0);
rendering.destroy();
rendering.destroy();
assert.equal(canvas.removed, true);
});
test('wind rendering starts before a field and still draws once one arrives', () => {
const { rendering, callbacks, strokes } = harness();
rendering.attach();
rendering.start();
// No field yet: the loop must keep scheduling instead of throwing.
const before = callbacks.shift();
before(16);
assert.equal(callbacks.length, 1, 'the loop reschedules without a field');
rendering.setField(FIELD);
callbacks.shift()(32);
assert.ok(strokes.length > 0, 'draws once the field is installed');
rendering.stop();
});
test('wind rendering skips particles hidden by the globe', () => {
const { rendering, callbacks, strokes } = harness({ occluded: true });
rendering.attach();
rendering.setField(FIELD);
rendering.start();
callbacks.shift()(16);
assert.equal(strokes.length, 0, 'no trail is drawn for an occluded point');
rendering.stop();
});
+33
View File
@@ -0,0 +1,33 @@
/** Create a source that loads the current GFS wind field. */
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/wind/manifest', {
signal,
cache: 'no-store',
});
signal?.throwIfAborted();
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
const manifest = await response.json();
signal?.throwIfAborted();
if (!manifest?.grid || !manifest.gridUrl)
throw new Error('Malformed wind manifest');
if (manifest.unavailable) return manifest;
const gridResponse = await fetchImpl(manifest.gridUrl, { signal });
signal?.throwIfAborted();
if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`);
const buffer = await gridResponse.arrayBuffer();
signal?.throwIfAborted();
const count = manifest.grid.nx * manifest.grid.ny;
if (buffer.byteLength !== count * 8)
throw new Error('Malformed wind grid');
const values = new Float32Array(buffer);
return {
...manifest,
u: values.slice(0, count),
v: values.slice(count),
};
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindSource } from './source.js';
const manifest = { grid: { nx: 2, ny: 1 }, gridUrl: '/grid.bin', unavailable: false };
const response = (body, ok = true, status = 200) => ({ ok, status, json: async () => body, arrayBuffer: async () => body });
test('wind source loads and splits a grid', async () => {
const buffer = Float32Array.from([1, 2, 3, 4]).buffer;
const source = createWindSource({ fetchImpl: async (url) => url.includes('manifest') ? response(manifest) : response(buffer) });
const result = await source.getSnapshot();
assert.deepEqual([...result.u], [1, 2]);
assert.deepEqual([...result.v], [3, 4]);
});
test('wind source handles unavailable and malformed responses', async () => {
let calls = 0;
const unavailable = { ...manifest, unavailable: true };
const source = createWindSource({ fetchImpl: async () => { calls++; return response(unavailable); } });
assert.equal((await source.getSnapshot()).unavailable, true);
assert.equal(calls, 1);
await assert.rejects(() => createWindSource({ fetchImpl: async () => response({}, false, 503) }).getSnapshot(), /Wind HTTP 503/);
await assert.rejects(() => createWindSource({ fetchImpl: async () => response(manifest) }).getSnapshot(), /Malformed wind grid/);
});
+2
View File
@@ -11,6 +11,7 @@ import { createInstallationSource } from '../layers/installations/source.js';
import { createSatelliteSource } from '../layers/satellites/source.js';
import { createLaunchSource } from '../layers/launches/source.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
import { createWindSource } from '../layers/wind/source.js';
import { createFirmsSource } from '../layers/firms/source.js';
import { createReferenceSources } from '../sources/reference.js';
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
@@ -33,5 +34,6 @@ export function createStandaloneLayerSources() {
launches: createLaunchSource(),
alpr: createOverpassAlprSource(),
firms: createFirmsSource(),
wind: createWindSource(),
};
}