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();