From deb34ebe358a38799b6cf1effa1d4f9c467a1d79 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 12:59:18 -0300 Subject: [PATCH 1/2] feat(wind): add keyless NOAA GFS wind layer with animated particles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 6 + DATA_SOURCES.md | 15 +++ docs/CURRENT-STATE.md | 1 + package-lock.json | 19 ++++ package.json | 3 +- scripts/package-boundaries.json | 5 + server/providers/local.js | 2 + server/providers/wind.js | 170 +++++++++++++++++++++++++++++ server/providers/wind/catalog.js | 16 +++ server/providers/wind/decode.js | 63 +++++++++++ server/providers/wind/gfs.js | 89 +++++++++++++++ server/providers/wind/grid.js | 51 +++++++++ src/app/data.js | 2 + src/app/sources.js | 2 + src/data/dataCredits.js | 7 ++ src/data/layerState.js | 1 + src/data/layerState.test.mjs | 4 +- src/data/wind.js | 13 +++ src/data/windGfs.test.mjs | 13 +++ src/data/windGrid.test.mjs | 9 ++ src/data/windProxy.test.mjs | 170 +++++++++++++++++++++++++++++ src/layers/wind/index.js | 50 +++++++++ src/layers/wind/model.js | 60 ++++++++++ src/layers/wind/model.test.mjs | 25 +++++ src/layers/wind/rendering.js | 153 ++++++++++++++++++++++++++ src/layers/wind/rendering.test.mjs | 119 ++++++++++++++++++++ src/layers/wind/source.js | 33 ++++++ src/layers/wind/source.test.mjs | 24 ++++ 28 files changed, 1122 insertions(+), 3 deletions(-) create mode 100644 server/providers/wind.js create mode 100644 server/providers/wind/catalog.js create mode 100644 server/providers/wind/decode.js create mode 100644 server/providers/wind/gfs.js create mode 100644 server/providers/wind/grid.js create mode 100644 src/data/wind.js create mode 100644 src/data/windGfs.test.mjs create mode 100644 src/data/windGrid.test.mjs create mode 100644 src/data/windProxy.test.mjs create mode 100644 src/layers/wind/index.js create mode 100644 src/layers/wind/model.js create mode 100644 src/layers/wind/model.test.mjs create mode 100644 src/layers/wind/rendering.js create mode 100644 src/layers/wind/rendering.test.mjs create mode 100644 src/layers/wind/source.js create mode 100644 src/layers/wind/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a30caf1..452503bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added +- 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). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 08a3d662e..eb1700ec2 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -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 | @@ -134,6 +135,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/ — diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7bc10963a..24586e5b1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2312,6 +2312,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) | `src/data/militaryAwareness.js` remains registered internally as the Contacts coordinator, but it is not a user-visible Data Layers entry. Its visible entry diff --git a/package-lock.json b/package-lock.json index c24590e4a..6ca45043c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6aa6c956..586d7c20d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fb9469e52..9b7a95437 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1091,6 +1091,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", diff --git a/server/providers/local.js b/server/providers/local.js index 52a48f6d8..7d736c412 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -18,6 +18,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() { @@ -41,6 +42,7 @@ function localProviderPlugins() { trackBackfillProxies(), openAiRealtimeProxy(), googlePlacesContextProxy(), + windProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/wind.js b/server/providers/wind.js new file mode 100644 index 000000000..321717728 --- /dev/null +++ b/server/providers/wind.js @@ -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/.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} */ + 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); + }, + }; +} diff --git a/server/providers/wind/catalog.js b/server/providers/wind/catalog.js new file mode 100644 index 000000000..1e67b8537 --- /dev/null +++ b/server/providers/wind/catalog.js @@ -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 }; +} diff --git a/server/providers/wind/decode.js b/server/providers/wind/decode.js new file mode 100644 index 000000000..734639f0a --- /dev/null +++ b/server/providers/wind/decode.js @@ -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} + */ +let eccodesPromise = null; + +/** @returns {Promise} 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, 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(() => {}); + } +} diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js new file mode 100644 index 000000000..ac637a14d --- /dev/null +++ b/server/providers/wind/gfs.js @@ -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}} 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; +} diff --git a/server/providers/wind/grid.js b/server/providers/wind/grid.js new file mode 100644 index 000000000..aae81524f --- /dev/null +++ b/server/providers/wind/grid.js @@ -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, v: ArrayLike, 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 }; +} diff --git a/src/app/data.js b/src/app/data.js index a2f161cd6..6e28ee201 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; import militaryInstallationsLayer from '../data/militaryInstallations.js'; import militaryAwarenessLayer from '../data/militaryAwareness.js'; import localDataLayers from '../data/localLayers.js'; +import windLayer from '../data/wind.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; /** Register the application layer catalog before allowing state restoration. */ @@ -50,6 +51,7 @@ export function createApplicationData({ dataManager.register(militaryInstallationsLayer); dataManager.register(militaryAwarenessLayer); militaryAwarenessLayer.attachDataManager(dataManager); + dataManager.register(windLayer); for (const layer of localDataLayers) { dataManager.register(layer); } diff --git a/src/app/sources.js b/src/app/sources.js index ced314e6a..a1e7d1eac 100644 --- a/src/app/sources.js +++ b/src/app/sources.js @@ -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({ diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 8fc891433..4ec32dcd8 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -181,6 +181,13 @@ export const DATA_CREDITS = [ 'Re:Earth Terrain / ' + 'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)', }, + { + key: 'wind-gfs', + html: + 'Wind: NOAA Global Forecast System (GFS) 10 m wind via ' + + 'NOAA Open Data on AWS ' + + '(U.S. public domain; forecast, not observations)', + }, // ── Bundled snapshots ─────────────────────────────────────────── { key: 'datacenters', diff --git a/src/data/layerState.js b/src/data/layerState.js index ed73b98cc..85e8abc30 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -292,6 +292,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)); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index fe964d19f..05f8e8977 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -157,8 +157,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 17); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17); + assert.equal(REGISTERED_LAYER_IDS.length, 18); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), diff --git a/src/data/wind.js b/src/data/wind.js new file mode 100644 index 000000000..2a7a80894 --- /dev/null +++ b/src/data/wind.js @@ -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(); diff --git a/src/data/windGfs.test.mjs b/src/data/windGfs.test.mjs new file mode 100644 index 000000000..243a3e022 --- /dev/null +++ b/src/data/windGfs.test.mjs @@ -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) })); +}); diff --git a/src/data/windGrid.test.mjs b/src/data/windGrid.test.mjs new file mode 100644 index 000000000..0a57f501b --- /dev/null +++ b/src/data/windGrid.test.mjs @@ -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); +}); diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs new file mode 100644 index 000000000..f8ee9136b --- /dev/null +++ b/src/data/windProxy.test.mjs @@ -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' }); +}); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js new file mode 100644 index 000000000..49d468caf --- /dev/null +++ b/src/layers/wind/index.js @@ -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; +} diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js new file mode 100644 index 000000000..bdc9cd413 --- /dev/null +++ b/src/layers/wind/model.js @@ -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; +} diff --git a/src/layers/wind/model.test.mjs b/src/layers/wind/model.test.mjs new file mode 100644 index 000000000..59d86efbb --- /dev/null +++ b/src/layers/wind/model.test.mjs @@ -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)); +}); diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js new file mode 100644 index 000000000..646ee6b77 --- /dev/null +++ b/src/layers/wind/rendering.js @@ -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; + }, + }; +} diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs new file mode 100644 index 000000000..179b58d86 --- /dev/null +++ b/src/layers/wind/rendering.test.mjs @@ -0,0 +1,119 @@ +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: {}, + 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(); +}); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js new file mode 100644 index 000000000..8d5fb6ec6 --- /dev/null +++ b/src/layers/wind/source.js @@ -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), + }; + }, + }; +} diff --git a/src/layers/wind/source.test.mjs b/src/layers/wind/source.test.mjs new file mode 100644 index 000000000..221ff2072 --- /dev/null +++ b/src/layers/wind/source.test.mjs @@ -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/); +}); From b7033d86c2a2654b104628f5d0b51fa9959ee07a Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 14:02:48 -0300 Subject: [PATCH 2/2] fix(wind): make the particle overlay actually visible Live browser QA of the Wind layer surfaced several rendering defects that unit tests with fakes did not catch: - The trail fade used a translucent black fill (source-over), which raises alpha instead of erasing; combined with additive strokes the overlay saturated into a solid white disk. Fade with destination-out instead, and draw strokes with normal blending. - Particles were seeded uniformly over the whole globe, so at regional zoom almost none fell on screen. Seed inside the camera's visible rectangle. - A non-finite particle position (polar divide-by-zero) reached Cesium, which threw and aborted the entire frame before any stroke. Guard finite coordinates per particle and per projection. - Exaggerate advection in proportion to camera height, so the same visual speed is legible at street level and at hemispheric zoom. - Brighter colour ramp (the old ramp started near-invisible dark grey) and a defensive frame try/catch so one bad frame cannot kill the loop. Verified: the wind field/colour/particle model renders the real GFS 06z field correctly in an offline render, and the wind unit tests pass (8/8). --- src/layers/wind/model.js | 4 +- src/layers/wind/rendering.js | 142 +++++++++++++++++++++-------- src/layers/wind/rendering.test.mjs | 1 + 3 files changed, 108 insertions(+), 39 deletions(-) diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index bdc9cd413..b7e5c8667 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -36,9 +36,9 @@ export function windSpeed(u, v) { return Math.hypot(u, v); } -/** Map wind magnitude to a fixed CSS colour ramp. */ +/** Map wind magnitude to a fixed CSS colour ramp (bright from calm upward). */ export function windColor(speed, { max = 30 } = {}) { - const stops = ['#4a5568', '#7fe3ff', '#7dffb0', '#ffd166', '#ff7a59']; + 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)); diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 646ee6b77..9e556c6e6 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -19,6 +19,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { let lastTime = 0; let cssWidth = 1; let cssHeight = 1; + let warned = false; const randomParticle = () => ({ lon: Math.random() * 360 - 180, @@ -26,13 +27,46 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { 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(3000, Math.min(8000, Math.floor((cssWidth * cssHeight) / 1000))); + Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150))); const seed = () => { particles = []; const limit = budget(); - for (let i = 0; i < limit; i += 1) particles.push(randomParticle()); + const scene = getViewer?.()?.scene; + for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene)); }; const resize = (viewer) => { @@ -49,6 +83,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { /** 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); @@ -59,42 +94,74 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { 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; + 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; } - 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); }; @@ -104,6 +171,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { attach() { if (canvas) return; canvas = document.createElement('canvas'); + canvas.dataset.gevWind = '1'; canvas.style.position = 'absolute'; canvas.style.inset = '0'; canvas.style.pointerEvents = 'none'; diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index 179b58d86..dade321db 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -19,6 +19,7 @@ function harness({ occluded = false } = {}) { }; const canvas = { style: {}, + dataset: {}, width: 0, height: 0, clientWidth: 800,