From bb18e39b7d393a887386803d822abd60923a6ea4 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 13:04:35 -0300 Subject: [PATCH 1/3] 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 8db53063a98e0008eca9ce4cc0163ad453753655 Mon Sep 17 00:00:00 2001 From: Gustavo Beneduzi Date: Mon, 14 Sep 2026 14:32:22 -0300 Subject: [PATCH 2/3] 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 3/3] 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; +}