From dcb3339070e0b295494a6b11068a8d5ec3c1a4d0 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 02:21:09 -0500 Subject: [PATCH] fix(wind): bound GFS acquisition and preserve canvas trails and lifecycle --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 2 + scripts/package-boundaries.json | 1 + server/providers/wind.js | 37 ++++++++++++++---- server/providers/wind/decode.js | 11 +++--- server/providers/wind/gfs.js | 15 +++---- server/providers/wind/grid.js | 2 + src/data/windGfs.test.mjs | 8 ++++ src/data/windProxy.test.mjs | 2 +- src/layers/wind/index.js | 63 +++++++++++++++++++++++------- src/layers/wind/rendering.js | 29 +++++++++----- src/layers/wind/rendering.test.mjs | 26 +++++++++--- src/layers/wind/source.js | 58 ++++++++++++++------------- src/layers/wind/source.test.mjs | 39 +++++++++--------- src/sources/windBody.js | 35 +++++++++++++++++ src/ui/layerPanel.js | 13 ++++-- src/ui/styles/radio.css | 9 +++++ 17 files changed, 251 insertions(+), 101 deletions(-) create mode 100644 src/sources/windBody.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db1b7181..30b32ce55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -227,7 +227,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md and are corrected with the calibration gizmo. `CCTV_CALGARY_MAX_SOURCES` sets the cap and `CCTV_CALGARY_ENABLED=0` turns the pack off. -- Add a keyless **Wind** layer from NOAA GFS 10 m wind. The `/api/wind` proxy +- Add a keyless **Wind** layer from NOAA GFS 10 m wind (#459, thanks @beneduzi). The `/api/wind` proxy byte-range fetches only the UGRD/VGRD GRIB2 messages from the public AWS bucket, decodes them with ecCodes (WASM), and serves a compact Float32 U/V grid; the client renders nullschool-style animated particles in a canvas overlay that diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 444924e87..9a27d6cfb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Wind is an optional GFS 10 m forecast overlay. Source acquisition has deadline and body budgets, disconnect cancellation and one-minute failure backoff. The row distinguishes model issue time from forecast valid time. Animation starts only with a field, stops on disable/destruction, preserves trails between unchanged canvas dimensions and scales from 200 to 4,000 particles with viewport area. Application catalog construction owns each instance. + Place search accepts an explicit Nominatim provider with independently configured search and reverse endpoints. The default offline/Google/Photon/local-fallback order is unchanged when no provider is selected. Provider adapters share normalized coordinates, viewport framing and reverse labels; roads and boundary geometry remain separate services. Portable capped-response and Overpass lexical helpers are exported independently of the Node server. Reference feed construction is exported through `sources/reference`; the cable source also has a dedicated `layers/submarine-cables/source` entry. Standalone catalog compatibility remains available. Source choices, data and attribution are unchanged. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a0fb1791..534d643be 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1192,6 +1192,7 @@ "src/layers/wind/model.js", "src/layers/wind/rendering.js", "src/layers/wind/source.js", + "src/sources/windBody.js", "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", diff --git a/server/providers/wind.js b/server/providers/wind.js index 321717728..ae1ef77b3 100644 --- a/server/providers/wind.js +++ b/server/providers/wind.js @@ -40,6 +40,9 @@ export function windProxy({ let cached = null; /** @type {?Promise} */ let loading = null; + let lastAttempt = -Infinity; + let controller = null; + let waiters = 0; const sendJson = (res, value, status = 200) => { res.writeHead(status, { @@ -58,14 +61,19 @@ export function windProxy({ if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs) return cached; if (loading) return loading; + if (cached && now() - lastAttempt < 60_000) return cached; + lastAttempt = now(); + controller = new AbortController(); + const signal = controller.signal; + const timer = setTimeout(() => controller.abort(), 40_000); loading = (async () => { try { const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; - const index = await fetchText({ url: `${base}.idx`, fetchImpl }); + const index = await fetchText({ url: `${base}.idx`, fetchImpl, signal }); const ranges = windMessageRanges(parseGfsIdx(index.toString())); const [uBuffer, vBuffer] = await Promise.all([ - fetchRange({ url: base, ...ranges.u, fetchImpl }), - fetchRange({ url: base, ...ranges.v, fetchImpl }), + fetchRange({ url: base, ...ranges.u, fetchImpl, signal }), + fetchRange({ url: base, ...ranges.v, fetchImpl, signal }), ]); const [u, v] = await Promise.all([ decodeImpl(uBuffer), @@ -83,11 +91,13 @@ export function windProxy({ dx: targetDx, dy: targetDx, }); + signal.throwIfAborted(); + if ((!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite))) throw new Error('Invalid wind grid'); const idGrid = `${id}-${targetDx}`; const manifest = { schemaVersion: 1, model: 'gfs', - cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) }, + cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) }, fetchedAt: now(), level: '10 m above ground', units: 'm/s', @@ -110,7 +120,7 @@ export function windProxy({ if (cached) { cached = { ...cached, - manifest: { ...cached.manifest, stale: true, reason: error.message }, + manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' }, }; } else { cached = { @@ -123,13 +133,15 @@ export function windProxy({ model: 'gfs', stale: true, unavailable: true, - reason: error.message, + reason: 'Wind upstream unavailable', }, }; } return cached; } finally { + clearTimeout(timer); loading = null; + controller = null; } })(); return loading; @@ -137,7 +149,18 @@ export function windProxy({ const handler = async (req, res) => { const path = new URL(req.url, 'http://localhost').pathname; - const state = await refresh(); + if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405); + if (!['/', '/manifest', '/status'].includes(path) && !/^\/grid\/[\w.-]+\.bin$/.test(path)) return sendJson(res, { error: 'not_found' }, 404); + // Grid reads never initiate acquisition; an issued manifest owns its cached grid. + if (path.startsWith('/grid/') && (!cached?.grid || path !== `/grid/${cached.idGrid}.bin`)) return sendJson(res, { error: 'unknown_grid' }, 404); + let disconnected = false; + const close = () => { disconnected = true; waiters -= 1; if (waiters === 0) controller?.abort(); }; + waiters += 1; + res.once?.('close', close); + let state; + try { state = path.startsWith('/grid/') ? cached : await refresh(); } + finally { res.removeListener?.('close', close); if (!disconnected) waiters -= 1; } + if (disconnected) return; if (path === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); diff --git a/server/providers/wind/decode.js b/server/providers/wind/decode.js index 734639f0a..7f137ae68 100644 --- a/server/providers/wind/decode.js +++ b/server/providers/wind/decode.js @@ -1,7 +1,6 @@ import os from 'node:os'; import path from 'node:path'; import { promises as fs } from 'node:fs'; -import { randomUUID } from 'node:crypto'; /** * Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm` @@ -37,12 +36,12 @@ export async function decodeWindGribMessage( { eccodesModule, mountDir = os.tmpdir() } = {}, ) { const eccodes = eccodesModule ?? (await loadEccodes()); - const name = `wind-${randomUUID()}.grib2`; - const file = path.join(mountDir, name); - await fs.writeFile(file, buffer); + const directory = await fs.mkdtemp(path.join(mountDir, 'gev-wind-')); + const file = path.join(directory, 'wind.grib2'); + await fs.writeFile(file, buffer, { mode: 0o600 }); let handle = null; try { - eccodes.mountFilesystem(mountDir); + eccodes.mountFilesystem(directory); handle = eccodes.openGrib(file); return { ni: handle.getLong('Ni'), @@ -58,6 +57,6 @@ export async function decodeWindGribMessage( }; } finally { if (handle) handle.delete(); - await fs.unlink(file).catch(() => {}); + await fs.rm(directory, { recursive: true, force: true }).catch(() => {}); } } diff --git a/server/providers/wind/gfs.js b/server/providers/wind/gfs.js index ac637a14d..d28b2faa7 100644 --- a/server/providers/wind/gfs.js +++ b/server/providers/wind/gfs.js @@ -1,3 +1,4 @@ +import { readWindBody } from '../../../src/sources/windBody.js'; /** * Parse a GFS `.idx` inventory into ordered message records. * @@ -55,9 +56,9 @@ export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) /** Fetch a bounded text response as a Buffer. */ export async function fetchText({ url, fetchImpl = fetch, signal }) { - const response = await fetchImpl(url, { signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return Buffer.from(await response.arrayBuffer()); + const response = await fetchImpl(url, { signal, redirect: 'error' }); + if (!response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } + return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal)); } /** @@ -72,17 +73,17 @@ export async function fetchRange({ signal, maxBytes = 8 * 1024 * 1024, }) { - if (end < start || end - start + 1 > maxBytes) + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end - start + 1 > maxBytes) throw new Error('range too large'); const response = await fetchImpl(url, { signal, + redirect: 'error', headers: { Range: `bytes=${start}-${end}` }, }); const validStatus = response.status === 206 || (response.status === 200 && start === 0); - if (!validStatus || !response.ok) - throw new Error(`invalid range response ${response.status}`); - const buffer = Buffer.from(await response.arrayBuffer()); + if (!validStatus || !response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); } + const buffer = Buffer.from(await readWindBody(response, end - start + 1, signal)); if (buffer.length !== end - start + 1) throw new Error('invalid range length'); return buffer; diff --git a/server/providers/wind/grid.js b/server/providers/wind/grid.js index aae81524f..9125e1f10 100644 --- a/server/providers/wind/grid.js +++ b/server/providers/wind/grid.js @@ -21,6 +21,8 @@ export function resampleWindGrid({ dx = 1, dy = 1, }) { + if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000 || u?.length !== ni * nj || v?.length !== ni * nj || ![lo1, la1, di, dj, dx, dy].every(Number.isFinite) || di <= 0 || dj <= 0 || dx < 0.25 || dy < 0.25 || dx > 180 || dy > 180) + throw new Error('Invalid wind grid geometry'); const nx = Math.round(360 / dx); const ny = Math.round(180 / dy) + 1; const outU = new Float32Array(nx * ny); diff --git a/src/data/windGfs.test.mjs b/src/data/windGfs.test.mjs index 243a3e022..e5387f115 100644 --- a/src/data/windGfs.test.mjs +++ b/src/data/windGfs.test.mjs @@ -11,3 +11,11 @@ test('catalog and index helpers', async () => { assert.deepEqual([...b], [1, 2, 3]); assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) })); }); + +test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => { + await assert.rejects(fetchRange({ url: 'x', start: -1, end: 2 }), /range too large/); + let cancelled = false; + const body = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(10)); }, cancel() { cancelled = true; } }); + await assert.rejects(fetchRange({ url: 'x', start: 0, end: 2, fetchImpl: async () => new Response(body, { status: 206 }) }), /budget/); + assert.equal(cancelled, true); +}); diff --git a/src/data/windProxy.test.mjs b/src/data/windProxy.test.mjs index f8ee9136b..3055ae52c 100644 --- a/src/data/windProxy.test.mjs +++ b/src/data/windProxy.test.mjs @@ -156,7 +156,7 @@ test('wind serves last-good with stale on upstream failure', async () => { clock += 2 * 3600_000; const degraded = JSON.parse((await request('/')).body); assert.equal(degraded.stale, true); - assert.equal(degraded.reason, 'gfs upstream down'); + assert.equal(degraded.reason, 'Wind upstream unavailable'); const frame = await request(good.gridUrl.replace('/api/wind', '')); assert.equal(frame.statusCode, 200, 'last-good grid stays addressable'); }); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 49d468caf..276c83373 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -1,17 +1,24 @@ import * as Cesium from 'cesium'; import { createWindRendering } from './rendering.js'; -/** Summarize a wind manifest for the layer status surface. */ +/** Format a forecast timestamp explicitly in UTC. */ +export function formatWindValidTime(value) { + const ms = Date.parse(value); + return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : null; +} + +/** Forecast issue time is source age; valid time is reported separately. */ export function windStats(manifest) { + const run = Date.parse(manifest?.cycle?.runIso); return { count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0, - lastUpdate: manifest?.cycle?.runIso ? Date.parse(manifest.cycle.runIso) : manifest?.fetchedAt ?? null, - error: manifest?.unavailable ? manifest.reason || 'Wind unavailable' : manifest?.reason || null, + lastUpdate: Number.isFinite(run) ? run : null, + error: manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null), }; } -/** Create the GFS wind data layer. */ -export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) { +/** Construct one wind layer with an explicit source and owned animation. */ +export function createWindLayer({ feed, cesium = Cesium, container, createRendering = createWindRendering } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); let viewer = null; let request = null; @@ -19,31 +26,57 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } = let rendering = null; let manifest = null; let error = null; + let loading = false; + let rowControlsListener = null; + const notify = () => rowControlsListener?.(); const layer = { - id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS', updateInterval: 3600_000, - init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); }, + id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS · FORECAST', updateInterval: 3600_000, + init(nextViewer) { + viewer = nextViewer; + rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer }); + rendering.attach(); + }, enable() { enabled = true; rendering?.start(); }, - disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); }, + disable() { + enabled = false; + request?.abort(); request = null; loading = false; + rendering?.stop(); rendering?.clear(); + }, async update(nextViewer, { signal } = {}) { if (!enabled) return false; request?.abort(); const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); request = controller; + loading = true; + notify(); try { - const snapshot = await feed.getSnapshot({ signal: signal || controller.signal }); - if (!enabled || controller.signal.aborted || signal?.aborted) return false; + signal?.throwIfAborted(); + const snapshot = await feed.getSnapshot({ signal: controller.signal }); + if (!enabled || controller.signal.aborted || request !== controller) return false; manifest = snapshot; error = null; - if (!snapshot.unavailable) rendering.setField(snapshot); + if (snapshot.unavailable) { rendering.stop(); rendering.clear(); } + else { rendering.setField(snapshot); rendering.start(); } return true; } catch (cause) { - if (controller.signal.aborted || signal?.aborted) return false; + if (controller.signal.aborted || request !== controller) return false; error = cause?.message || 'Wind source unavailable'; return true; - } finally { if (request === controller) request = null; } + } finally { + signal?.removeEventListener('abort', abort); + if (request === controller) { request = null; loading = false; notify(); } + } }, - destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; }, - getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; }, + getRowControls() { + const valid = formatWindValidTime(manifest?.cycle?.validIso); + const run = formatWindValidTime(manifest?.cycle?.runIso); + return { chips: [], legend: [], info: `GFS forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` }; + }, + setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; }, + destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; }, + getStats() { return { ...windStats(manifest), loading, error: error || windStats(manifest).error }; }, getParticleCount() { return rendering?.getParticleCount() || 0; }, }; return layer; diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index 9e556c6e6..1a1234cda 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -20,6 +20,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { let cssWidth = 1; let cssHeight = 1; let warned = false; + let cameraSignature = null; const randomParticle = () => ({ lon: Math.random() * 360 - 180, @@ -60,7 +61,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }; const budget = () => - Math.max(4000, Math.min(12000, Math.floor((cssWidth * cssHeight) / 150))); + Math.max(200, Math.min(4000, Math.floor((cssWidth * cssHeight) / 300))); const seed = () => { particles = []; @@ -74,8 +75,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { const sceneCanvas = viewer?.scene?.canvas; cssWidth = sceneCanvas?.clientWidth || container?.clientWidth || 1; cssHeight = sceneCanvas?.clientHeight || container?.clientHeight || 1; - canvas.width = Math.floor(cssWidth * ratio); - canvas.height = Math.floor(cssHeight * ratio); + const width = Math.floor(cssWidth * ratio); + const height = Math.floor(cssHeight * ratio); + // Assigning either dimension clears a real canvas, even if unchanged. + if (canvas.width === width && canvas.height === height) return; + canvas.width = width; + canvas.height = height; canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; context.setTransform?.(ratio, 0, 0, ratio, 0, 0); @@ -96,6 +101,13 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (!viewer || viewer.isDestroyed?.()) return; try { resize(viewer); + if (particles.length !== budget()) seed(); + const camera = viewer.scene.camera; + const signature = [camera?.positionWC?.x, camera?.positionWC?.y, camera?.positionWC?.z, camera?.heading, camera?.pitch, camera?.roll].join(','); + if (signature !== cameraSignature) { + context.clearRect(0, 0, cssWidth, cssHeight); + cameraSignature = signature; + } const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016; lastTime = time; // Fade previous trails by ERASING them (destination-out). A translucent @@ -103,10 +115,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { context.globalCompositeOperation = 'destination-out'; context.fillStyle = 'rgba(0, 0, 0, 0.02)'; context.fillRect(0, 0, cssWidth, cssHeight); - if (!field) { - frame = globalThis.requestAnimationFrame(draw); - return; - } + if (!field) return; const scene = viewer.scene; // Real wind (m/s) is invisible at globe scale, so the advection is // exaggerated in proportion to camera height: the same visual speed is @@ -181,10 +190,11 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }, /** Install a wind field and (re)seed particles. */ setField(next) { - field = next; + field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next; if (canvas) { resize(getViewer?.()); seed(); + if (running && frame === null) frame = globalThis.requestAnimationFrame(draw); } }, /** Start the animation loop. Safe to call before a field is installed. */ @@ -192,7 +202,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { if (running || !canvas) return; running = true; lastTime = 0; - frame = globalThis.requestAnimationFrame(draw); + if (field) frame = globalThis.requestAnimationFrame(draw); }, /** Stop the animation loop. */ stop() { @@ -202,6 +212,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) { }, /** Clear the canvas and particles without removing the canvas. */ clear() { + field = null; particles = []; context?.clearRect(0, 0, cssWidth, cssHeight); }, diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index dade321db..d477d94c5 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -82,7 +82,7 @@ test('wind rendering owns the canvas and particle lifecycle', () => { rendering.attach(); assert.equal(typeof canvas.getContext('2d'), 'object'); rendering.setField(FIELD); - assert.ok(rendering.getParticleCount() >= 3000); + assert.ok(rendering.getParticleCount() >= 200); rendering.start(); callbacks.shift()(16); assert.ok(strokes.length > 0, 'a driven frame draws trails'); @@ -99,10 +99,7 @@ test('wind rendering starts before a field and still draws once one arrives', () const { rendering, callbacks, strokes } = harness(); rendering.attach(); rendering.start(); - // No field yet: the loop must keep scheduling instead of throwing. - const before = callbacks.shift(); - before(16); - assert.equal(callbacks.length, 1, 'the loop reschedules without a field'); + assert.equal(callbacks.length, 0, 'no animation work before a field arrives'); rendering.setField(FIELD); callbacks.shift()(32); assert.ok(strokes.length > 0, 'draws once the field is installed'); @@ -118,3 +115,22 @@ test('wind rendering skips particles hidden by the globe', () => { assert.equal(strokes.length, 0, 'no trail is drawn for an occluded point'); rendering.stop(); }); + +// HTML canvas dimension setters erase pixels even on an identical assignment. +test('steady frames preserve canvas dimensions and resize adjusts the particle budget', () => { + const { rendering, canvas, callbacks } = harness(); + let resets = 0; let width = 0; let height = 0; + Object.defineProperties(canvas, { + width: { get: () => width, set: value => { width = value; resets++; } }, + height: { get: () => height, set: value => { height = value; resets++; } }, + }); + rendering.attach(); rendering.setField({ grid: FIELD, u: FIELD.u, v: FIELD.v }); rendering.start(); + const initial = resets; + callbacks.shift()(16); callbacks.shift()(32); + assert.equal(resets, initial, 'steady frames must not invoke canvas dimension setters'); + canvas.clientWidth = 320; canvas.clientHeight = 240; + callbacks.shift()(48); + assert.equal(resets, initial + 2); + assert.ok(rendering.getParticleCount() < 500, 'small view uses a smaller budget'); + rendering.destroy(); +}); diff --git a/src/layers/wind/source.js b/src/layers/wind/source.js index 8d5fb6ec6..847536b4c 100644 --- a/src/layers/wind/source.js +++ b/src/layers/wind/source.js @@ -1,33 +1,35 @@ -/** Create a source that loads the current GFS wind field. */ -export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) { +import { readWindBody } from '../../sources/windBody.js'; + +/** Create a bounded, cancellable source for the same-origin wind provider. */ +export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) { return { async getSnapshot({ signal } = {}) { - signal?.throwIfAborted(); - const response = await fetchImpl('/api/wind/manifest', { - signal, - cache: 'no-store', - }); - signal?.throwIfAborted(); - if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); - const manifest = await response.json(); - signal?.throwIfAborted(); - if (!manifest?.grid || !manifest.gridUrl) - throw new Error('Malformed wind manifest'); - if (manifest.unavailable) return manifest; - const gridResponse = await fetchImpl(manifest.gridUrl, { signal }); - signal?.throwIfAborted(); - if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`); - const buffer = await gridResponse.arrayBuffer(); - signal?.throwIfAborted(); - const count = manifest.grid.nx * manifest.grid.ny; - if (buffer.byteLength !== count * 8) - throw new Error('Malformed wind grid'); - const values = new Float32Array(buffer); - return { - ...manifest, - u: values.slice(0, count), - v: values.slice(count), - }; + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => controller.abort(new Error('Wind request timed out')), timeoutMs); + const active = controller.signal; + try { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/wind/manifest', { signal: active, cache: 'no-store', redirect: 'error' }); + if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); + const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active))); + if (manifest?.unavailable) return manifest; + const grid = manifest?.grid; + if (manifest?.model !== 'gfs' || !grid || !Number.isInteger(grid.nx) || !Number.isInteger(grid.ny) || grid.nx < 1 || grid.ny < 1 || grid.nx * grid.ny > 1_000_000 || ![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) || grid.dx <= 0 || grid.dy <= 0 || Math.abs(grid.nx * grid.dx - 360) > 0.01 || !/^\/api\/wind\/grid\/[\w.-]+\.bin$/.test(manifest.gridUrl)) + throw new Error('Malformed wind manifest'); + const count = grid.nx * grid.ny; + const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' }); + if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`); + const bytes = await readWindBody(gridResponse, count * 8, active); + if (bytes.byteLength !== count * 8) throw new Error('Malformed wind grid'); + const values = new Float32Array(bytes.buffer); + if (!values.every(Number.isFinite)) throw new Error('Malformed wind grid'); + return { ...manifest, u: values.slice(0, count), v: values.slice(count) }; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + } }, }; } diff --git a/src/layers/wind/source.test.mjs b/src/layers/wind/source.test.mjs index 221ff2072..8d6188167 100644 --- a/src/layers/wind/source.test.mjs +++ b/src/layers/wind/source.test.mjs @@ -1,24 +1,25 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWindSource } from './source.js'; - -const manifest = { grid: { nx: 2, ny: 1 }, gridUrl: '/grid.bin', unavailable: false }; -const response = (body, ok = true, status = 200) => ({ ok, status, json: async () => body, arrayBuffer: async () => body }); - -test('wind source loads and splits a grid', async () => { - const buffer = Float32Array.from([1, 2, 3, 4]).buffer; - const source = createWindSource({ fetchImpl: async (url) => url.includes('manifest') ? response(manifest) : response(buffer) }); - const result = await source.getSnapshot(); - assert.deepEqual([...result.u], [1, 2]); - assert.deepEqual([...result.v], [3, 4]); +const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/test.bin' }; +const response = (value) => new Response(typeof value === 'object' && !(value instanceof ArrayBuffer) ? JSON.stringify(value) : value); +test('wind source splits a bounded field and preserves unavailable responses', async () => { + const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([1,2,3,4]).buffer) }); + const field = await source.getSnapshot(); + assert.deepEqual([...field.u], [1,2]); assert.deepEqual([...field.v], [3,4]); + const unavailable = createWindSource({ fetchImpl: async () => response({ unavailable: true }) }); + assert.equal((await unavailable.getSnapshot()).unavailable, true); }); - -test('wind source handles unavailable and malformed responses', async () => { - let calls = 0; - const unavailable = { ...manifest, unavailable: true }; - const source = createWindSource({ fetchImpl: async () => { calls++; return response(unavailable); } }); - assert.equal((await source.getSnapshot()).unavailable, true); - assert.equal(calls, 1); - await assert.rejects(() => createWindSource({ fetchImpl: async () => response({}, false, 503) }).getSnapshot(), /Wind HTTP 503/); - await assert.rejects(() => createWindSource({ fetchImpl: async () => response(manifest) }).getSnapshot(), /Malformed wind grid/); +test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => { + for (const bad of [{ ...manifest, gridUrl: 'https://example.com/exfil' }, { ...manifest, grid: { ...manifest.grid, nx: 1e9 } }]) { + let calls = 0; + const source = createWindSource({ fetchImpl: async () => { calls++; return response(bad); } }); + await assert.rejects(source.getSnapshot(), /Malformed/); assert.equal(calls, 1); + } + const source = createWindSource({ fetchImpl: async (url) => response(url.includes('manifest') ? manifest : Float32Array.from([NaN,2,3,4]).buffer) }); + await assert.rejects(source.getSnapshot(), /Malformed/); +}); +test('wind source timeout aborts the underlying fetch', async () => { + const source = createWindSource({ timeoutMs: 5, fetchImpl: (_, { signal }) => new Promise((resolve,reject) => signal.addEventListener('abort', () => reject(signal.reason), { once:true })) }); + await assert.rejects(source.getSnapshot(), /timed out/); }); diff --git a/src/sources/windBody.js b/src/sources/windBody.js new file mode 100644 index 000000000..d6287ccb3 --- /dev/null +++ b/src/sources/windBody.js @@ -0,0 +1,35 @@ +/** Read a fetch body under a hard byte budget, including chunked responses. */ +export async function readWindBody(response, maxBytes, signal) { + const fail = () => new Error('Wind response exceeds byte budget'); + if (Number(response.headers?.get('content-length')) > maxBytes) { + await response.body?.cancel(); + throw fail(); + } + const reader = response.body?.getReader(); + if (!reader) throw new Error('Wind response has no readable body'); + const chunks = []; + let size = 0; + const cancel = () => { void reader.cancel().catch(() => {}); }; + signal?.addEventListener('abort', cancel, { once: true }); + try { + while (true) { + signal?.throwIfAborted(); + const { done, value } = await reader.read(); + signal?.throwIfAborted(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) throw fail(); + chunks.push(value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return bytes; + } catch (error) { + cancel(); + throw error; + } finally { + signal?.removeEventListener('abort', cancel); + reader.releaseLock(); + } +} diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 73cf5ecdd..ed96d9713 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -226,11 +226,11 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0; + container.hidden = chips.length === 0 && legend.length === 0 && !controls?.info; for (const node of [...container.children]) { if ( - String(node.className).split(/\s+/).includes('data-toggle-legend-item') + String(node.className).split(/\s+/).some((name) => ['data-toggle-legend-item', 'data-toggle-controls-info'].includes(name)) ) node.remove(); } @@ -267,10 +267,17 @@ export class LayerPanel { swatch.className = 'data-toggle-legend-swatch'; swatch.style.background = item.color; const text = document.createElement('span'); - text.textContent = `${item.label} ${this._formatCount(item.count)}`; + text.textContent = item.count == null ? String(item.label) : `${item.label} ${this._formatCount(item.count)}`; entry.append(swatch, text); container.appendChild(entry); } + if (controls?.info) { + const info = document.createElement('div'); + info.className = 'data-toggle-controls-info'; + info.textContent = String(controls.info); + if (controls.infoTitle) info.title = controls.infoTitle; + container.appendChild(info); + } } /** diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 7b2ce0a69..955a97b58 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -888,3 +888,12 @@ opacity: 0.75; font-style: italic; } + +.data-toggle-controls-info { + flex-basis: 100%; + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-secondary); + padding-top: 3px; + overflow-wrap: anywhere; +}