mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
fix(wind): bound GFS acquisition and preserve canvas trails and lifecycle
This commit is contained in:
1 parent
93d4224fdf
commit
dcb3339070
17 files changed
+251
-101
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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,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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user