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
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
+2
View File
@@ -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.
+1
View File
@@ -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",
+30 -7
View File
@@ -40,6 +40,9 @@ export function windProxy({
let cached = null;
/** @type {?Promise<object>} */
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);
+5 -6
View File
@@ -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(() => {});
}
}
+8 -7
View File
@@ -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;
+2
View File
@@ -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);
+8
View File
@@ -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);
});
+1 -1
View File
@@ -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');
});
+48 -15
View File
@@ -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;
+20 -9
View File
@@ -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);
},
+21 -5
View File
@@ -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();
});
+30 -28
View File
@@ -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);
}
},
};
}
+20 -19
View File
@@ -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/);
});
+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 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);
}
}
/**
+9
View File
@@ -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;
}