From deb34ebe358a38799b6cf1effa1d4f9c467a1d79 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 12:59:18 -0300 Subject: [PATCH 01/11] 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 bb18e39b7d393a887386803d822abd60923a6ea4 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 13:04:35 -0300 Subject: [PATCH 02/11] feat(wind): add ECMWF IFS as a second wind model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ECMWF IFS 10 m wind alongside NOAA GFS, selectable at runtime. The /api/wind proxy becomes a model dispatcher: `?model=gfs|ifs` (default gfs, unknown → 400), cached per model+cycle+grid. IFS reads the keyless ECMWF Open Data JSON Lines `.index` (note: `.index`, not `.index`), byte-range fetches only the `10u`/`10v` messages, decodes them with the same ecCodes WASM path, and resamples with the existing grid code (ECMWF grids start at lo1=180; the resampler already wraps columns). The client layer gains a `model` parameter (setParams/getParams) with a generation guard so a model switch aborts and refetches exactly once. Registered as an enabled+options layer with a `model` enum. Validated end-to-end against the real 2026-09-14 06z IFS cycle on Node 25 (180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|), and GFS still passes the same check. Attribution: contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. Verified: npm test (3412 pass, 0 fail), check:boundaries. --- CHANGELOG.md | 2 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 2 + server/providers/wind.js | 194 +++++++---------------------------- server/providers/wind/gfs.js | 19 ++++ server/providers/wind/ifs.js | 122 ++++++++++++++++++++++ src/data/dataCredits.js | 7 ++ src/data/layerState.js | 5 +- src/layers/wind/index.js | 23 ++++- src/layers/wind/source.js | 6 +- 10 files changed, 216 insertions(+), 166 deletions(-) create mode 100644 server/providers/wind/ifs.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 452503bb1..24a6f3ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -655,3 +655,5 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + +- Add ECMWF IFS as a selectable wind model alongside GFS. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index eb1700ec2..cf466553a 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -190,3 +190,5 @@ Douglas-Peucker simplification, 6-decimal rounding). ## In-app attribution The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. + +- ECMWF IFS: keyless CC BY 4.0 GRIB2 forecast data fetched via `.index` and HTTP Range. Attribution: "Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. Source: https://data.ecmwf.int/forecasts/". diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 24586e5b1..4b9a4da3e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3402,3 +3402,5 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending work and releases owned resources, including late factory results. Supplied 3D tilesets remain owned by the caller; tilesets created through the controller's factory are added to its viewer and removed on destruction. + +Wind supports GFS/IFS models. diff --git a/server/providers/wind.js b/server/providers/wind.js index 321717728..13dc0447e 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -1,170 +1,48 @@ -import { - GFS_BUCKET, - gfsObjectKey, - selectLatestGfsCycle, -} from './wind/catalog.js'; -import { - fetchText, - fetchRange, - parseGfsIdx, - windMessageRanges, -} from './wind/gfs.js'; +import { fetchGfsWind } from './wind/gfs.js'; +import { fetchIfsWind } from './wind/ifs.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 () => { +/** Dispatch cached wind requests by model and cycle. */ +export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) { + const caches = new Map(); + const loadings = new Map(); + const sendJson = (res, value, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)); }; + const refresh = (model) => { + const loader = models[model]; + const options = { fetchImpl, now, decodeImpl, targetDx }; + const promise = (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 value = await loader(options); + const id = `${value.cycle.date}-${value.cycle.hour}`; 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; + const manifest = { schemaVersion: 1, model, cycle: value.cycle, fetchedAt: now(), level: value.level, units: value.units, grid: { nx: value.grid.nx, ny: value.grid.ny, lo1: value.grid.lo1, la1: value.grid.la1, dx: value.grid.dx, dy: value.grid.dy }, stale: false, unavailable: false, reason: null, gridUrl: `/api/wind/grid/${idGrid}.bin` }; + const state = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid: value.grid }; + caches.set(model, state); + return state; } 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; - } + const old = caches.get(model); + if (old) { old.manifest = { ...old.manifest, stale: true, reason: error.message }; return old; } + return { id: null, idGrid: null, grid: null, manifest: { schemaVersion: 1, model, stale: true, unavailable: true, reason: error.message } }; + } finally { loadings.delete(model); } })(); - return loading; - } - + loadings.set(model, promise); + return promise; + }; 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', - }); + const url = new URL(req.url, 'http://localhost'); + const model = url.searchParams.get('model') || 'gfs'; + if (!models[model]) return sendJson(res, { error: 'unknown_model' }, 400); + let state = caches.get(model); + if (!state || now() - state.fetchedAt >= ttlMs) state = loadings.get(model) || refresh(model); + state = await state; + if (url.pathname === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); } + if (url.pathname.startsWith('/grid/')) { + if (!state.grid || url.pathname !== `/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); - }, - }; + return { name: 'wind', configureServer({ middlewares }) { middlewares.use('/api/wind', handler); }, configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); } }; } diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index ac637a14d..6caee82b3 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -87,3 +87,22 @@ export async function fetchRange({ throw new Error('invalid range length'); return buffer; } + +import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle } from './catalog.js'; +import { decodeWindGribMessage } from './decode.js'; +import { resampleWindGrid } from './grid.js'; + +/** Fetch and decode the latest GFS 10 m wind field. */ +export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage } = {}) { + const cycle = selectLatestGfsCycle(now()); + 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 }); + return { cycle: { ...cycle, forecastHour: 0, runIso: `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z` }, level: '10 m above ground', units: 'm/s', grid }; +} diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js new file mode 100644 index 000000000..450a83827 --- /dev/null +++ b/server/providers/wind/ifs.js @@ -0,0 +1,122 @@ +import { fetchText, fetchRange } from './gfs.js'; +import { decodeWindGribMessage } from './decode.js'; +import { resampleWindGrid } from './grid.js'; + +/** Keyless ECMWF Open Data root for real-time IFS forecasts. */ +export const IFS_BASE = 'https://data.ecmwf.int/forecasts'; + +/** + * Select the latest IFS cycle that should be available. + * @param {number} nowMs - Reference epoch milliseconds. + * @param {{availabilityLagMs?: number}} [options] + * @returns {{date: string, hour: number}} + */ +export function selectLatestIfsCycle( + nowMs, + { availabilityLagMs = 6 * 3600_000 } = {}, +) { + const shifted = new Date(nowMs - availabilityLagMs); + return { + date: shifted.toISOString().slice(0, 10).replaceAll('-', ''), + hour: Math.floor(shifted.getUTCHours() / 6) * 6, + }; +} + +/** + * Build the IFS GRIB2 and `.index` URLs for one cycle and step. + * @param {{date: string, hour: number, step?: number}} cycle + * @returns {{grib: string, index: string}} + */ +export function ifsObjectUrls({ date, hour, step = 0 }) { + const hh = String(hour).padStart(2, '0'); + const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`; + // The inventory sits beside the GRIB2 as `.index`, NOT `.index`. + return { grib: `${stem}.grib2`, index: `${stem}.index` }; +} + +/** + * Parse the IFS JSON Lines inventory into `{param, offset, length}` records. + * Malformed lines are skipped; a payload with no valid lines is rejected. + * @param {string} text - Raw `.index` body. + * @returns {Array<{param: string, offset: number, length: number}>} + */ +export function parseIfsIndex(text) { + const entries = []; + for (const line of String(text).split(/\r?\n/)) { + if (!line) continue; + let value; + try { + value = JSON.parse(line); + } catch { + continue; // non-JSON line in the index + } + if ( + value?.param && + Number.isFinite(value._offset) && + Number.isFinite(value._length) + ) + entries.push({ + param: value.param, + offset: value._offset, + length: value._length, + }); + } + if (!entries.length) throw new Error('malformed IFS index'); + return entries; +} + +/** + * Byte ranges for the IFS 10 m wind components. + * @param {Array<{param: string, offset: number, length: number}>} entries + * @returns {{u: {start: number, end: number}, v: {start: number, end: number}}} + */ +export function ifsWindRanges(entries) { + const range = (param) => { + const item = entries.find((entry) => entry.param === param); + if (!item) throw new Error(`missing ${param}`); + return { start: item.offset, end: item.offset + item.length - 1 }; + }; + return { u: range('10u'), v: range('10v') }; +} + +/** + * Fetch and decode the latest IFS 10 m wind field. + * @param {{fetchImpl?: Function, now?: Function, targetDx?: number, + * decodeImpl?: Function}} [options] + * @returns {Promise<{cycle: object, level: string, units: string, grid: object}>} + */ +export async function fetchIfsWind({ + fetchImpl = fetch, + now = () => Date.now(), + targetDx = 1, + decodeImpl = decodeWindGribMessage, +} = {}) { + const cycle = selectLatestIfsCycle(now()); + const urls = ifsObjectUrls(cycle); + const index = await fetchText({ url: urls.index, fetchImpl }); + const ranges = ifsWindRanges(parseIfsIndex(index.toString())); + const [uBuffer, vBuffer] = await Promise.all([ + fetchRange({ url: urls.grib, ...ranges.u, fetchImpl }), + fetchRange({ url: urls.grib, ...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 runIso = `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`; + return { + cycle: { ...cycle, forecastHour: 0, runIso }, + level: '10 m above ground', + units: 'm/s', + grid, + }; +} diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 4ec32dcd8..c6a555de9 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -188,6 +188,13 @@ export const DATA_CREDITS = [ 'NOAA Open Data on AWS ' + '(U.S. public domain; forecast, not observations)', }, + { + key: 'ecmwf-ifs', + html: + 'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' + + 'Source: https://data.ecmwf.int/forecasts/ ' + + '(ECMWF Open Data)', + }, // ── Bundled snapshots ─────────────────────────────────────────── { key: 'datacenters', diff --git a/src/data/layerState.js b/src/data/layerState.js index 85e8abc30..0b492fd51 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -182,6 +182,9 @@ function integerOption(key, token, defaultValue) { } const OPTION_GROUPS = Object.freeze({ + wind: Object.freeze([ + enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }), + ]), flights: Object.freeze([ // Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in // PROXIMITY mode. Proximity is itself the altitude/count gate — models only @@ -292,7 +295,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' }), + Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }), ]); export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id)); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 49d468caf..bf0038a8f 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -10,7 +10,7 @@ export function windStats(manifest) { }; } -/** Create the GFS wind data layer. */ +/** Create the GFS/IFS 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; @@ -19,8 +19,10 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = let rendering = null; let manifest = null; let error = null; + let model = 'gfs'; + let generation = 0; const layer = { - id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS', updateInterval: 3600_000, + id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS / ECMWF IFS', 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(); }, @@ -30,7 +32,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = const controller = new AbortController(); request = controller; try { - const snapshot = await feed.getSnapshot({ signal: signal || controller.signal }); + const snapshot = await feed.getSnapshot({ signal: signal || controller.signal, model }); if (!enabled || controller.signal.aborted || signal?.aborted) return false; manifest = snapshot; error = null; @@ -42,8 +44,21 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = return true; } finally { if (request === controller) request = null; } }, + setParams(params = {}) { + if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return; + model = params.model; + generation += 1; + if (enabled) { + request?.abort(); + const currentGeneration = generation; + queueMicrotask(() => { + if (enabled && currentGeneration === generation) layer.update(viewer); + }); + } + }, + getParams() { return { model }; }, destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; }, - getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; }, + getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error, model }; }, getParticleCount() { return rendering?.getParticleCount() || 0; }, }; return layer; diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 8d5fb6ec6..7d3a60048 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -1,9 +1,9 @@ -/** Create a source that loads the current GFS wind field. */ +/** Create a source that loads the current wind field. */ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) { return { - async getSnapshot({ signal } = {}) { + async getSnapshot({ signal, model = 'gfs' } = {}) { signal?.throwIfAborted(); - const response = await fetchImpl('/api/wind/manifest', { + const response = await fetchImpl(`/api/wind/manifest?model=${encodeURIComponent(model)}`, { signal, cache: 'no-store', }); From b7033d86c2a2654b104628f5d0b51fa9959ee07a Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 14:02:48 -0300 Subject: [PATCH 03/11] 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, From 8db53063a98e0008eca9ce4cc0163ad453753655 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 14:32:22 -0300 Subject: [PATCH 04/11] fix(wind): scope grid ids and URLs to the model The grid id and URL omitted the model, so two models sharing a cycle produced the same `/api/wind/grid/.bin`. Because the grid route defaults to `gfs`, an IFS grid request resolved against the GFS cache: when both cycles matched it silently served the GFS field as IFS, and when they differed it 404'd and the client parsed the error body as float data. Include the model in both the id and the URL, and reject a resampled grid that contains non-finite values instead of caching it. Verified against live data: the IFS grid now decodes to 65,160 finite values (u -20.2..27.6 m/s) and renders a field distinct from GFS. --- server/providers/wind.js | 141 ++++++++++++++++++++++++++++++++---- src/data/windProxy.test.mjs | 38 +++++++++- 2 files changed, 162 insertions(+), 17 deletions(-) diff --git a/server/providers/wind.js b/server/providers/wind.js index 13dc0447e..283b82104 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -2,47 +2,156 @@ import { fetchGfsWind } from './wind/gfs.js'; import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; -/** Dispatch cached wind requests by model and cycle. */ -export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) { +/** + * Return whether every value in a resampled grid is finite. A decode that + * yields NaN/Infinity must never be cached or served as a valid wind field. + * @param {{u: Float32Array, v: Float32Array}} grid + * @returns {boolean} + */ +function gridIsFinite(grid) { + for (const array of [grid.u, grid.v]) { + for (let i = 0; i < array.length; i += 1) + if (!Number.isFinite(array[i])) return false; + } + return true; +} + +/** + * GFS/IFS 10 m wind proxy. + * + * Dispatches by `?model=gfs|ifs` (default gfs), caches each model's latest + * cycle for an hour (single-flight, last-good on failure), and serves a + * manifest plus a Float32 grid (U values then V values). + * + * Routes (mounted at `/api/wind`): + * GET /api/wind/manifest?model=… → manifest JSON + * GET /api/wind/grid/.bin?model=… → Float32 U…V payload + * GET /api/wind/status?model=… → manifest without `gridUrl` + * + * @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function, + * targetDx?: number, ttlMs?: number, models?: object}} [options] + * @returns {import('vite').Plugin} + */ +export function windProxy({ + fetchImpl = fetch, + now = () => Date.now(), + decodeImpl = decodeWindGribMessage, + targetDx = 1, + ttlMs = 3600_000, + models = { gfs: fetchGfsWind, ifs: fetchIfsWind }, +} = {}) { + /** @type {Map} */ const caches = new Map(); + /** @type {Map>} */ const loadings = new Map(); - const sendJson = (res, value, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)); }; + + const sendJson = (res, value, status = 200) => { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(value)); + }; + const refresh = (model) => { const loader = models[model]; const options = { fetchImpl, now, decodeImpl, targetDx }; const promise = (async () => { try { const value = await loader(options); - const id = `${value.cycle.date}-${value.cycle.hour}`; - const idGrid = `${id}-${targetDx}`; - const manifest = { schemaVersion: 1, model, cycle: value.cycle, fetchedAt: now(), level: value.level, units: value.units, grid: { nx: value.grid.nx, ny: value.grid.ny, lo1: value.grid.lo1, la1: value.grid.la1, dx: value.grid.dx, dy: value.grid.dy }, stale: false, unavailable: false, reason: null, gridUrl: `/api/wind/grid/${idGrid}.bin` }; - const state = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid: value.grid }; + if (!gridIsFinite(value.grid)) + throw new Error('wind grid contains non-finite values'); + // The model is part of both the id and the URL: two models can share a + // cycle, and the grid route must resolve to the requesting model. + const idGrid = `${model}-${value.cycle.date}-${value.cycle.hour}-${targetDx}`; + const manifest = { + schemaVersion: 1, + model, + cycle: value.cycle, + fetchedAt: now(), + level: value.level, + units: value.units, + grid: { + nx: value.grid.nx, + ny: value.grid.ny, + lo1: value.grid.lo1, + la1: value.grid.la1, + dx: value.grid.dx, + dy: value.grid.dy, + }, + stale: false, + unavailable: false, + reason: null, + gridUrl: `/api/wind/grid/${idGrid}.bin?model=${model}`, + }; + const state = { + idGrid, + fetchedAt: manifest.fetchedAt, + manifest, + grid: value.grid, + }; caches.set(model, state); return state; } catch (error) { const old = caches.get(model); - if (old) { old.manifest = { ...old.manifest, stale: true, reason: error.message }; return old; } - return { id: null, idGrid: null, grid: null, manifest: { schemaVersion: 1, model, stale: true, unavailable: true, reason: error.message } }; - } finally { loadings.delete(model); } + if (old) { + old.manifest = { ...old.manifest, stale: true, reason: error.message }; + return old; + } + return { + idGrid: null, + grid: null, + manifest: { + schemaVersion: 1, + model, + stale: true, + unavailable: true, + reason: error.message, + }, + }; + } finally { + loadings.delete(model); + } })(); loadings.set(model, promise); return promise; }; + const handler = async (req, res) => { const url = new URL(req.url, 'http://localhost'); const model = url.searchParams.get('model') || 'gfs'; if (!models[model]) return sendJson(res, { error: 'unknown_model' }, 400); let state = caches.get(model); - if (!state || now() - state.fetchedAt >= ttlMs) state = loadings.get(model) || refresh(model); + if (!state || now() - state.fetchedAt >= ttlMs) + state = loadings.get(model) || refresh(model); state = await state; - if (url.pathname === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); } + if (url.pathname === '/status') { + const { gridUrl, ...manifest } = state.manifest; + return sendJson(res, manifest); + } if (url.pathname.startsWith('/grid/')) { - if (!state.grid || url.pathname !== `/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' }); + if (!state.grid || url.pathname !== `/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); } }; + + return { + name: 'wind', + configureServer({ middlewares }) { + middlewares.use('/api/wind', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/wind', handler); + }, + }; } diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs index f8ee9136b..545d12fbe 100644 --- a/src/data/windProxy.test.mjs +++ b/src/data/windProxy.test.mjs @@ -53,6 +53,9 @@ const DECODED_U = { }; const DECODED_V = { ...DECODED_U, values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] }; +/** Resampled-grid metadata shared by the injected model fakes. */ +const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 }; + /** Fetch double: `.idx` text, and range bodies of the requested length. */ function makeFetch({ fail = false, counter } = {}) { return async (url, options = {}) => { @@ -87,7 +90,7 @@ test('wind manifest describes the GFS cycle and resampled grid', async () => { 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.match(body.gridUrl, /^\/api\/wind\/grid\/gfs-20260914-6-90\.bin\?model=gfs$/); assert.equal(body.stale, false); }); @@ -168,3 +171,36 @@ test('wind returns a JSON 404 for an unknown grid', async () => { assert.equal(res.statusCode, 404); assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' }); }); + +test('wind grid ids and URLs are model-scoped', async () => { + const request = install( + proxy({ + models: { + gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }), + ifs: async () => ({ cycle: { date: '20260914', hour: 0 }, level: 'x', units: 'm/s', grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) } }), + }, + }), + ); + const gfs = JSON.parse((await request('/')).body); + const ifs = JSON.parse((await request('/?model=ifs')).body); + assert.match(gfs.gridUrl, /gfs-20260914-6-90\.bin\?model=gfs$/); + assert.match(ifs.gridUrl, /ifs-20260914-0-90\.bin\?model=ifs$/); + // Each model's grid resolves through its own model parameter. + assert.equal((await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode, 200); + assert.equal((await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode, 200); +}); + +test('wind rejects a non-finite grid instead of caching it', async () => { + const bad = new Float32Array(12); + bad[3] = Number.POSITIVE_INFINITY; + const request = install( + proxy({ + models: { + gfs: async () => ({ cycle: { date: '20260914', hour: 6 }, level: 'x', units: 'm/s', grid: { ...GRID, u: bad, v: new Float32Array(12) } }), + }, + }), + ); + const body = JSON.parse((await request('/')).body); + assert.equal(body.unavailable, true); + assert.match(body.reason, /non-finite/); +}); From 48b3c1c332aa6acccc933f55789201c49c1a85e9 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 17:17:08 -0300 Subject: [PATCH 05/11] fix(wind): restore model rendering and show valid time --- server/providers/wind/catalog.js | 13 ++ server/providers/wind/gfs.js | 22 ++- server/providers/wind/ifs.js | 33 +++- src/layers/wind/index.js | 86 ++++++++- src/layers/wind/index.test.mjs | 108 +++++++++++ src/layers/wind/model.js | 4 +- src/layers/wind/rendering.js | 303 ++++++++++++++++++++++++------- src/layers/wind/source.js | 2 +- src/ui/layerPanel.js | 22 ++- src/ui/styles/radio.css | 15 ++ 10 files changed, 526 insertions(+), 82 deletions(-) create mode 100644 src/layers/wind/index.test.mjs diff --git a/server/providers/wind/catalog.js b/server/providers/wind/catalog.js index 1e67b8537..f78e9056f 100644 --- a/server/providers/wind/catalog.js +++ b/server/providers/wind/catalog.js @@ -14,3 +14,16 @@ export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } date.setUTCHours(hour); return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour }; } + +/** + * Round a time offset to the nearest published GFS forecast step so the field + * shown is the one valid closest to now, not always the analysis (f000). + * GFS 0.25° is hourly through f120, then every 3 h through f384. + * @param {number} hours - Hours elapsed since the cycle run time. + * @returns {number} A valid forecast step. + */ +export function nearestGfsStep(hours) { + const value = Math.max(0, Number.isFinite(hours) ? hours : 0); + const step = value <= 120 ? Math.round(value) : Math.round(value / 3) * 3; + return Math.max(0, Math.min(384, step)); +} diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index 6caee82b3..b4bdd50e1 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -88,14 +88,24 @@ export async function fetchRange({ return buffer; } -import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle } from './catalog.js'; +import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js'; import { decodeWindGribMessage } from './decode.js'; import { resampleWindGrid } from './grid.js'; -/** Fetch and decode the latest GFS 10 m wind field. */ +/** Fetch and decode the GFS 10 m wind field valid closest to now. */ export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage } = {}) { - const cycle = selectLatestGfsCycle(now()); - const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; + const nowMs = now(); + const cycle = selectLatestGfsCycle(nowMs); + const runMs = Date.UTC( + Number(cycle.date.slice(0, 4)), + Number(cycle.date.slice(4, 6)) - 1, + Number(cycle.date.slice(6, 8)), + cycle.hour, + ); + // Pick the forecast step whose valid time is closest to now, so the layer + // shows the freshest field the cycle offers instead of the analysis. + const forecastHour = nearestGfsStep((nowMs - runMs) / 3600_000); + const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey({ ...cycle, forecastHour })}`; const index = await fetchText({ url: `${base}.idx`, fetchImpl }); const ranges = windMessageRanges(parseGfsIdx(index.toString())); const [uBuffer, vBuffer] = await Promise.all([ @@ -104,5 +114,7 @@ export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), ]); 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 }); - return { cycle: { ...cycle, forecastHour: 0, runIso: `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z` }, level: '10 m above ground', units: 'm/s', grid }; + const runIso = new Date(runMs).toISOString(); + const validIso = new Date(runMs + forecastHour * 3600_000).toISOString(); + return { cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid }; } diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 450a83827..7c7af7d37 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -80,7 +80,20 @@ export function ifsWindRanges(entries) { } /** - * Fetch and decode the latest IFS 10 m wind field. + * Round a time offset to the nearest published IFS forecast step. IFS 0.25° + * oper is 3-hourly through f144 (00/12z) or f90 (06/18z). + * @param {number} hours - Hours elapsed since the cycle run time. + * @param {number} cycleHour - The cycle hour (0, 6, 12 or 18). + * @returns {number} A valid forecast step. + */ +export function nearestIfsStep(hours, cycleHour) { + const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144; + const value = Math.max(0, Number.isFinite(hours) ? hours : 0); + return Math.max(0, Math.min(max, Math.round(value / 3) * 3)); +} + +/** + * Fetch and decode the IFS 10 m wind field valid closest to now. * @param {{fetchImpl?: Function, now?: Function, targetDx?: number, * decodeImpl?: Function}} [options] * @returns {Promise<{cycle: object, level: string, units: string, grid: object}>} @@ -91,8 +104,17 @@ export async function fetchIfsWind({ targetDx = 1, decodeImpl = decodeWindGribMessage, } = {}) { - const cycle = selectLatestIfsCycle(now()); - const urls = ifsObjectUrls(cycle); + const nowMs = now(); + const cycle = selectLatestIfsCycle(nowMs); + const runMs = Date.UTC( + Number(cycle.date.slice(0, 4)), + Number(cycle.date.slice(4, 6)) - 1, + Number(cycle.date.slice(6, 8)), + cycle.hour, + ); + // Show the field valid closest to now rather than always the analysis. + const forecastHour = nearestIfsStep((nowMs - runMs) / 3600_000, cycle.hour); + const urls = ifsObjectUrls({ ...cycle, step: forecastHour }); const index = await fetchText({ url: urls.index, fetchImpl }); const ranges = ifsWindRanges(parseIfsIndex(index.toString())); const [uBuffer, vBuffer] = await Promise.all([ @@ -112,9 +134,10 @@ export async function fetchIfsWind({ dx: targetDx, dy: targetDx, }); - const runIso = `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`; + const runIso = new Date(runMs).toISOString(); + const validIso = new Date(runMs + forecastHour * 3600_000).toISOString(); return { - cycle: { ...cycle, forecastHour: 0, runIso }, + cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid, diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index bf0038a8f..022bd1fd4 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -1,11 +1,26 @@ import * as Cesium from 'cesium'; import { createWindRendering } from './rendering.js'; +/** Format an ISO date/time into UTC string (YYYY-MM-DD HH:mm UTC). */ +export function formatWindValidTime(isoString) { + if (!isoString) return null; + const timeMs = Date.parse(isoString); + if (!Number.isFinite(timeMs)) return null; + const date = new Date(timeMs); + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + const h = String(date.getUTCHours()).padStart(2, '0'); + const min = String(date.getUTCMinutes()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min} UTC`; +} + /** Summarize a wind manifest for the layer status surface. */ export function windStats(manifest) { + const time = manifest?.cycle?.validIso || manifest?.cycle?.runIso; return { count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0, - lastUpdate: manifest?.cycle?.runIso ? Date.parse(manifest.cycle.runIso) : manifest?.fetchedAt ?? null, + lastUpdate: time ? Date.parse(time) : manifest?.fetchedAt ?? null, error: manifest?.unavailable ? manifest.reason || 'Wind unavailable' : manifest?.reason || null, }; } @@ -21,6 +36,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = let error = null; let model = 'gfs'; let generation = 0; + let rowControlsListener = null; const layer = { id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS / ECMWF IFS', updateInterval: 3600_000, init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); }, @@ -37,10 +53,12 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = manifest = snapshot; error = null; if (!snapshot.unavailable) rendering.setField(snapshot); + rowControlsListener?.(); return true; } catch (cause) { if (controller.signal.aborted || signal?.aborted) return false; error = cause?.message || 'Wind source unavailable'; + rowControlsListener?.(); return true; } finally { if (request === controller) request = null; } }, @@ -48,6 +66,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return; model = params.model; generation += 1; + rowControlsListener?.(); if (enabled) { request?.abort(); const currentGeneration = generation; @@ -57,8 +76,71 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = } }, getParams() { return { model }; }, + /** + * Row controls: a GFS/IFS source chip, the wind-intensity colour legend, + * and active model valid timestamp readout with sensible fallback. + * The chip is stateless — it declares the params to apply and the manager + * owns the write. + * @returns {{chips: Array, legend: Array, info: string, infoTitle: string}} + */ + getRowControls() { + const chip = (value, label) => ({ + id: `model-${value}`, + label, + active: model === value, + state: model === value ? 'active' : 'idle', + title: + value === 'gfs' + ? 'Use NOAA GFS 10 m wind' + : 'Use ECMWF IFS 10 m wind', + params: { model: value }, + }); + const modelUpper = (model || 'gfs').toUpperCase(); + const validIso = + manifest?.model === model && !manifest?.unavailable + ? manifest?.cycle?.validIso || manifest?.cycle?.runIso + : null; + const validTimestamp = formatWindValidTime(validIso); + const validLabel = validTimestamp ? `Valid: ${validTimestamp}` : 'Valid: Unavailable'; + + return { + chips: [chip('gfs', 'GFS'), chip('ifs', 'IFS')], + legend: [ + { label: '0', color: '#1e3a8a' }, + { label: '5', color: '#2563eb' }, + { label: '10', color: '#22d3ee' }, + { label: '15', color: '#34d399' }, + { label: '20', color: '#fbbf24' }, + { label: '25', color: '#f97316' }, + { label: '30+ m/s', color: '#ef4444' }, + ], + info: `${modelUpper} · ${validLabel}`, + infoTitle: validTimestamp + ? `${modelUpper} 10 m wind forecast valid at ${validTimestamp}` + : `${modelUpper} 10 m wind forecast valid time unavailable`, + }; + }, + /** Repaint the row after a synchronous model change. */ + setRowControlsListener(listener) { + rowControlsListener = typeof listener === 'function' ? listener : null; + }, destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; }, - getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error, model }; }, + getStats() { + const stats = windStats(manifest); + const modelUpper = (model || 'gfs').toUpperCase(); + const validIso = + manifest?.model === model && !manifest?.unavailable + ? manifest?.cycle?.validIso || manifest?.cycle?.runIso + : null; + const validTimestamp = formatWindValidTime(validIso); + return { + ...stats, + source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', + model: modelUpper, + validTime: validTimestamp || 'Unavailable', + error: error || stats.error, + }; + }, getParticleCount() { return rendering?.getParticleCount() || 0; }, }; return layer; diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs new file mode 100644 index 000000000..25421120f --- /dev/null +++ b/src/layers/wind/index.test.mjs @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createWindLayer, formatWindValidTime, windStats } from './index.js'; + +test('formatWindValidTime formats UTC dates and falls back for invalid/missing input', () => { + assert.equal(formatWindValidTime('2026-09-14T12:00:00.000Z'), '2026-09-14 12:00 UTC'); + assert.equal(formatWindValidTime('2026-01-05T06:30:00.000Z'), '2026-01-05 06:30 UTC'); + assert.equal(formatWindValidTime(null), null); + assert.equal(formatWindValidTime(undefined), null); + assert.equal(formatWindValidTime('invalid-date'), null); +}); + +test('wind layer provides row controls with active model and valid timestamp', async () => { + let manifest = { + model: 'gfs', + cycle: { validIso: '2026-09-14T18:00:00.000Z', runIso: '2026-09-14T12:00:00.000Z' }, + grid: { nx: 2, ny: 2 }, + unavailable: false, + }; + const feed = { + getSnapshot: async ({ model }) => ({ ...manifest, model }), + }; + + const container = { clientWidth: 800, clientHeight: 600, appendChild() {} }; + const canvas = { + style: {}, + dataset: {}, + getContext: () => ({ + clearRect() {}, + setTransform() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + stroke() {}, + }), + remove() {}, + }; + globalThis.document = { createElement: () => canvas }; + globalThis.requestAnimationFrame = () => 1; + globalThis.cancelAnimationFrame = () => {}; + + const viewer = { + container, + scene: { canvas, camera: { positionWC: {} } }, + isDestroyed: () => false, + }; + const cesium = { + Cartesian3: { fromDegrees: () => ({}) }, + Ellipsoid: { WGS84: {} }, + EllipsoidalOccluder: class { isPointVisible() { return true; } }, + SceneTransforms: { worldToWindowCoordinates: () => ({ x: 10, y: 10 }) }, + }; + + const layer = createWindLayer({ feed, cesium, container }); + layer.init(viewer); + + // Before update / while unavailable + const initialControls = layer.getRowControls(); + assert.equal(initialControls.chips.length, 2); + assert.equal(initialControls.chips[0].active, true); + assert.equal(initialControls.chips[1].active, false); + assert.match(initialControls.info, /GFS · Valid: Unavailable/); + + // After enable and update + layer.enable(); + await layer.update(viewer); + + const activeControls = layer.getRowControls(); + assert.equal(activeControls.chips[0].active, true); + assert.match(activeControls.info, /GFS · Valid: 2026-09-14 18:00 UTC/); + assert.ok(activeControls.legend.length >= 7); + + const stats = layer.getStats(); + assert.equal(stats.model, 'GFS'); + assert.equal(stats.validTime, '2026-09-14 18:00 UTC'); + assert.equal(stats.source, 'NOAA GFS'); + + // Switch model to IFS + layer.setParams({ model: 'ifs' }); + const ifsControls = layer.getRowControls(); + assert.equal(ifsControls.chips[1].active, true); + assert.match(ifsControls.info, /IFS · Valid: Unavailable/); // manifest pending for ifs + + manifest = { + model: 'ifs', + cycle: { validIso: '2026-09-14T21:00:00.000Z', runIso: '2026-09-14T12:00:00.000Z' }, + grid: { nx: 2, ny: 2 }, + unavailable: false, + }; + await layer.update(viewer); + + const updatedIfsControls = layer.getRowControls(); + assert.match(updatedIfsControls.info, /IFS · Valid: 2026-09-14 21:00 UTC/); + + const ifsStats = layer.getStats(); + assert.equal(ifsStats.model, 'IFS'); + assert.equal(ifsStats.validTime, '2026-09-14 21:00 UTC'); + assert.equal(ifsStats.source, 'ECMWF IFS'); + + // Unavailable manifest fallback + manifest = { model: 'ifs', unavailable: true, reason: 'ECMWF upstream outage' }; + await layer.update(viewer); + const unavailControls = layer.getRowControls(); + assert.match(unavailControls.info, /IFS · Valid: Unavailable/); + + layer.disable(); + layer.destroy(); +}); diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index b7e5c8667..6f489e101 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 (bright from calm upward). */ +/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */ export function windColor(speed, { max = 30 } = {}) { - const stops = ['#38bdf8', '#22d3ee', '#34d399', '#fbbf24', '#f87171']; + const stops = ['#1e3a8a', '#2563eb', '#22d3ee', '#34d399', '#fbbf24', '#f97316', '#ef4444']; 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 9e556c6e6..b0969af84 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -1,4 +1,4 @@ -import { advectParticle, sampleWind, windColor, windSpeed } from './model.js'; +import { advectParticle, metersPerDegreeLon, sampleWind, windColor, windSpeed } from './model.js'; /** * Canvas particle renderer for a global wind field. @@ -21,52 +21,114 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { let cssHeight = 1; let warned = false; - const randomParticle = () => ({ - lon: Math.random() * 360 - 180, - lat: Math.random() * 178 - 89, - age: Math.random() * 180, - }); + const MAX_TRAIL_LENGTH = 10; + + const randomParticle = () => { + const life = 1.4 + Math.random() * 1.8; + return { + lon: Math.random() * 360 - 180, + lat: Math.random() * 178 - 89, + age: Math.random() * life, + life, + trail: [], + }; + }; + + const wrapLon = (lon) => ((lon + 180) % 360 + 360) % 360 - 180; + const clampLat = (lat) => Math.max(-89, Math.min(89, lat)); + const finite = (value) => Number.isFinite(value); /** - * 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". + * The camera's visible geographic bounds, or null when it cannot be computed + * (`computeViewRectangle` returns NaN at very close zoom). Falls back to a + * height-scaled spread around the camera subpoint. + * @returns {?{west: number, east: number, south: number, north: number}} */ - const spawnParticle = (scene) => { + const viewBounds = (scene) => { const camera = scene?.camera; - if (camera?.computeViewRectangle && cesium.Ellipsoid && cesium.Math) { + const math = cesium.Math; + if (camera?.computeViewRectangle && cesium.Ellipsoid && 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, - }; + const west = math.toDegrees(rect.west); + const east = math.toDegrees(rect.east); + const south = math.toDegrees(rect.south); + const north = math.toDegrees(rect.north); + if (finite(west) && finite(east) && finite(south) && finite(north) && east > west) + return { west, east, south, north }; } } catch { - /* fall through to the global fallback */ + /* fall through to the subpoint spread */ } } - return randomParticle(); + const carto = camera?.positionCartographic; + if (carto && finite(carto.longitude) && finite(carto.latitude) && math) { + const height = finite(carto.height) ? carto.height : 1e6; + const spread = Math.min( + 60, + Math.max(0.5, math.toDegrees(Math.acos(6378137 / (6378137 + height)))), + ); + const lon = math.toDegrees(carto.longitude); + const lat = math.toDegrees(carto.latitude); + return { west: lon - spread, east: lon + spread, south: lat - spread, north: lat + spread }; + } + return null; + }; + + /** A particle with a finite lifetime and staggered age so the field keeps reseeding smoothly. */ + const particleAt = (lon, lat, age = null) => { + const life = 1.4 + Math.random() * 1.8; + return { + lon: wrapLon(lon), + lat: clampLat(lat), + age: age !== null ? age : Math.random() * life, + life, + trail: [], + }; + }; + + /** Spawn a particle inside the current view (uniform over the bounds). */ + const spawnParticle = (scene) => { + const bounds = viewBounds(scene); + if (bounds) { + const lon = bounds.west + Math.random() * (bounds.east - bounds.west); + const lat = bounds.south + Math.random() * (bounds.north - bounds.south); + return particleAt(lon, lat, 0); + } + const p = randomParticle(); + p.age = 0; + return p; }; const budget = () => - Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150))); + Math.max(3200, Math.min(6000, Math.floor((cssWidth * cssHeight) / 140))); + /** + * Seed particles on a jittered grid over the visible bounds. Uniform coverage + * is what keeps the layer from looking patchy or clumped like random seeding. + */ const seed = () => { particles = []; const limit = budget(); const scene = getViewer?.()?.scene; - for (let i = 0; i < limit; i += 1) particles.push(spawnParticle(scene)); + const bounds = viewBounds(scene); + if (!bounds) { + for (let i = 0; i < limit; i += 1) particles.push(randomParticle()); + return; + } + const width = Math.max(0.01, bounds.east - bounds.west); + const height = Math.max(0.01, bounds.north - bounds.south); + const aspect = Math.max(1, width / height); + const cols = Math.max(1, Math.round(Math.sqrt(limit * aspect))); + const rows = Math.max(1, Math.ceil(limit / cols)); + for (let row = 0; row < rows && particles.length < limit; row += 1) { + for (let col = 0; col < cols && particles.length < limit; col += 1) { + const lon = bounds.west + ((col + Math.random()) / cols) * width; + const lat = bounds.south + ((row + Math.random()) / rows) * height; + particles.push(particleAt(lon, lat)); + } + } }; const resize = (viewer) => { @@ -89,6 +151,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian); }; + let lastCamX = 0; + let lastCamY = 0; + let lastCamZ = 0; + let lastCamHeading = 0; + let lastCamPitch = 0; + const draw = (time) => { frame = null; if (!running) return; @@ -96,23 +164,14 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (!viewer || viewer.isDestroyed?.()) return; try { resize(viewer); - const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016; + const dt = lastTime ? Math.min(0.1, Math.max(0.001, (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) @@ -120,41 +179,153 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { } 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. + + // Camera motion check: if camera moved significantly, clear trails to prevent smearing + const camera = scene?.camera; + const camPos = camera?.positionWC; + const camX = camPos?.x ?? 0; + const camY = camPos?.y ?? 0; + const camZ = camPos?.z ?? 0; + const camHeading = camera?.heading ?? 0; + const camPitch = camera?.pitch ?? 0; + + const cameraMoved = + Math.abs(camX - lastCamX) > 5 || + Math.abs(camY - lastCamY) > 5 || + Math.abs(camZ - lastCamZ) > 5 || + Math.abs(camHeading - lastCamHeading) > 0.002 || + Math.abs(camPitch - lastCamPitch) > 0.002; + + if (cameraMoved) { + lastCamX = camX; + lastCamY = camY; + lastCamZ = camZ; + lastCamHeading = camHeading; + lastCamPitch = camPitch; + for (let i = 0; i < particles.length; i += 1) { + particles[i].trail.length = 0; + } + } + + // Redraw fresh frame: clean screen eliminates static clumps and smearing + context.clearRect?.(0, 0, cssWidth, cssHeight); + context.lineCap = 'round'; + context.lineJoin = 'round'; 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. + const speed = windSpeed(wind.u, wind.v); + + // Stagnant air penalty to prevent accumulation in dead calm zones + if (speed < 0.2) { + particle.age += dt * 2; + } else { + particle.age += dt; + } + + const beforeLon = particle.lon; + const beforeLat = particle.lat; + + // Dynamic step speed based on local wind speed: + // Calm wind moves gently (~1.3 - 1.8 px), gale winds flow swiftly (~4.5 - 5.5 px) + if (speed > 0.01) { + const speedT = Math.min(1, speed / 30); + const targetPx = 1.3 + Math.pow(speedT, 0.75) * 4.2; + const cosLat = Math.max(0.05, Math.cos((particle.lat * Math.PI) / 180)); + const probeLon = particle.lon + (wind.u / speed) * (0.001 / cosLat); + const probeLat = particle.lat + (wind.v / speed) * 0.001; + const probe = project(scene, occluder, probeLon, probeLat); + const start = project(scene, occluder, particle.lon, particle.lat); + if (probe && start) { + const px = Math.hypot(probe.x - start.x, probe.y - start.y); + if (px > 0.001) { + const scale = targetPx / px; + // RK2 midpoint advection for smooth streamline curvature along vortices + const halfDt = (0.0005 * scale * 111320) / speed; + const midLon = particle.lon + (wind.u * halfDt) / metersPerDegreeLon(particle.lat); + const midLat = Math.max(-89, Math.min(89, particle.lat + (wind.v * halfDt) / 111320)); + const midWind = sampleWind(field, midLon, midLat); + const fullDt = (0.001 * scale * 111320) / speed; + advectParticle(particle, midWind, fullDt, { speedScale: 1 }); + } + } + } + if (!Number.isFinite(particle.lon) || !Number.isFinite(particle.lat)) { Object.assign(particle, spawnParticle(scene)); + particle.trail.length = 0; continue; } - const point = project(scene, occluder, particle.lon, particle.lat); + + const head = 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) { + !head || + head.x < -10 || + head.y < -10 || + head.x > cssWidth + 10 || + head.y > cssHeight + 10; + + if (particle.age >= particle.life || offscreen) { Object.assign(particle, spawnParticle(scene)); + particle.trail.length = 0; 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(); + + // Maintain continuous streamline trail + if (particle.trail.length === 0) { + const prev = project(scene, occluder, beforeLon, beforeLat); + if (prev) particle.trail.push({ x: prev.x, y: prev.y }); + } + particle.trail.push({ x: head.x, y: head.y }); + if (particle.trail.length > MAX_TRAIL_LENGTH) { + particle.trail.shift(); + } + + const trail = particle.trail; + const len = trail.length; + if (len >= 2) { + const color = windColor(speed); + const lifeFrac = Math.min(1, Math.max(0, particle.age / particle.life)); + const lifeFade = Math.sin(lifeFrac * Math.PI); + + if (len < 4) { + // Short streamline segment + context.strokeStyle = color; + context.lineWidth = 1.1; + context.globalAlpha = 0.5 * lifeFade; + context.beginPath(); + context.moveTo(trail[0].x, trail[0].y); + for (let i = 1; i < len; i += 1) { + context.lineTo(trail[i].x, trail[i].y); + } + context.stroke(); + } else { + // Mature streamline with subtle tapered and faded trail (tail -> head) + const mid = Math.floor(len * 0.45); + + // Tail section: faint, delicate + context.strokeStyle = color; + context.lineWidth = 0.85; + context.globalAlpha = 0.22 * lifeFade; + context.beginPath(); + context.moveTo(trail[0].x, trail[0].y); + for (let i = 1; i <= mid; i += 1) { + context.lineTo(trail[i].x, trail[i].y); + } + context.stroke(); + + // Head section: brighter, tapered width + context.lineWidth = 1.65; + context.globalAlpha = 0.82 * lifeFade; + context.beginPath(); + context.moveTo(trail[mid].x, trail[mid].y); + for (let i = mid + 1; i < len; i += 1) { + context.lineTo(trail[i].x, trail[i].y); + } + context.stroke(); + } + } } context.globalAlpha = 1; } catch (error) { @@ -181,7 +352,11 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }, /** Install a wind field and (re)seed particles. */ setField(next) { - field = next; + // The layer hands over the source manifest, whose grid metadata is nested + // under `grid`; flatten it into the `{ u, v, nx, ny, lo1, la1, dx, dy }` + // shape sampleWind expects. Without this every index is NaN and nothing + // is ever drawn. + field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next; if (canvas) { resize(getViewer?.()); seed(); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 7d3a60048..183da816c 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -11,9 +11,9 @@ export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(... if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); const manifest = await response.json(); signal?.throwIfAborted(); + if (manifest.unavailable) return manifest; 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}`); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index fafcb5d67..2dfcdfcdc 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -238,11 +238,14 @@ export class LayerPanel { const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; const chips = controls?.chips || []; const legend = controls?.legend || []; - container.hidden = chips.length === 0 && legend.length === 0; + container.hidden = + chips.length === 0 && legend.length === 0 && !controls?.info; for (const node of [...container.children]) { + const classNames = String(node.className).split(/\s+/); if ( - String(node.className).split(/\s+/).includes('data-toggle-legend-item') + classNames.includes('data-toggle-legend-item') || + classNames.includes('data-toggle-controls-info') ) node.remove(); } @@ -279,10 +282,23 @@ export class LayerPanel { swatch.className = 'data-toggle-legend-swatch'; swatch.style.background = item.color; const text = document.createElement('span'); - text.textContent = `${item.label} ${this._formatCount(item.count)}`; + // Numeric colour legends (e.g. wind speed) carry only a label; only + // tallied legends carry a count. + text.textContent = + item.count === undefined || item.count === null + ? String(item.label) + : `${item.label} ${this._formatCount(item.count)}`; entry.append(swatch, text); container.appendChild(entry); } + + if (controls?.info) { + const info = document.createElement('div'); + info.className = 'data-toggle-controls-info'; + info.textContent = String(controls.info); + if (controls.infoTitle) info.title = controls.infoTitle; + container.appendChild(info); + } } _refreshTogglePanel() { diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 77b08eee1..f298812aa 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -800,3 +800,18 @@ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18); flex: none; } + +.data-toggle-controls-info { + display: flex; + align-items: center; + gap: 6px; + flex-basis: 100%; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.6px; + color: var(--text-secondary); + padding-top: 3px; + margin-top: 2px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + user-select: none; +} From dcb3339070e0b295494a6b11068a8d5ec3c1a4d0 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:21:09 -0500 Subject: [PATCH 06/11] fix(wind): bound GFS acquisition and preserve canvas trails and lifecycle --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 + scripts/package-boundaries.json | 1 + server/providers/wind.js | 37 ++++++++++++++---- server/providers/wind/decode.js | 11 +++--- server/providers/wind/gfs.js | 15 +++---- server/providers/wind/grid.js | 2 + src/data/windGfs.test.mjs | 8 ++++ src/data/windProxy.test.mjs | 2 +- src/layers/wind/index.js | 63 +++++++++++++++++++++++------- src/layers/wind/rendering.js | 29 +++++++++----- src/layers/wind/rendering.test.mjs | 26 +++++++++--- src/layers/wind/source.js | 58 ++++++++++++++------------- src/layers/wind/source.test.mjs | 39 +++++++++--------- src/sources/windBody.js | 35 +++++++++++++++++ src/ui/layerPanel.js | 13 ++++-- src/ui/styles/radio.css | 9 +++++ 17 files changed, 251 insertions(+), 101 deletions(-) create mode 100644 src/sources/windBody.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db1b7181..30b32ce55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,7 +227,7 @@ 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 +- Add a keyless **Wind** layer from NOAA GFS 10 m wind (#459, thanks @beneduzi). 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 444924e87..9a27d6cfb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Wind is an optional GFS 10 m forecast overlay. Source acquisition has deadline and body budgets, disconnect cancellation and one-minute failure backoff. The row distinguishes model issue time from forecast valid time. Animation starts only with a field, stops on disable/destruction, preserves trails between unchanged canvas dimensions and scales from 200 to 4,000 particles with viewport area. Application catalog construction owns each instance. + Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a0fb1791..534d643be 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1192,6 +1192,7 @@ "src/layers/wind/model.js", "src/layers/wind/rendering.js", "src/layers/wind/source.js", + "src/sources/windBody.js", "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", diff --git a/server/providers/wind.js b/server/providers/wind.js index 321717728..ae1ef77b3 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -40,6 +40,9 @@ export function windProxy({ let cached = null; /** @type {?Promise} */ let loading = null; + let lastAttempt = -Infinity; + let controller = null; + let waiters = 0; const sendJson = (res, value, status = 200) => { res.writeHead(status, { @@ -58,14 +61,19 @@ export function windProxy({ if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs) return cached; if (loading) return loading; + if (cached && now() - lastAttempt < 60_000) return cached; + lastAttempt = now(); + controller = new AbortController(); + const signal = controller.signal; + const timer = setTimeout(() => controller.abort(), 40_000); loading = (async () => { try { const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; - const index = await fetchText({ url: `${base}.idx`, fetchImpl }); + const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal }); const ranges = windMessageRanges(parseGfsIdx(index.toString())); const [uBuffer, vBuffer] = await Promise.all([ - fetchRange({ url: base, ...ranges.u, fetchImpl }), - fetchRange({ url: base, ...ranges.v, fetchImpl }), + fetchRange({ url: base, ...ranges.u, fetchImpl, signal }), + fetchRange({ url: base, ...ranges.v, fetchImpl, signal }), ]); const [u, v] = await Promise.all([ decodeImpl(uBuffer), @@ -83,11 +91,13 @@ export function windProxy({ dx: targetDx, dy: targetDx, }); + signal.throwIfAborted(); + if ((!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite))) throw new Error('Invalid wind grid'); const idGrid = `${id}-${targetDx}`; const manifest = { schemaVersion: 1, model: 'gfs', - cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) }, + cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) }, fetchedAt: now(), level: '10 m above ground', units: 'm/s', @@ -110,7 +120,7 @@ export function windProxy({ if (cached) { cached = { ...cached, - manifest: { ...cached.manifest, stale: true, reason: error.message }, + manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' }, }; } else { cached = { @@ -123,13 +133,15 @@ export function windProxy({ model: 'gfs', stale: true, unavailable: true, - reason: error.message, + reason: 'Wind upstream unavailable', }, }; } return cached; } finally { + clearTimeout(timer); loading = null; + controller = null; } })(); return loading; @@ -137,7 +149,18 @@ export function windProxy({ const handler = async (req, res) => { const path = new URL(req.url, 'http://localhost').pathname; - const state = await refresh(); + if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405); + if (!['/', '/manifest', '/status'].includes(path) && !/^\/grid\/[\w.-]+\.bin$/.test(path)) return sendJson(res, { error: 'not_found' }, 404); + // Grid reads never initiate acquisition; an issued manifest owns its cached grid. + if (path.startsWith('/grid/') && (!cached?.grid || path !== `/grid/${cached.idGrid}.bin`)) return sendJson(res, { error: 'unknown_grid' }, 404); + let disconnected = false; + const close = () => { disconnected = true; waiters -= 1; if (waiters === 0) controller?.abort(); }; + waiters += 1; + res.once?.('close', close); + let state; + try { state = path.startsWith('/grid/') ? cached : await refresh(); } + finally { res.removeListener?.('close', close); if (!disconnected) waiters -= 1; } + if (disconnected) return; if (path === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); diff --git a/server/providers/wind/decode.js b/server/providers/wind/decode.js index 734639f0a..7f137ae68 100644 --- a/server/providers/wind/decode.js +++ b/server/providers/wind/decode.js @@ -1,7 +1,6 @@ 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` @@ -37,12 +36,12 @@ export async function decodeWindGribMessage( { 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); + const directory = await fs.mkdtemp(path.join(mountDir, 'gev-wind-')); + const file = path.join(directory, 'wind.grib2'); + await fs.writeFile(file, buffer, { mode: 0o600 }); let handle = null; try { - eccodes.mountFilesystem(mountDir); + eccodes.mountFilesystem(directory); handle = eccodes.openGrib(file); return { ni: handle.getLong('Ni'), @@ -58,6 +57,6 @@ export async function decodeWindGribMessage( }; } finally { if (handle) handle.delete(); - await fs.unlink(file).catch(() => {}); + await fs.rm(directory, { recursive: true, force: true }).catch(() => {}); } } diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index ac637a14d..d28b2faa7 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -1,3 +1,4 @@ +import { readWindBody } from '../../../src/sources/windBody.js'; /** * Parse a GFS `.idx` inventory into ordered message records. * @@ -55,9 +56,9 @@ export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) /** 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()); + const response = await fetchImpl(url, { signal, redirect: 'error' }); + if (!response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } + return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal)); } /** @@ -72,17 +73,17 @@ export async function fetchRange({ signal, maxBytes = 8 * 1024 * 1024, }) { - if (end < start || end - start + 1 > maxBytes) + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end - start + 1 > maxBytes) throw new Error('range too large'); const response = await fetchImpl(url, { signal, + redirect: 'error', 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 (!validStatus || !response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } + const buffer = Buffer.from(await readWindBody(response, end - start + 1, signal)); 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 index aae81524f..9125e1f10 100644 --- a/server/providers/wind/grid.js +++ b/server/providers/wind/grid.js @@ -21,6 +21,8 @@ export function resampleWindGrid({ dx = 1, dy = 1, }) { + if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000 || u?.length !== ni * nj || v?.length !== ni * nj || ![lo1, la1, di, dj, dx, dy].every(Number.isFinite) || di <= 0 || dj <= 0 || dx < 0.25 || dy < 0.25 || dx > 180 || dy > 180) + throw new Error('Invalid wind grid geometry'); const nx = Math.round(360 / dx); const ny = Math.round(180 / dy) + 1; const outU = new Float32Array(nx * ny); diff --git a/src/data/windGfs.test.mjs b/src/data/windGfs.test.mjs index 243a3e022..e5387f115 100644 --- a/src/data/windGfs.test.mjs +++ b/src/data/windGfs.test.mjs @@ -11,3 +11,11 @@ test('catalog and index helpers', async () => { assert.deepEqual([...b], [1, 2, 3]); assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) })); }); + +test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => { + await assert.rejects(fetchRange({ url: 'x', start: -1, end: 2 }), /range too large/); + let cancelled = false; + const body = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(10)); }, cancel() { cancelled = true; } }); + await assert.rejects(fetchRange({ url: 'x', start: 0, end: 2, fetchImpl: async () => new Response(body, { status: 206 }) }), /budget/); + assert.equal(cancelled, true); +}); diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs index f8ee9136b..3055ae52c 100644 --- a/src/data/windProxy.test.mjs +++ b/src/data/windProxy.test.mjs @@ -156,7 +156,7 @@ test('wind serves last-good with stale on upstream failure', async () => { clock += 2 * 3600_000; const degraded = JSON.parse((await request('/')).body); assert.equal(degraded.stale, true); - assert.equal(degraded.reason, 'gfs upstream down'); + assert.equal(degraded.reason, 'Wind upstream unavailable'); const frame = await request(good.gridUrl.replace('/api/wind', '')); assert.equal(frame.statusCode, 200, 'last-good grid stays addressable'); }); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 49d468caf..276c83373 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -1,17 +1,24 @@ import * as Cesium from 'cesium'; import { createWindRendering } from './rendering.js'; -/** Summarize a wind manifest for the layer status surface. */ +/** Format a forecast timestamp explicitly in UTC. */ +export function formatWindValidTime(value) { + const ms = Date.parse(value); + return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : null; +} + +/** Forecast issue time is source age; valid time is reported separately. */ export function windStats(manifest) { + const run = Date.parse(manifest?.cycle?.runIso); 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, + lastUpdate: Number.isFinite(run) ? run : null, + error: manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null), }; } -/** Create the GFS wind data layer. */ -export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) { +/** Construct one wind layer with an explicit source and owned animation. */ +export function createWindLayer({ feed, cesium = Cesium, container, createRendering = createWindRendering } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); let viewer = null; let request = null; @@ -19,31 +26,57 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = let rendering = null; let manifest = null; let error = null; + let loading = false; + let rowControlsListener = null; + const notify = () => rowControlsListener?.(); 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(); }, + id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS · FORECAST', updateInterval: 3600_000, + init(nextViewer) { + viewer = nextViewer; + rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer }); + rendering.attach(); + }, enable() { enabled = true; rendering?.start(); }, - disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); }, + disable() { + enabled = false; + request?.abort(); request = null; loading = false; + rendering?.stop(); rendering?.clear(); + }, async update(nextViewer, { signal } = {}) { if (!enabled) return false; request?.abort(); const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); request = controller; + loading = true; + notify(); try { - const snapshot = await feed.getSnapshot({ signal: signal || controller.signal }); - if (!enabled || controller.signal.aborted || signal?.aborted) return false; + signal?.throwIfAborted(); + const snapshot = await feed.getSnapshot({ signal: controller.signal }); + if (!enabled || controller.signal.aborted || request !== controller) return false; manifest = snapshot; error = null; - if (!snapshot.unavailable) rendering.setField(snapshot); + if (snapshot.unavailable) { rendering.stop(); rendering.clear(); } + else { rendering.setField(snapshot); rendering.start(); } return true; } catch (cause) { - if (controller.signal.aborted || signal?.aborted) return false; + if (controller.signal.aborted || request !== controller) return false; error = cause?.message || 'Wind source unavailable'; return true; - } finally { if (request === controller) request = null; } + } finally { + signal?.removeEventListener('abort', abort); + if (request === controller) { request = null; loading = false; notify(); } + } }, - destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; }, - getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; }, + getRowControls() { + const valid = formatWindValidTime(manifest?.cycle?.validIso); + const run = formatWindValidTime(manifest?.cycle?.runIso); + return { chips: [], legend: [], info: `GFS forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + }, + setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, + destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; }, + getStats() { return { ...windStats(manifest), loading, error: error || windStats(manifest).error }; }, getParticleCount() { return rendering?.getParticleCount() || 0; }, }; return layer; diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 9e556c6e6..1a1234cda 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -20,6 +20,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { let cssWidth = 1; let cssHeight = 1; let warned = false; + let cameraSignature = null; const randomParticle = () => ({ lon: Math.random() * 360 - 180, @@ -60,7 +61,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }; const budget = () => - Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150))); + Math.max(200, Math.min(4000, Math.floor((cssWidth * cssHeight) / 300))); const seed = () => { particles = []; @@ -74,8 +75,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { 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); + const width = Math.floor(cssWidth * ratio); + const height = Math.floor(cssHeight * ratio); + // Assigning either dimension clears a real canvas, even if unchanged. + if (canvas.width === width && canvas.height === height) return; + canvas.width = width; + canvas.height = height; canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; context.setTransform?.(ratio, 0, 0, ratio, 0, 0); @@ -96,6 +101,13 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (!viewer || viewer.isDestroyed?.()) return; try { resize(viewer); + if (particles.length !== budget()) seed(); + const camera = viewer.scene.camera; + const signature = [camera?.positionWC?.x, camera?.positionWC?.y, camera?.positionWC?.z, camera?.heading, camera?.pitch, camera?.roll].join(','); + if (signature !== cameraSignature) { + context.clearRect(0, 0, cssWidth, cssHeight); + cameraSignature = signature; + } 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 @@ -103,10 +115,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { 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; - } + if (!field) 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 @@ -181,10 +190,11 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }, /** Install a wind field and (re)seed particles. */ setField(next) { - field = next; + field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next; if (canvas) { resize(getViewer?.()); seed(); + if (running && frame === null) frame = globalThis.requestAnimationFrame(draw); } }, /** Start the animation loop. Safe to call before a field is installed. */ @@ -192,7 +202,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (running || !canvas) return; running = true; lastTime = 0; - frame = globalThis.requestAnimationFrame(draw); + if (field) frame = globalThis.requestAnimationFrame(draw); }, /** Stop the animation loop. */ stop() { @@ -202,6 +212,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }, /** Clear the canvas and particles without removing the canvas. */ clear() { + field = null; particles = []; context?.clearRect(0, 0, cssWidth, cssHeight); }, diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index dade321db..d477d94c5 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -82,7 +82,7 @@ test('wind rendering owns the canvas and particle lifecycle', () => { rendering.attach(); assert.equal(typeof canvas.getContext('2d'), 'object'); rendering.setField(FIELD); - assert.ok(rendering.getParticleCount() >= 3000); + assert.ok(rendering.getParticleCount() >= 200); rendering.start(); callbacks.shift()(16); assert.ok(strokes.length > 0, 'a driven frame draws trails'); @@ -99,10 +99,7 @@ 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'); + assert.equal(callbacks.length, 0, 'no animation work before a field arrives'); rendering.setField(FIELD); callbacks.shift()(32); assert.ok(strokes.length > 0, 'draws once the field is installed'); @@ -118,3 +115,22 @@ test('wind rendering skips particles hidden by the globe', () => { assert.equal(strokes.length, 0, 'no trail is drawn for an occluded point'); rendering.stop(); }); + +// HTML canvas dimension setters erase pixels even on an identical assignment. +test('steady frames preserve canvas dimensions and resize adjusts the particle budget', () => { + const { rendering, canvas, callbacks } = harness(); + let resets = 0; let width = 0; let height = 0; + Object.defineProperties(canvas, { + width: { get: () => width, set: value => { width = value; resets++; } }, + height: { get: () => height, set: value => { height = value; resets++; } }, + }); + rendering.attach(); rendering.setField({ grid: FIELD, u: FIELD.u, v: FIELD.v }); rendering.start(); + const initial = resets; + callbacks.shift()(16); callbacks.shift()(32); + assert.equal(resets, initial, 'steady frames must not invoke canvas dimension setters'); + canvas.clientWidth = 320; canvas.clientHeight = 240; + callbacks.shift()(48); + assert.equal(resets, initial + 2); + assert.ok(rendering.getParticleCount() < 500, 'small view uses a smaller budget'); + rendering.destroy(); +}); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 8d5fb6ec6..847536b4c 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -1,33 +1,35 @@ -/** Create a source that loads the current GFS wind field. */ -export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) { +import { readWindBody } from '../../sources/windBody.js'; + +/** Create a bounded, cancellable source for the same-origin wind provider. */ +export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) { 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), - }; + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('Wind request timed out')), timeoutMs); + const active = controller.signal; + try { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/wind/manifest', { signal: active, cache: 'no-store', redirect: 'error' }); + if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); + const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active))); + if (manifest?.unavailable) return manifest; + const grid = manifest?.grid; + if (manifest?.model !== 'gfs' || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !/^\/api\/wind\/grid\/[\w.-]+\.bin$/.test(manifest.gridUrl)) + throw new Error('Malformed wind manifest'); + const count = grid.nx * grid.ny; + const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' }); + if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`); + const bytes = await readWindBody(gridResponse, count * 8, active); + if (bytes.byteLength !== count * 8) throw new Error('Malformed wind grid'); + const values = new Float32Array(bytes.buffer); + if (!values.every(Number.isFinite)) throw new Error('Malformed wind grid'); + return { ...manifest, u: values.slice(0, count), v: values.slice(count) }; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } }, }; } diff --git a/src/layers/wind/source.test.mjs b/src/layers/wind/source.test.mjs index 221ff2072..8d6188167 100644 --- a/src/layers/wind/source.test.mjs +++ b/src/layers/wind/source.test.mjs @@ -1,24 +1,25 @@ 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]); +const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/test.bin' }; +const response = (value) => new Response(typeof value === 'object' && !(value instanceof ArrayBuffer) ? JSON.stringify(value) : value); +test('wind source splits a bounded field and preserves unavailable responses', async () => { + const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([1,2,3,4]).buffer) }); + const field = await source.getSnapshot(); + assert.deepEqual([...field.u], [1,2]); assert.deepEqual([...field.v], [3,4]); + const unavailable = createWindSource({ fetchImpl: async () => response({ unavailable: true }) }); + assert.equal((await unavailable.getSnapshot()).unavailable, true); }); - -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/); +test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => { + for (const bad of [{ ...manifest, gridUrl: 'https://example.com/exfil' }, { ...manifest, grid: { ...manifest.grid, nx: 1e9 } }]) { + let calls = 0; + const source = createWindSource({ fetchImpl: async () => { calls++; return response(bad); } }); + await assert.rejects(source.getSnapshot(), /Malformed/); assert.equal(calls, 1); + } + const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([NaN,2,3,4]).buffer) }); + await assert.rejects(source.getSnapshot(), /Malformed/); +}); +test('wind source timeout aborts the underlying fetch', async () => { + const source = createWindSource({ timeoutMs: 5, fetchImpl: (_, { signal }) => new Promise((resolve,reject) => signal.addEventListener('abort', () => reject(signal.reason), { once:true })) }); + await assert.rejects(source.getSnapshot(), /timed out/); }); diff --git a/src/sources/windBody.js b/src/sources/windBody.js new file mode 100644 index 000000000..d6287ccb3 --- /dev/null +++ b/src/sources/windBody.js @@ -0,0 +1,35 @@ +/** Read a fetch body under a hard byte budget, including chunked responses. */ +export async function readWindBody(response, maxBytes, signal) { + const fail = () => new Error('Wind response exceeds byte budget'); + if (Number(response.headers?.get('content-length')) > maxBytes) { + await response.body?.cancel(); + throw fail(); + } + const reader = response.body?.getReader(); + if (!reader) throw new Error('Wind response has no readable body'); + const chunks = []; + let size = 0; + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal?.addEventListener('abort', cancel, { once: true }); + try { + while (true) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) throw fail(); + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes; + } catch (error) { + cancel(); + throw error; + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); + } +} diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 73cf5ecdd..ed96d9713 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -226,11 +226,11 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0; + container.hidden = chips.length === 0 && legend.length === 0 && !controls?.info; for (const node of [...container.children]) { if ( - String(node.className).split(/\s+/).includes('data-toggle-legend-item') + String(node.className).split(/\s+/).some((name) => ['data-toggle-legend-item', 'data-toggle-controls-info'].includes(name)) ) node.remove(); } @@ -267,10 +267,17 @@ export class LayerPanel { swatch.className = 'data-toggle-legend-swatch'; swatch.style.background = item.color; const text = document.createElement('span'); - text.textContent = `${item.label} ${this._formatCount(item.count)}`; + text.textContent = item.count == null ? String(item.label) : `${item.label} ${this._formatCount(item.count)}`; entry.append(swatch, text); container.appendChild(entry); } + if (controls?.info) { + const info = document.createElement('div'); + info.className = 'data-toggle-controls-info'; + info.textContent = String(controls.info); + if (controls.infoTitle) info.title = controls.infoTitle; + container.appendChild(info); + } } /** diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 7b2ce0a69..955a97b58 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -888,3 +888,12 @@ opacity: 0.75; font-style: italic; } + +.data-toggle-controls-info { + flex-basis: 100%; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-secondary); + padding-top: 3px; + overflow-wrap: anywhere; +} From 430dcd4c5525dff9b9ed66c4a27981cce284bb8b Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:30:29 -0500 Subject: [PATCH 07/11] fix(wind): decode in bounded virtual storage and test real canvas retention --- CHANGELOG.md | 1 - DATA_SOURCES.md | 1 - scripts/package-boundaries.json | 3 ++ scripts/qa-wind-canvas.mjs | 44 ++++++++++++++++++++++++ server/providers/wind/decode.js | 60 +++++++++++---------------------- src/data/wind.js | 2 +- src/data/windDecode.test.mjs | 9 +++++ src/ui/layerPanel.js | 16 +++++++-- 8 files changed, 89 insertions(+), 47 deletions(-) create mode 100644 scripts/qa-wind-canvas.mjs create mode 100644 src/data/windDecode.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8892711cd..74d2412c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -901,4 +901,3 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. - diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8364515b5..73bbf4b84 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -201,4 +201,3 @@ Douglas-Peucker simplification, 6-decimal rounding). ## In-app attribution The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. - diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 534d643be..369dbda15 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1667,6 +1667,9 @@ "src/annotations/resolver.js", "src/app/catalog.js", "src/app/constructCatalog.js", + "src/layers/wind/index.js", + "src/layers/wind/model.js", + "src/layers/wind/rendering.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", diff --git a/scripts/qa-wind-canvas.mjs b/scripts/qa-wind-canvas.mjs new file mode 100644 index 000000000..f29f60e3e --- /dev/null +++ b/scripts/qa-wind-canvas.mjs @@ -0,0 +1,44 @@ +/** Real-canvas wind regression. Run against this candidate's dev server. */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +const browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || await puppeteer.executablePath(), headless: false, args: ['--no-sandbox', '--disable-dev-shm-usage'] }); +try { + const page = await browser.newPage(); + await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/`, { waitUntil: 'domcontentloaded' }); + const result = await page.evaluate(async () => { + const { createWindRendering } = await import('/src/layers/wind/rendering.js'); + const raf = window.requestAnimationFrame; const caf = window.cancelAnimationFrame; + const callbacks = new Map(); let sequence = 0; + window.requestAnimationFrame = callback => { callbacks.set(++sequence, callback); return sequence; }; + window.cancelAnimationFrame = id => callbacks.delete(id); + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:600px;z-index:9999;background:#102030'; + document.body.appendChild(container); + const sceneCanvas = { clientWidth: 800, clientHeight: 600 }; + const viewer = { scene: { canvas: sceneCanvas, camera: {} }, isDestroyed: () => false }; + const cesium = { Cartesian3: { fromDegrees: (lon,lat) => ({ lon,lat }) }, SceneTransforms: { worldToWindowCoordinates: (scene, point) => ({ x: 400 + point.lon, y: 300 + point.lat }) } }; + const rendering = createWindRendering({ cesium, container, getViewer: () => viewer }); + try { + rendering.attach(); rendering.start(); + const idleCallbacks = callbacks.size; + rendering.setField({ grid: { nx:1,ny:1,lo1:0,la1:90,dx:360,dy:180 }, u: new Float32Array([10]), v: new Float32Array([0]) }); + const canvas = container.querySelector('canvas'); const context = canvas.getContext('2d'); + const tick = time => { const [id, callback] = callbacks.entries().next().value; callbacks.delete(id); callback(time); }; + tick(16); // First camera signature clears the surface. + context.fillStyle = '#ff0000'; context.fillRect(10,10,5,5); + tick(32); + const alpha = context.getImageData(12,12,1,1).data[3]; + const count = rendering.getParticleCount(); + sceneCanvas.clientWidth = 320; sceneCanvas.clientHeight = 240; + tick(48); + const resizedCount = rendering.getParticleCount(); + rendering.stop(); rendering.clear(); + return { idleCallbacks, alpha, count, resizedCount, pending: callbacks.size, afterClear: context.getImageData(12,12,1,1).data[3] }; + } finally { rendering.destroy(); container.remove(); window.requestAnimationFrame = raf; window.cancelAnimationFrame = caf; } + }); + assert.equal(result.idleCallbacks, 0); + assert.ok(result.alpha > 200, 'unchanged canvas dimensions preserve existing trail pixels'); + assert.ok(result.resizedCount < result.count); + assert.equal(result.pending, 0); assert.equal(result.afterClear, 0); + console.log(JSON.stringify(result)); +} finally { await browser.close(); } diff --git a/server/providers/wind/decode.js b/server/providers/wind/decode.js index 7f137ae68..359368791 100644 --- a/server/providers/wind/decode.js +++ b/server/providers/wind/decode.js @@ -1,51 +1,29 @@ -import os from 'node:os'; -import path from 'node:path'; -import { promises as fs } from 'node:fs'; - -/** - * 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} - */ +/** Lazy shared ecCodes WASM instance, created only for an active wind request. */ 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(); - })(); + eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm').then(module => module.createEccodes()).catch(error => { + eccodesPromise = null; + throw error; + }); 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 directory = await fs.mkdtemp(path.join(mountDir, 'gev-wind-')); - const file = path.join(directory, 'wind.grib2'); - await fs.writeFile(file, buffer, { mode: 0o600 }); +/** Decode one bounded message in WASM memory without mounting the host filesystem. */ +export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) { + if (buffer.byteLength > 8 * 1024 * 1024) throw new Error('Wind GRIB exceeds byte budget'); + const eccodes = eccodesModule ?? await loadEccodes(); + // Everything after this await is synchronous. A shared virtual file is safe + // across U/V and model requests and its storage is released after each decode. + const file = '/gev-wind.grib2'; let handle = null; try { - eccodes.mountFilesystem(directory); + eccodes.writeFile(file, buffer); handle = eccodes.openGrib(file); + const ni = handle.getLong('Ni'); + const nj = handle.getLong('Nj'); + if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000) throw new Error('Invalid wind GRIB geometry'); return { - ni: handle.getLong('Ni'), - nj: handle.getLong('Nj'), + ni, nj, lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'), la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'), di: handle.getDouble('iDirectionIncrementInDegrees'), @@ -56,7 +34,7 @@ export async function decodeWindGribMessage( units: handle.getString('units'), }; } finally { - if (handle) handle.delete(); - await fs.rm(directory, { recursive: true, force: true }).catch(() => {}); + handle?.delete(); + eccodes.writeFile(file, new Uint8Array(0)); } } diff --git a/src/data/wind.js b/src/data/wind.js index 2a7a80894..2529003fb 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -1,4 +1,4 @@ -import { createSourceSlot } from '../app/sourceSlot.js'; +import { createSourceSlot } from '../sources/sourceSlot.js'; import { createWindLayer as createLayer } from '../layers/wind/index.js'; import { createWindSource } from '../layers/wind/source.js'; diff --git a/src/data/windDecode.test.mjs b/src/data/windDecode.test.mjs new file mode 100644 index 000000000..173ff97eb --- /dev/null +++ b/src/data/windDecode.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { decodeWindGribMessage } from '../../server/providers/wind/decode.js'; +test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => { + const writes = []; let deleted = false; + const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) }; + await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/); + assert.deepEqual(writes, [20,0]); assert.equal(deleted, true); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ed96d9713..a7be82176 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -226,11 +226,18 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0 && !controls?.info; + container.hidden = + chips.length === 0 && legend.length === 0 && !controls?.info; for (const node of [...container.children]) { if ( - String(node.className).split(/\s+/).some((name) => ['data-toggle-legend-item', 'data-toggle-controls-info'].includes(name)) + String(node.className) + .split(/\s+/) + .some((name) => + ['data-toggle-legend-item', 'data-toggle-controls-info'].includes( + name, + ), + ) ) node.remove(); } @@ -267,7 +274,10 @@ export class LayerPanel { swatch.className = 'data-toggle-legend-swatch'; swatch.style.background = item.color; const text = document.createElement('span'); - text.textContent = item.count == null ? String(item.label) : `${item.label} ${this._formatCount(item.count)}`; + text.textContent = + item.count == null + ? String(item.label) + : `${item.label} ${this._formatCount(item.count)}`; entry.append(swatch, text); container.appendChild(entry); } From d14765c704219f15a3b4902ac9403cfb44fe411a Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:32:34 -0500 Subject: [PATCH 08/11] fix(wind): apply current ECMWF source attribution and forecast steps --- DATA_SOURCES.md | 2 +- server/providers/wind/ifs.js | 7 ++++--- src/data/dataCredits.js | 10 ++++++---- src/data/windIfs.test.mjs | 4 ++-- src/layers/wind/index.js | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 73bbf4b84..985435152 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -144,7 +144,7 @@ The former bundled 2026-05-25 snapshot was removed 2026-07-16. ### ECMWF IFS wind -Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Attribution: “Contains modified Copernicus/ECMWF IFS forecast data.” Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations. +Wind also offers ECMWF IFS 10 m forecasts from the keyless [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) service. The proxy reads its JSON Lines inventory and fetches only 10u/10v GRIB messages, resampling the forecast to the display grid. Data is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) also apply. In-app attribution identifies this service as based on ECMWF data and products, links the CC BY 4.0 licence, identifies resampling and animation as modifications, and retains the required ECMWF liability disclaimer. Both model issue time and the selected forecast valid time appear in the layer row. These are model forecasts, not observations. ### NOAA GFS wind diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 433591454..56c2f788b 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -81,15 +81,16 @@ export function ifsWindRanges(entries) { /** * Round a time offset to the nearest published IFS forecast step. IFS 0.25° - * oper is 3-hourly through f144 (00/12z) or f90 (06/18z). + * oper is 3-hourly through f144, then 6-hourly to f360 for 00/12z. * @param {number} hours - Hours elapsed since the cycle run time. * @param {number} cycleHour - The cycle hour (0, 6, 12 or 18). * @returns {number} A valid forecast step. */ export function nearestIfsStep(hours, cycleHour) { - const max = cycleHour === 6 || cycleHour === 18 ? 90 : 144; + const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360; const value = Math.max(0, Number.isFinite(hours) ? hours : 0); - return Math.max(0, Math.min(max, Math.round(value / 3) * 3)); + const step = value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6; + return Math.max(0, Math.min(max, step)); } /** diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 1b3c22cda..88e13c018 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -200,16 +200,18 @@ export const DATA_CREDITS = [ { key: 'wind-gfs', html: - 'Wind: NOAA Global Forecast System (GFS) 10 m wind via ' + + 'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' + 'NOAA Open Data on AWS ' + '(U.S. public domain; forecast, not observations)', }, { key: 'ecmwf-ifs', html: - 'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' + - 'Source: https://data.ecmwf.int/forecasts/ ' + - '(ECMWF Open Data)', + 'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' + + 'ECMWF Open Data · ' + + 'CC BY 4.0. ' + + 'Modified: resampled 10 m IFS forecast vectors and animated display. ' + + 'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.', }, // ── Bundled snapshots ─────────────────────────────────────────── { diff --git a/src/data/windIfs.test.mjs b/src/data/windIfs.test.mjs index 7f98c808d..25c21ba38 100644 --- a/src/data/windIfs.test.mjs +++ b/src/data/windIfs.test.mjs @@ -4,9 +4,9 @@ import { ifsObjectUrls, parseIfsIndex, ifsWindRanges, nearestIfsStep, selectLate import { nearestGfsStep } from '../../server/providers/wind/catalog.js'; test('IFS inventory selects only 10 m components and distinct index suffix', () => { const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 }); - assert.match(urls.index, /09000|9h-oper-fc.index$/); + assert.match(urls.index, /9h-oper-fc\.index$/); assert.equal(urls.index.includes('.grib2.index'), false); assert.deepEqual(ifsWindRanges(parseIfsIndex('{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}')), { u: {start:20,end:29}, v:{start:40,end:54} }); assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026,8,15,4)), { date:'20260914', hour:18 }); - assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestGfsStep(8.2),8); + assert.equal(nearestIfsStep(8,6),9); assert.equal(nearestIfsStep(200,6),144); assert.equal(nearestIfsStep(151,0),150); assert.equal(nearestGfsStep(8.2),8); }); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 105190467..ee3925503 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -89,7 +89,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender getRowControls() { const valid = formatWindValidTime(manifest?.cycle?.validIso); const run = formatWindValidTime(manifest?.cycle?.runIso); - return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model.toUpperCase()} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; }, setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; }, From ca9d1640a057612ea0c8beb679231585d96f5958 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:33:26 -0500 Subject: [PATCH 09/11] test(wind): include wind in current catalog registration counts --- src/app/constructCatalog.test.mjs | 2 +- src/data/layerState.test.mjs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index d25ad45ee..ba434ef61 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -38,7 +38,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 18); + assert.equal(first.layers.length, 19); assert.deepEqual( first.layers.map(({ id }) => id), second.layers.map(({ id }) => id), diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index f985eb71a..cd6f3924e 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -158,8 +158,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, 18); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18); + assert.equal(REGISTERED_LAYER_IDS.length, 19); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 19); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), From ed2ca6ac6c24791c6119696eaa010d282a6d3a68 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:34:23 -0500 Subject: [PATCH 10/11] test(wind): verify shared acquisition cancels after final disconnect --- src/data/windProxy.test.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs index 711b19bfb..d60d2a33b 100644 --- a/src/data/windProxy.test.mjs +++ b/src/data/windProxy.test.mjs @@ -233,3 +233,21 @@ test('wind failure backoff prevents unbounded repeated upstream acquisition', as assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable'); await request('/'); assert.equal(calls, 1); }); + +test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => { + const { EventEmitter } = await import('node:events'); + let handler; let signal; let loads = 0; + const plugin = proxy({ models: { gfs: ({ signal: next }) => { + loads++; signal = next; + return new Promise((resolve, reject) => next.addEventListener('abort', () => reject(next.reason), { once: true })); + } } }); + plugin.configureServer({ middlewares: { use: (_path, value) => { handler = value; } } }); + const first = new EventEmitter(); const second = new EventEmitter(); + const a = handler({ url: '/manifest', method: 'GET' }, first); + const b = handler({ url: '/manifest', method: 'GET' }, second); + assert.equal(loads, 1); + first.emit('close'); assert.equal(signal.aborted, false); + second.emit('close'); assert.equal(signal.aborted, true); + await Promise.all([a,b]); + assert.equal(first.listenerCount('close'), 0); assert.equal(second.listenerCount('close'), 0); +}); From 9ae79cf34adf348b91b82694995987d1bb5b0747 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 10:54:09 -0500 Subject: [PATCH 11/11] style(wind): adopt automatically discovered runtime formatting --- server/providers/wind.js | 121 ++++++++++++++++++++++----- server/providers/wind/catalog.js | 9 +- server/providers/wind/decode.js | 27 ++++-- server/providers/wind/gfs.js | 62 ++++++++++++-- server/providers/wind/grid.js | 17 +++- server/providers/wind/ifs.js | 3 +- src/data/layerState.js | 7 +- src/data/wind.js | 6 +- src/layers/wind/index.js | 136 +++++++++++++++++++++++++------ src/layers/wind/model.js | 30 +++++-- src/layers/wind/rendering.js | 29 +++++-- src/layers/wind/source.js | 62 +++++++++++--- src/sources/windBody.js | 9 +- 13 files changed, 421 insertions(+), 97 deletions(-) diff --git a/server/providers/wind.js b/server/providers/wind.js index c305a93d0..42b5e37cb 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -3,14 +3,33 @@ import { fetchIfsWind } from './wind/ifs.js'; import { decodeWindGribMessage } from './wind/decode.js'; /** Serve bounded, single-flight, per-model forecast snapshots from fixed providers. */ -export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, timeoutMs = 40_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) { +export function windProxy({ + fetchImpl = fetch, + now = () => Date.now(), + decodeImpl = decodeWindGribMessage, + targetDx = 1, + ttlMs = 3600_000, + timeoutMs = 40_000, + models = { gfs: fetchGfsWind, ifs: fetchIfsWind }, +} = {}) { const caches = new Map(); const loadings = new Map(); const grids = new Map(); const attempts = new Map(); - const unavailable = (model) => ({ manifest: { model, schemaVersion: 1, unavailable: true, stale: true, reason: 'Wind upstream unavailable' } }); + const unavailable = (model) => ({ + manifest: { + model, + schemaVersion: 1, + unavailable: true, + stale: true, + reason: 'Wind upstream unavailable', + }, + }); const sendJson = (res, value, status = 200) => { - res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify(value)); }; function refresh(model) { @@ -21,15 +40,36 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp operation.promise = (async () => { const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const value = await models[model]({ fetchImpl, now, decodeImpl, targetDx, signal: controller.signal }); + const value = await models[model]({ + fetchImpl, + now, + decodeImpl, + targetDx, + signal: controller.signal, + }); controller.signal.throwIfAborted(); const { grid, cycle } = value; - if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) throw new Error('Invalid wind grid'); + if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) + throw new Error('Invalid wind grid'); const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`; const manifest = { - schemaVersion: 1, model, cycle, fetchedAt: now(), level: value.level, units: value.units, - 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, + schemaVersion: 1, + model, + cycle, + fetchedAt: now(), + level: value.level, + units: value.units, + 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/${id}.bin?model=${model}`, }; const state = { id, grid, manifest, fetchedAt: now() }; @@ -47,7 +87,11 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp } const old = caches.get(model); if (old) { - old.manifest = { ...old.manifest, stale: true, reason: 'Wind upstream unavailable' }; + old.manifest = { + ...old.manifest, + stale: true, + reason: 'Wind upstream unavailable', + }; return old; } return unavailable(model); @@ -62,38 +106,71 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp const handler = async (req, res) => { const url = new URL(req.url, 'http://localhost'); const model = url.searchParams.get('model') || 'gfs'; - if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405); - if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) return sendJson(res, { error: 'unknown_model' }, 400); + if (req.method !== 'GET') + return sendJson(res, { error: 'method_not_allowed' }, 405); + if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) + return sendJson(res, { error: 'unknown_model' }, 400); if (url.pathname.startsWith('/grid/')) { const id = url.pathname.slice(6).replace(/\.bin$/, ''); - const state = url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id); + const state = + url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id); if (!state) return sendJson(res, { error: 'unknown_grid' }, 404); - const bytes = 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' }); + const bytes = 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(bytes); } - if (!['/', '/manifest', '/status'].includes(url.pathname)) return sendJson(res, { error: 'not_found' }, 404); + if (!['/', '/manifest', '/status'].includes(url.pathname)) + return sendJson(res, { error: 'not_found' }, 404); let state = caches.get(model); if (!state || now() - state.fetchedAt >= ttlMs) { let operation = loadings.get(model); - if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) operation = refresh(model); + if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) + operation = refresh(model); if (operation) { let disconnected = false; operation.waiters += 1; - const close = () => { disconnected = true; if (--operation.waiters === 0) operation.controller.abort(); }; + const close = () => { + disconnected = true; + if (--operation.waiters === 0) operation.controller.abort(); + }; res.once?.('close', close); - try { state = await operation.promise; } - finally { res.removeListener?.('close', close); if (!disconnected) operation.waiters -= 1; } + try { + state = await operation.promise; + } finally { + res.removeListener?.('close', close); + if (!disconnected) operation.waiters -= 1; + } if (disconnected) return; } } const manifest = state?.manifest || unavailable(model).manifest; - if (url.pathname === '/status') { const { gridUrl, ...status } = manifest; return sendJson(res, status); } + if (url.pathname === '/status') { + const { gridUrl, ...status } = manifest; + return sendJson(res, status); + } return sendJson(res, manifest); }; return { name: 'wind', - configureServer({ middlewares }) { middlewares.use('/api/wind', handler); }, - configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); }, + 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 index f78e9056f..445953f7f 100644 --- a/server/providers/wind/catalog.js +++ b/server/providers/wind/catalog.js @@ -7,9 +7,14 @@ export function gfsObjectKey({ date, hour, forecastHour = 0 }) { } /** Select the latest GFS cycle that should be available. */ -export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } = {}) { +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 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 index 359368791..dbc756404 100644 --- a/server/providers/wind/decode.js +++ b/server/providers/wind/decode.js @@ -1,17 +1,20 @@ /** Lazy shared ecCodes WASM instance, created only for an active wind request. */ let eccodesPromise = null; function loadEccodes() { - eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm').then(module => module.createEccodes()).catch(error => { - eccodesPromise = null; - throw error; - }); + eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm') + .then((module) => module.createEccodes()) + .catch((error) => { + eccodesPromise = null; + throw error; + }); return eccodesPromise; } /** Decode one bounded message in WASM memory without mounting the host filesystem. */ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) { - if (buffer.byteLength > 8 * 1024 * 1024) throw new Error('Wind GRIB exceeds byte budget'); - const eccodes = eccodesModule ?? await loadEccodes(); + if (buffer.byteLength > 8 * 1024 * 1024) + throw new Error('Wind GRIB exceeds byte budget'); + const eccodes = eccodesModule ?? (await loadEccodes()); // Everything after this await is synchronous. A shared virtual file is safe // across U/V and model requests and its storage is released after each decode. const file = '/gev-wind.grib2'; @@ -21,9 +24,17 @@ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) { handle = eccodes.openGrib(file); const ni = handle.getLong('Ni'); const nj = handle.getLong('Nj'); - if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000) throw new Error('Invalid wind GRIB geometry'); + if ( + !Number.isInteger(ni) || + !Number.isInteger(nj) || + ni < 1 || + nj < 1 || + ni * nj > 2_000_000 + ) + throw new Error('Invalid wind GRIB geometry'); return { - ni, nj, + ni, + nj, lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'), la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'), di: handle.getDouble('iDirectionIncrementInDegrees'), diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index 36c49ce70..403728ba4 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -41,7 +41,10 @@ export function parseGfsIdx(text) { * @param {{level?: string}} [options] * @returns {{u: {start: number, end: number}, v: {start: number, end: number}}} */ -export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) { +export function windMessageRanges( + parsed, + { level = '10 m above ground' } = {}, +) { const range = (variable) => { const index = parsed.messages.findIndex( (message) => message.variable === variable && message.level === level, @@ -57,7 +60,10 @@ export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) /** Fetch a bounded text response as a Buffer. */ export async function fetchText({ url, fetchImpl = fetch, signal }) { const response = await fetchImpl(url, { signal, redirect: 'error' }); - if (!response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } + if (!response.ok) { + await response.body?.cancel(); + throw new Error('Wind upstream unavailable'); + } return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal)); } @@ -73,7 +79,13 @@ export async function fetchRange({ signal, maxBytes = 8 * 1024 * 1024, }) { - if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end - start + 1 > maxBytes) + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + end - start + 1 > maxBytes + ) throw new Error('range too large'); const response = await fetchImpl(url, { signal, @@ -82,19 +94,35 @@ export async function fetchRange({ }); const validStatus = response.status === 206 || (response.status === 200 && start === 0); - if (!validStatus || !response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } - const buffer = Buffer.from(await readWindBody(response, end - start + 1, signal)); + if (!validStatus || !response.ok) { + await response.body?.cancel(); + throw new Error('Wind upstream unavailable'); + } + const buffer = Buffer.from( + await readWindBody(response, end - start + 1, signal), + ); if (buffer.length !== end - start + 1) throw new Error('invalid range length'); return buffer; } -import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js'; +import { + GFS_BUCKET, + gfsObjectKey, + selectLatestGfsCycle, + nearestGfsStep, +} from './catalog.js'; import { decodeWindGribMessage } from './decode.js'; import { resampleWindGrid } from './grid.js'; /** Fetch and decode the GFS 10 m wind field valid closest to now. */ -export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage, signal } = {}) { +export async function fetchGfsWind({ + fetchImpl = fetch, + now = () => Date.now(), + targetDx = 1, + decodeImpl = decodeWindGribMessage, + signal, +} = {}) { const nowMs = now(); const cycle = selectLatestGfsCycle(nowMs); const runMs = Date.UTC( @@ -114,8 +142,24 @@ export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), fetchRange({ url: base, ...ranges.v, fetchImpl, signal }), ]); 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 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 runIso = new Date(runMs).toISOString(); const validIso = new Date(runMs + forecastHour * 3600_000).toISOString(); - return { cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid }; + return { + cycle: { ...cycle, forecastHour, runIso, validIso }, + level: '10 m above ground', + units: 'm/s', + grid, + }; } diff --git a/server/providers/wind/grid.js b/server/providers/wind/grid.js index 9125e1f10..c610fee17 100644 --- a/server/providers/wind/grid.js +++ b/server/providers/wind/grid.js @@ -21,7 +21,22 @@ export function resampleWindGrid({ dx = 1, dy = 1, }) { - if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000 || u?.length !== ni * nj || v?.length !== ni * nj || ![lo1, la1, di, dj, dx, dy].every(Number.isFinite) || di <= 0 || dj <= 0 || dx < 0.25 || dy < 0.25 || dx > 180 || dy > 180) + if ( + !Number.isInteger(ni) || + !Number.isInteger(nj) || + ni < 1 || + nj < 1 || + ni * nj > 2_000_000 || + u?.length !== ni * nj || + v?.length !== ni * nj || + ![lo1, la1, di, dj, dx, dy].every(Number.isFinite) || + di <= 0 || + dj <= 0 || + dx < 0.25 || + dy < 0.25 || + dx > 180 || + dy > 180 + ) throw new Error('Invalid wind grid geometry'); const nx = Math.round(360 / dx); const ny = Math.round(180 / dy) + 1; diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 56c2f788b..648dba43a 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -89,7 +89,8 @@ export function ifsWindRanges(entries) { export function nearestIfsStep(hours, cycleHour) { const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360; const value = Math.max(0, Number.isFinite(hours) ? hours : 0); - const step = value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6; + const step = + value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6; return Math.max(0, Math.min(max, step)); } diff --git a/src/data/layerState.js b/src/data/layerState.js index c74c951d2..5575a12db 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -387,7 +387,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ }), Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }), Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }), - Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }), + Object.freeze({ + id: 'wind', + token: 'k', + disposition: 'enabled+options', + optionOwner: 'wind', + }), ]); export const REGISTERED_LAYER_IDS = Object.freeze( diff --git a/src/data/wind.js b/src/data/wind.js index 2529003fb..38bc10688 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -2,7 +2,11 @@ import { createSourceSlot } from '../sources/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'); +const sourceSlot = createSourceSlot( + createWindSource(), + ['getSnapshot'], + 'Wind source', +); export const configureWindSource = sourceSlot.configure; /** Create a wind layer using the configured application source by default. */ diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index ee3925503..588e171dc 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -4,7 +4,9 @@ import { createWindRendering } from './rendering.js'; /** Format a forecast timestamp explicitly in UTC. */ export function formatWindValidTime(value) { const ms = Date.parse(value); - return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : null; + return Number.isFinite(ms) + ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` + : null; } /** Forecast issue time is source age; valid time is reported separately. */ @@ -13,13 +15,20 @@ export function windStats(manifest) { return { count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0, lastUpdate: Number.isFinite(run) ? run : null, - error: manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null), + error: + manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null), }; } /** Construct one wind layer with an explicit source and owned animation. */ -export function createWindLayer({ feed, cesium = Cesium, container, createRendering = createWindRendering } = {}) { - if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); +export function createWindLayer({ + feed, + cesium = Cesium, + container, + createRendering = createWindRendering, +} = {}) { + if (typeof feed?.getSnapshot !== 'function') + throw new TypeError('Wind requires a snapshot source'); let viewer = null; let request = null; let enabled = false; @@ -32,18 +41,32 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender let rowControlsListener = null; const notify = () => rowControlsListener?.(); const layer = { - id: 'wind', name: 'Wind', icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000, + id: 'wind', + name: 'Wind', + icon: '🌬', + source: 'GFS / ECMWF IFS · FORECAST', + updateInterval: 3600_000, init(nextViewer) { viewer = nextViewer; - rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer }); + rendering = createRendering({ + cesium, + container: container ?? nextViewer.container, + getViewer: () => viewer, + }); rendering.attach(); }, - enable() { enabled = true; rendering?.start(); }, + enable() { + enabled = true; + rendering?.start(); + }, disable() { enabled = false; generation += 1; - request?.abort(); request = null; loading = false; - rendering?.stop(); rendering?.clear(); + request?.abort(); + request = null; + loading = false; + rendering?.stop(); + rendering?.clear(); }, async update(nextViewer, { signal } = {}) { if (!enabled) return false; @@ -57,12 +80,21 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender notify(); try { signal?.throwIfAborted(); - const snapshot = await feed.getSnapshot({ signal: controller.signal, model }); - if (!enabled || controller.signal.aborted || request !== controller) return false; + const snapshot = await feed.getSnapshot({ + signal: controller.signal, + model, + }); + if (!enabled || controller.signal.aborted || request !== controller) + return false; manifest = snapshot; error = null; - if (snapshot.unavailable) { rendering.stop(); rendering.clear(); } - else { rendering.setField(snapshot); rendering.start(); } + if (snapshot.unavailable) { + rendering.stop(); + rendering.clear(); + } else { + rendering.setField(snapshot); + rendering.start(); + } return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; @@ -70,31 +102,81 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender return true; } finally { signal?.removeEventListener('abort', abort); - if (request === controller) { request = null; loading = false; notify(); } + if (request === controller) { + request = null; + loading = false; + notify(); + } } }, setParams(params = {}) { - if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return; + if (!['gfs', 'ifs'].includes(params.model) || params.model === model) + return; model = params.model; - request?.abort(); request = null; - manifest = null; error = null; loading = enabled; - rendering?.stop(); rendering?.clear(); + request?.abort(); + request = null; + manifest = null; + error = null; + loading = enabled; + rendering?.stop(); + rendering?.clear(); const current = ++generation; notify(); - if (enabled) queueMicrotask(() => { - if (enabled && generation === current) void layer.update(viewer); - }); + if (enabled) + queueMicrotask(() => { + if (enabled && generation === current) void layer.update(viewer); + }); + }, + getParams() { + return { model }; }, - getParams() { return { model }; }, getRowControls() { const valid = formatWindValidTime(manifest?.cycle?.validIso); const run = formatWindValidTime(manifest?.cycle?.runIso); - return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + return { + chips: ['gfs', 'ifs'].map((value) => ({ + id: `model-${value}`, + label: value.toUpperCase(), + active: model === value, + params: { model: value }, + title: + value === 'ifs' + ? 'ECMWF IFS 10 m forecast' + : 'NOAA GFS 10 m forecast', + })), + legend: [ + { label: '0', color: '#1e3a8a' }, + { label: '10', color: '#22d3ee' }, + { label: '20', color: '#fbbf24' }, + { label: '30+ m/s', color: '#ef4444' }, + ], + info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}`, + }; + }, + setRowControlsListener(listener) { + rowControlsListener = typeof listener === 'function' ? listener : null; + }, + destroy() { + layer.disable(); + rendering?.destroy(); + rendering = null; + viewer = null; + rowControlsListener = null; + }, + getStats() { + return { + ...windStats(manifest), + loading, + model: model.toUpperCase(), + source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', + validTime: + formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', + error: error || windStats(manifest).error, + }; + }, + getParticleCount() { + return rendering?.getParticleCount() || 0; }, - setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, - destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; }, - getStats() { return { ...windStats(manifest), loading, model: model.toUpperCase(), source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', validTime: formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', 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 index 6f489e101..efff055bd 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -1,6 +1,6 @@ /** Normalize a longitude into the half-open world interval. */ export function normalizeLongitude(lon) { - return ((lon + 180) % 360 + 360) % 360 - 180; + return ((((lon + 180) % 360) + 360) % 360) - 180; } /** Sample a north-to-south, seam-wrapped wind grid bilinearly. */ @@ -38,23 +38,41 @@ export function windSpeed(u, v) { /** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */ export function windColor(speed, { max = 30 } = {}) { - const stops = ['#1e3a8a', '#2563eb', '#22d3ee', '#34d399', '#fbbf24', '#f97316', '#ef4444']; + const stops = [ + '#1e3a8a', + '#2563eb', + '#22d3ee', + '#34d399', + '#fbbf24', + '#f97316', + '#ef4444', + ]; 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 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 } = {}) { +export function advectParticle( + particle, + wind, + dtSeconds, + { speedScale = 1 } = {}, +) { particle.lon = normalizeLongitude( - particle.lon + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat), + particle.lon + + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat), + ); + particle.lat = Math.max( + -89, + Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320), ); - particle.lat = Math.max(-89, Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320)); return particle; } diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 1a1234cda..39d035105 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -48,7 +48,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { const lon = west + Math.random() * (east - west); const lat = south + Math.random() * (north - south); return { - lon: ((lon + 180) % 360 + 360) % 360 - 180, + lon: ((((lon + 180) % 360) + 360) % 360) - 180, lat: Math.max(-89, Math.min(89, lat)), age: Math.random() * 30, }; @@ -103,12 +103,21 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { resize(viewer); if (particles.length !== budget()) seed(); const camera = viewer.scene.camera; - const signature = [camera?.positionWC?.x, camera?.positionWC?.y, camera?.positionWC?.z, camera?.heading, camera?.pitch, camera?.roll].join(','); + const signature = [ + camera?.positionWC?.x, + camera?.positionWC?.y, + camera?.positionWC?.z, + camera?.heading, + camera?.pitch, + camera?.roll, + ].join(','); if (signature !== cameraSignature) { context.clearRect(0, 0, cssWidth, cssHeight); cameraSignature = signature; } - const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016; + 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. @@ -124,8 +133,15 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { 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); + 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 } @@ -194,7 +210,8 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (canvas) { resize(getViewer?.()); seed(); - if (running && frame === null) frame = globalThis.requestAnimationFrame(draw); + if (running && frame === null) + frame = globalThis.requestAnimationFrame(draw); } }, /** Start the animation loop. Safe to call before a field is installed. */ diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 2ec49812c..d8ce606d0 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -1,32 +1,72 @@ import { readWindBody } from '../../sources/windBody.js'; /** Create a bounded, cancellable source for the same-origin wind provider. */ -export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) { +export function createWindSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + timeoutMs = 45_000, +} = {}) { return { async getSnapshot({ signal, model = 'gfs' } = {}) { - if (!['gfs', 'ifs'].includes(model)) throw new Error('Unknown wind model'); + if (!['gfs', 'ifs'].includes(model)) + throw new Error('Unknown wind model'); const controller = new AbortController(); const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); - const timer = setTimeout(() => controller.abort(new Error('Wind request timed out')), timeoutMs); + const timer = setTimeout( + () => controller.abort(new Error('Wind request timed out')), + timeoutMs, + ); const active = controller.signal; try { signal?.throwIfAborted(); - const response = await fetchImpl(`/api/wind/manifest?model=${model}`, { signal: active, cache: 'no-store', redirect: 'error' }); + const response = await fetchImpl(`/api/wind/manifest?model=${model}`, { + signal: active, + cache: 'no-store', + redirect: 'error', + }); if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); - const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active))); + const manifest = JSON.parse( + new TextDecoder().decode( + await readWindBody(response, 16_384, active), + ), + ); if (manifest?.unavailable) return manifest; const grid = manifest?.grid; - if (manifest?.model !== model || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !new RegExp(`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`).test(manifest.gridUrl)) + if ( + manifest?.model !== model || + !grid || + !Number.isInteger(grid.nx) || + !Number.isInteger(grid.ny) || + grid.nx < 1 || + grid.ny < 1 || + grid.nx * grid.ny > 1_000_000 || + ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || + grid.dx <= 0 || + grid.dy <= 0 || + Math.abs(grid.nx * grid.dx - 360) > 0.01 || + !new RegExp( + `^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`, + ).test(manifest.gridUrl) + ) throw new Error('Malformed wind manifest'); const count = grid.nx * grid.ny; - const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' }); - if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`); + const gridResponse = await fetchImpl(manifest.gridUrl, { + signal: active, + redirect: 'error', + }); + if (!gridResponse.ok) + throw new Error(`Wind HTTP ${gridResponse.status}`); const bytes = await readWindBody(gridResponse, count * 8, active); - if (bytes.byteLength !== count * 8) throw new Error('Malformed wind grid'); + if (bytes.byteLength !== count * 8) + throw new Error('Malformed wind grid'); const values = new Float32Array(bytes.buffer); - if (!values.every(Number.isFinite)) throw new Error('Malformed wind grid'); - return { ...manifest, u: values.slice(0, count), v: values.slice(count) }; + if (!values.every(Number.isFinite)) + throw new Error('Malformed wind grid'); + return { + ...manifest, + u: values.slice(0, count), + v: values.slice(count), + }; } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); diff --git a/src/sources/windBody.js b/src/sources/windBody.js index d6287ccb3..0aa1a0e22 100644 --- a/src/sources/windBody.js +++ b/src/sources/windBody.js @@ -9,7 +9,9 @@ export async function readWindBody(response, maxBytes, signal) { if (!reader) throw new Error('Wind response has no readable body'); const chunks = []; let size = 0; - const cancel = () => { void reader.cancel().catch(() => {}); }; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; signal?.addEventListener('abort', cancel, { once: true }); try { while (true) { @@ -23,7 +25,10 @@ export async function readWindBody(response, maxBytes, signal) { } const bytes = new Uint8Array(size); let offset = 0; - for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } return bytes; } catch (error) { cancel();