fix(wind): bound GFS acquisition and preserve canvas trails and lifecycle

This commit is contained in:
Bilawal Sidhu committed 2026-09-15 02:21:09 -05:00
1 parent 93d4224fdf
commit dcb3339070
17 files changed
+251 -101

No files matched your search

+1 -1
View File
@@ -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 and are corrected with the calibration gizmo. `CCTV_CALGARY_MAX_SOURCES` sets
the cap and `CCTV_CALGARY_ENABLED=0` turns the pack off. 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, 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 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 client renders nullschool-style animated particles in a canvas overlay that
+2
View File
@@ -1,5 +1,7 @@
# God's Eye View Current State # 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. 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. 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.
+1
View File
@@ -1192,6 +1192,7 @@
"src/layers/wind/model.js", "src/layers/wind/model.js",
"src/layers/wind/rendering.js", "src/layers/wind/rendering.js",
"src/layers/wind/source.js", "src/layers/wind/source.js",
"src/sources/windBody.js",
"src/data/detection.js", "src/data/detection.js",
"src/data/detectionCohort.js", "src/data/detectionCohort.js",
"src/data/detectionDraw.js", "src/data/detectionDraw.js",
+30 -7
View File
@@ -40,6 +40,9 @@ export function windProxy({
let cached = null; let cached = null;
/** @type {?Promise<object>} */ /** @type {?Promise<object>} */
let loading = null; let loading = null;
let lastAttempt = -Infinity;
let controller = null;
let waiters = 0;
const sendJson = (res, value, status = 200) => { const sendJson = (res, value, status = 200) => {
res.writeHead(status, { res.writeHead(status, {
@@ -58,14 +61,19 @@ export function windProxy({
if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs) if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs)
return cached; return cached;
if (loading) return loading; 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 () => { loading = (async () => {
try { try {
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`; 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 ranges = windMessageRanges(parseGfsIdx(index.toString()));
const [uBuffer, vBuffer] = await Promise.all([ const [uBuffer, vBuffer] = await Promise.all([
fetchRange({ url: base, ...ranges.u, fetchImpl }), fetchRange({ url: base, ...ranges.u, fetchImpl, signal }),
fetchRange({ url: base, ...ranges.v, fetchImpl }), fetchRange({ url: base, ...ranges.v, fetchImpl, signal }),
]); ]);
const [u, v] = await Promise.all([ const [u, v] = await Promise.all([
decodeImpl(uBuffer), decodeImpl(uBuffer),
@@ -83,11 +91,13 @@ export function windProxy({
dx: targetDx, dx: targetDx,
dy: 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 idGrid = `${id}-${targetDx}`;
const manifest = { const manifest = {
schemaVersion: 1, schemaVersion: 1,
model: 'gfs', model: 'gfs',
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) }, cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle), validIso: cycleRunIso(cycle) },
fetchedAt: now(), fetchedAt: now(),
level: '10 m above ground', level: '10 m above ground',
units: 'm/s', units: 'm/s',
@@ -110,7 +120,7 @@ export function windProxy({
if (cached) { if (cached) {
cached = { cached = {
...cached, ...cached,
manifest: { ...cached.manifest, stale: true, reason: error.message }, manifest: { ...cached.manifest, stale: true, reason: 'Wind upstream unavailable' },
}; };
} else { } else {
cached = { cached = {
@@ -123,13 +133,15 @@ export function windProxy({
model: 'gfs', model: 'gfs',
stale: true, stale: true,
unavailable: true, unavailable: true,
reason: error.message, reason: 'Wind upstream unavailable',
}, },
}; };
} }
return cached; return cached;
} finally { } finally {
clearTimeout(timer);
loading = null; loading = null;
controller = null;
} }
})(); })();
return loading; return loading;
@@ -137,7 +149,18 @@ export function windProxy({
const handler = async (req, res) => { const handler = async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname; 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') { if (path === '/status') {
const { gridUrl, ...manifest } = state.manifest; const { gridUrl, ...manifest } = state.manifest;
return sendJson(res, manifest); return sendJson(res, manifest);
+5 -6
View File
@@ -1,7 +1,6 @@
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
/** /**
* Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm` * Lazily created ecCodes WASM instance. The `@meri-imperiumi/eccodes-wasm`
@@ -37,12 +36,12 @@ export async function decodeWindGribMessage(
{ eccodesModule, mountDir = os.tmpdir() } = {}, { eccodesModule, mountDir = os.tmpdir() } = {},
) { ) {
const eccodes = eccodesModule ?? (await loadEccodes()); const eccodes = eccodesModule ?? (await loadEccodes());
const name = `wind-${randomUUID()}.grib2`; const directory = await fs.mkdtemp(path.join(mountDir, 'gev-wind-'));
const file = path.join(mountDir, name); const file = path.join(directory, 'wind.grib2');
await fs.writeFile(file, buffer); await fs.writeFile(file, buffer, { mode: 0o600 });
let handle = null; let handle = null;
try { try {
eccodes.mountFilesystem(mountDir); eccodes.mountFilesystem(directory);
handle = eccodes.openGrib(file); handle = eccodes.openGrib(file);
return { return {
ni: handle.getLong('Ni'), ni: handle.getLong('Ni'),
@@ -58,6 +57,6 @@ export async function decodeWindGribMessage(
}; };
} finally { } finally {
if (handle) handle.delete(); if (handle) handle.delete();
await fs.unlink(file).catch(() => {}); await fs.rm(directory, { recursive: true, force: true }).catch(() => {});
} }
} }
+8 -7
View File
@@ -1,3 +1,4 @@
import { readWindBody } from '../../../src/sources/windBody.js';
/** /**
* Parse a GFS `.idx` inventory into ordered message records. * 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. */ /** Fetch a bounded text response as a Buffer. */
export async function fetchText({ url, fetchImpl = fetch, signal }) { export async function fetchText({ url, fetchImpl = fetch, signal }) {
const response = await fetchImpl(url, { signal }); const response = await fetchImpl(url, { signal, redirect: 'error' });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); }
return Buffer.from(await response.arrayBuffer()); return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
} }
/** /**
@@ -72,17 +73,17 @@ export async function fetchRange({
signal, signal,
maxBytes = 8 * 1024 * 1024, 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'); throw new Error('range too large');
const response = await fetchImpl(url, { const response = await fetchImpl(url, {
signal, signal,
redirect: 'error',
headers: { Range: `bytes=${start}-${end}` }, headers: { Range: `bytes=${start}-${end}` },
}); });
const validStatus = const validStatus =
response.status === 206 || (response.status === 200 && start === 0); response.status === 206 || (response.status === 200 && start === 0);
if (!validStatus || !response.ok) if (!validStatus || !response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); }
throw new Error(`invalid range response ${response.status}`); const buffer = Buffer.from(await readWindBody(response, end - start + 1, signal));
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length !== end - start + 1) if (buffer.length !== end - start + 1)
throw new Error('invalid range length'); throw new Error('invalid range length');
return buffer; return buffer;
+2
View File
@@ -21,6 +21,8 @@ export function resampleWindGrid({
dx = 1, dx = 1,
dy = 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 nx = Math.round(360 / dx);
const ny = Math.round(180 / dy) + 1; const ny = Math.round(180 / dy) + 1;
const outU = new Float32Array(nx * ny); const outU = new Float32Array(nx * ny);
+8
View File
@@ -11,3 +11,11 @@ test('catalog and index helpers', async () => {
assert.deepEqual([...b], [1, 2, 3]); assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) })); 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);
});
+1 -1
View File
@@ -156,7 +156,7 @@ test('wind serves last-good with stale on upstream failure', async () => {
clock += 2 * 3600_000; clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body); const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true); 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', '')); const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable'); assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
}); });
+48 -15
View File
@@ -1,17 +1,24 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createWindRendering } from './rendering.js'; 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) { export function windStats(manifest) {
const run = Date.parse(manifest?.cycle?.runIso);
return { return {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0, count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: manifest?.cycle?.runIso ? Date.parse(manifest.cycle.runIso) : manifest?.fetchedAt ?? null, lastUpdate: Number.isFinite(run) ? run : null,
error: manifest?.unavailable ? manifest.reason || 'Wind unavailable' : manifest?.reason || null, error: manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
}; };
} }
/** Create the GFS wind data layer. */ /** Construct one wind layer with an explicit source and owned animation. */
export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) { export function createWindLayer({ feed, cesium = Cesium, container, createRendering = createWindRendering } = {}) {
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
let viewer = null; let viewer = null;
let request = null; let request = null;
@@ -19,31 +26,57 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
let rendering = null; let rendering = null;
let manifest = null; let manifest = null;
let error = null; let error = null;
let loading = false;
let rowControlsListener = null;
const notify = () => rowControlsListener?.();
const layer = { const layer = {
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS', updateInterval: 3600_000, id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS · FORECAST', updateInterval: 3600_000,
init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); }, init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer });
rendering.attach();
},
enable() { enabled = true; rendering?.start(); }, 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 } = {}) { async update(nextViewer, { signal } = {}) {
if (!enabled) return false; if (!enabled) return false;
request?.abort(); request?.abort();
const controller = new AbortController(); const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
request = controller; request = controller;
loading = true;
notify();
try { try {
const snapshot = await feed.getSnapshot({ signal: signal || controller.signal }); signal?.throwIfAborted();
if (!enabled || controller.signal.aborted || signal?.aborted) return false; const snapshot = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller) return false;
manifest = snapshot; manifest = snapshot;
error = null; error = null;
if (!snapshot.unavailable) rendering.setField(snapshot); if (snapshot.unavailable) { rendering.stop(); rendering.clear(); }
else { rendering.setField(snapshot); rendering.start(); }
return true; return true;
} catch (cause) { } catch (cause) {
if (controller.signal.aborted || signal?.aborted) return false; if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Wind source unavailable'; error = cause?.message || 'Wind source unavailable';
return true; 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; }, getRowControls() {
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; }, 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; }, getParticleCount() { return rendering?.getParticleCount() || 0; },
}; };
return layer; return layer;
+20 -9
View File
@@ -20,6 +20,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
let cssWidth = 1; let cssWidth = 1;
let cssHeight = 1; let cssHeight = 1;
let warned = false; let warned = false;
let cameraSignature = null;
const randomParticle = () => ({ const randomParticle = () => ({
lon: Math.random() * 360 - 180, lon: Math.random() * 360 - 180,
@@ -60,7 +61,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
}; };
const budget = () => 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 = () => { const seed = () => {
particles = []; particles = [];
@@ -74,8 +75,12 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
const sceneCanvas = viewer?.scene?.canvas; const sceneCanvas = viewer?.scene?.canvas;
cssWidth = sceneCanvas?.clientWidth || container?.clientWidth || 1; cssWidth = sceneCanvas?.clientWidth || container?.clientWidth || 1;
cssHeight = sceneCanvas?.clientHeight || container?.clientHeight || 1; cssHeight = sceneCanvas?.clientHeight || container?.clientHeight || 1;
canvas.width = Math.floor(cssWidth * ratio); const width = Math.floor(cssWidth * ratio);
canvas.height = Math.floor(cssHeight * 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.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`; canvas.style.height = `${cssHeight}px`;
context.setTransform?.(ratio, 0, 0, ratio, 0, 0); context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
@@ -96,6 +101,13 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
if (!viewer || viewer.isDestroyed?.()) return; if (!viewer || viewer.isDestroyed?.()) return;
try { try {
resize(viewer); 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; const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
lastTime = time; lastTime = time;
// Fade previous trails by ERASING them (destination-out). A translucent // 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.globalCompositeOperation = 'destination-out';
context.fillStyle = 'rgba(0, 0, 0, 0.02)'; context.fillStyle = 'rgba(0, 0, 0, 0.02)';
context.fillRect(0, 0, cssWidth, cssHeight); context.fillRect(0, 0, cssWidth, cssHeight);
if (!field) { if (!field) return;
frame = globalThis.requestAnimationFrame(draw);
return;
}
const scene = viewer.scene; const scene = viewer.scene;
// Real wind (m/s) is invisible at globe scale, so the advection is // Real wind (m/s) is invisible at globe scale, so the advection is
// exaggerated in proportion to camera height: the same visual speed 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. */ /** Install a wind field and (re)seed particles. */
setField(next) { setField(next) {
field = next; field = next?.grid ? { ...next.grid, u: next.u, v: next.v } : next;
if (canvas) { if (canvas) {
resize(getViewer?.()); resize(getViewer?.());
seed(); seed();
if (running && frame === null) frame = globalThis.requestAnimationFrame(draw);
} }
}, },
/** Start the animation loop. Safe to call before a field is installed. */ /** 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; if (running || !canvas) return;
running = true; running = true;
lastTime = 0; lastTime = 0;
frame = globalThis.requestAnimationFrame(draw); if (field) frame = globalThis.requestAnimationFrame(draw);
}, },
/** Stop the animation loop. */ /** Stop the animation loop. */
stop() { stop() {
@@ -202,6 +212,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
}, },
/** Clear the canvas and particles without removing the canvas. */ /** Clear the canvas and particles without removing the canvas. */
clear() { clear() {
field = null;
particles = []; particles = [];
context?.clearRect(0, 0, cssWidth, cssHeight); context?.clearRect(0, 0, cssWidth, cssHeight);
}, },
+21 -5
View File
@@ -82,7 +82,7 @@ test('wind rendering owns the canvas and particle lifecycle', () => {
rendering.attach(); rendering.attach();
assert.equal(typeof canvas.getContext('2d'), 'object'); assert.equal(typeof canvas.getContext('2d'), 'object');
rendering.setField(FIELD); rendering.setField(FIELD);
assert.ok(rendering.getParticleCount() >= 3000); assert.ok(rendering.getParticleCount() >= 200);
rendering.start(); rendering.start();
callbacks.shift()(16); callbacks.shift()(16);
assert.ok(strokes.length > 0, 'a driven frame draws trails'); 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(); const { rendering, callbacks, strokes } = harness();
rendering.attach(); rendering.attach();
rendering.start(); rendering.start();
// No field yet: the loop must keep scheduling instead of throwing. assert.equal(callbacks.length, 0, 'no animation work before a field arrives');
const before = callbacks.shift();
before(16);
assert.equal(callbacks.length, 1, 'the loop reschedules without a field');
rendering.setField(FIELD); rendering.setField(FIELD);
callbacks.shift()(32); callbacks.shift()(32);
assert.ok(strokes.length > 0, 'draws once the field is installed'); 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'); assert.equal(strokes.length, 0, 'no trail is drawn for an occluded point');
rendering.stop(); 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();
});
+30 -28
View File
@@ -1,33 +1,35 @@
/** Create a source that loads the current GFS wind field. */ import { readWindBody } from '../../sources/windBody.js';
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) {
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) {
return { return {
async getSnapshot({ signal } = {}) { async getSnapshot({ signal } = {}) {
signal?.throwIfAborted(); const controller = new AbortController();
const response = await fetchImpl('/api/wind/manifest', { const abort = () => controller.abort(signal.reason);
signal, signal?.addEventListener('abort', abort, { once: true });
cache: 'no-store', const timer = setTimeout(() => controller.abort(new Error('Wind request timed out')), timeoutMs);
}); const active = controller.signal;
signal?.throwIfAborted(); try {
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`); signal?.throwIfAborted();
const manifest = await response.json(); const response = await fetchImpl('/api/wind/manifest', { signal: active, cache: 'no-store', redirect: 'error' });
signal?.throwIfAborted(); if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
if (!manifest?.grid || !manifest.gridUrl) const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active)));
throw new Error('Malformed wind manifest'); if (manifest?.unavailable) return manifest;
if (manifest.unavailable) return manifest; const grid = manifest?.grid;
const gridResponse = await fetchImpl(manifest.gridUrl, { signal }); 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))
signal?.throwIfAborted(); throw new Error('Malformed wind manifest');
if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`); const count = grid.nx * grid.ny;
const buffer = await gridResponse.arrayBuffer(); const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' });
signal?.throwIfAborted(); if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`);
const count = manifest.grid.nx * manifest.grid.ny; const bytes = await readWindBody(gridResponse, count * 8, active);
if (buffer.byteLength !== count * 8) if (bytes.byteLength !== count * 8) throw new Error('Malformed wind grid');
throw new Error('Malformed wind grid'); const values = new Float32Array(bytes.buffer);
const values = new Float32Array(buffer); if (!values.every(Number.isFinite)) throw new Error('Malformed wind grid');
return { return { ...manifest, u: values.slice(0, count), v: values.slice(count) };
...manifest, } finally {
u: values.slice(0, count), clearTimeout(timer);
v: values.slice(count), signal?.removeEventListener('abort', abort);
}; }
}, },
}; };
} }
+20 -19
View File
@@ -1,24 +1,25 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createWindSource } from './source.js'; import { createWindSource } from './source.js';
const manifest = { model: 'gfs', grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 }, gridUrl: '/api/wind/grid/test.bin' };
const manifest = { grid: { nx: 2, ny: 1 }, gridUrl: '/grid.bin', unavailable: false }; const response = (value) => new Response(typeof value === 'object' && !(value instanceof ArrayBuffer) ? JSON.stringify(value) : value);
const response = (body, ok = true, status = 200) => ({ ok, status, json: async () => body, arrayBuffer: async () => body }); 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) });
test('wind source loads and splits a grid', async () => { const field = await source.getSnapshot();
const buffer = Float32Array.from([1, 2, 3, 4]).buffer; assert.deepEqual([...field.u], [1,2]); assert.deepEqual([...field.v], [3,4]);
const source = createWindSource({ fetchImpl: async (url) => url.includes('manifest') ? response(manifest) : response(buffer) }); const unavailable = createWindSource({ fetchImpl: async () => response({ unavailable: true }) });
const result = await source.getSnapshot(); assert.equal((await unavailable.getSnapshot()).unavailable, true);
assert.deepEqual([...result.u], [1, 2]);
assert.deepEqual([...result.v], [3, 4]);
}); });
test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => {
test('wind source handles unavailable and malformed responses', async () => { for (const bad of [{ ...manifest, gridUrl: 'https://example.com/exfil' }, { ...manifest, grid: { ...manifest.grid, nx: 1e9 } }]) {
let calls = 0; let calls = 0;
const unavailable = { ...manifest, unavailable: true }; const source = createWindSource({ fetchImpl: async () => { calls++; return response(bad); } });
const source = createWindSource({ fetchImpl: async () => { calls++; return response(unavailable); } }); await assert.rejects(source.getSnapshot(), /Malformed/); assert.equal(calls, 1);
assert.equal((await source.getSnapshot()).unavailable, true); }
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(() => createWindSource({ fetchImpl: async () => response({}, false, 503) }).getSnapshot(), /Wind HTTP 503/); await assert.rejects(source.getSnapshot(), /Malformed/);
await assert.rejects(() => createWindSource({ fetchImpl: async () => response(manifest) }).getSnapshot(), /Malformed wind grid/); });
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/);
}); });
+35
View File
@@ -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();
}
}
+10 -3
View File
@@ -226,11 +226,11 @@ export class LayerPanel {
const chips = controls?.chips || []; const chips = controls?.chips || [];
const legend = controls?.legend || []; const legend = controls?.legend || [];
this._syncRowList(listContainer, controls?.list || null); 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]) { for (const node of [...container.children]) {
if ( 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(); node.remove();
} }
@@ -267,10 +267,17 @@ export class LayerPanel {
swatch.className = 'data-toggle-legend-swatch'; swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color; swatch.style.background = item.color;
const text = document.createElement('span'); 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); entry.append(swatch, text);
container.appendChild(entry); 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);
}
} }
/** /**
+9
View File
@@ -888,3 +888,12 @@
opacity: 0.75; opacity: 0.75;
font-style: italic; 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;
}