mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
feat(wind): add keyless NOAA GFS wind layer with animated particles
Add a Wind layer animating the global 10 m wind field from NOAA GFS. The /api/wind proxy selects the latest available 0.25° cycle, reads its .idx inventory, byte-range fetches only the UGRD/VGRD 10 m GRIB2 messages (~1 MB each instead of the whole ~500 MB file), decodes them with ecCodes (WASM), resamples to a compact grid (1° default), and serves a manifest plus a Float32 U/V payload cached per cycle for an hour (single-flight, last-good on failure). The client renders nullschool-style particles in a canvas overlay: bilinear field sampling with longitude wrap, per-frame advection, Cesium camera projection, and globe-occlusion culling via EllipsoidalOccluder. Validated end-to-end against the real 2026-09-14 06z cycle on Node 25 (180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|). The WASM decoder requires Node >=24, which the repo already requires. Registered as `wind` (token k) with attribution and docs. Verified: npm test (3412 pass, 0 fail), npm run check:boundaries.
This commit is contained in:
1 parent
844c25212c
commit
deb34ebe35
28 files changed
+1122
-3
No files matched your search
@@ -103,6 +103,12 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
### Added
|
||||
|
||||
- Add a keyless **Wind** layer from NOAA GFS 10 m wind. 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
|
||||
follows the Cesium camera and skips globe-occluded points. Forecast, not
|
||||
observations. Requires Node ≥24 for the WASM decoder.
|
||||
- Add Ontario 511 as a keyless CCTV source pack, including Kitchener-area
|
||||
highway cameras, with server-registered still URLs and attribution.
|
||||
- CCTV Mesh adds Finland: Fintraffic road weather cameras, keyless, nationwide, 300 by default. Each camera view of a station is placed separately; ambient stills refresh on the source's 10-minute cadence (the active camera keeps the usual 10-second refresh).
|
||||
|
||||
@@ -15,6 +15,7 @@ How to read this:
|
||||
|
||||
| Source | Used for | License / terms | Attribution |
|
||||
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **NOAA GFS (wind)** | Global 10 m wind field for the animated Wind layer | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
|
||||
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping |
|
||||
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
|
||||
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||
@@ -134,6 +135,20 @@ transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### NOAA GFS wind
|
||||
|
||||
The optional **Wind** layer animates the global 10 m wind field from NOAA's
|
||||
Global Forecast System (GFS). The `/api/wind` server-side proxy selects the
|
||||
latest available 0.25° cycle, reads its `.idx` inventory, byte-range fetches only
|
||||
the `UGRD`/`VGRD` 10 m GRIB2 messages (≈1 MB each instead of the whole ~500 MB
|
||||
file), decodes them with ecCodes (WASM), resamples to a compact grid (1° by
|
||||
default), and serves a manifest plus a Float32 U/V payload. It is keyless, cached
|
||||
per cycle for an hour, and is a **forecast, not an observation**: the particles
|
||||
show model flow, not measured wind. Rendering is a 2D canvas particle overlay
|
||||
advected by the field and projected through the Cesium camera; globe-occluded
|
||||
particles are skipped. NOAA GFS data is U.S. public domain; the credit above is a
|
||||
courtesy and does not imply endorsement.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
|
||||
@@ -2312,6 +2312,7 @@ its criteria cannot be silently ignored.
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
|
||||
`src/data/militaryAwareness.js` remains registered internally as the Contacts
|
||||
coordinator, but it is not a user-visible Data Layers entry. Its visible entry
|
||||
|
||||
Generated
+19
@@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"mgrs": "^2.1.0",
|
||||
@@ -1076,6 +1077,24 @@
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@meri-imperiumi/eccodes-wasm": {
|
||||
"version": "2.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@meri-imperiumi/eccodes-wasm/-/eccodes-wasm-2.48.2.tgz",
|
||||
"integrity": "sha512-Z7rD3xInomPG7FS97ORD1UGKAdgeeRP9ofa9k4m8d+k2sDfDQeAjTzvs9UOndyaZovaSbEQeIoNX8gTmQNhiSA==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz",
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "gods-eye-view",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Bilawal Sidhu",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||
"cesium": "^1.124.0",
|
||||
"egm96-universal": "^1.1.1",
|
||||
"mgrs": "^2.1.0",
|
||||
|
||||
@@ -1091,6 +1091,11 @@
|
||||
"src/data/contactMatch.js",
|
||||
"src/data/contextStore.js",
|
||||
"src/data/dataCredits.js",
|
||||
"src/data/wind.js",
|
||||
"src/layers/wind/index.js",
|
||||
"src/layers/wind/model.js",
|
||||
"src/layers/wind/rendering.js",
|
||||
"src/layers/wind/source.js",
|
||||
"src/data/detection.js",
|
||||
"src/data/detectionCohort.js",
|
||||
"src/data/detectionDraw.js",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||
import { openAiRealtimeProxy } from './openai.js';
|
||||
import { googlePlacesContextProxy } from './places.js';
|
||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
/** Construct the local provider plugins in their established order. */
|
||||
function localProviderPlugins() {
|
||||
@@ -41,6 +42,7 @@ function localProviderPlugins() {
|
||||
trackBackfillProxies(),
|
||||
openAiRealtimeProxy(),
|
||||
googlePlacesContextProxy(),
|
||||
windProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
GFS_BUCKET,
|
||||
gfsObjectKey,
|
||||
selectLatestGfsCycle,
|
||||
} from './wind/catalog.js';
|
||||
import {
|
||||
fetchText,
|
||||
fetchRange,
|
||||
parseGfsIdx,
|
||||
windMessageRanges,
|
||||
} from './wind/gfs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { resampleWindGrid } from './wind/grid.js';
|
||||
|
||||
/**
|
||||
* NOAA GFS 10 m wind proxy.
|
||||
*
|
||||
* Selects the latest available GFS cycle, reads its `.idx` inventory, byte-range
|
||||
* fetches only the UGRD/VGRD 10 m GRIB2 messages, decodes them with ecCodes
|
||||
* (WASM), resamples to a compact grid, and serves a manifest plus a Float32
|
||||
* grid (U values then V values). Keyless; cached per cycle for an hour.
|
||||
*
|
||||
* Routes (mounted at `/api/wind`):
|
||||
* GET /api/wind/manifest → manifest JSON
|
||||
* GET /api/wind/grid/<id>.bin → Float32 U…V payload
|
||||
* GET /api/wind/status → manifest without `gridUrl`
|
||||
*
|
||||
* @param {{fetchImpl?: Function, now?: Function, decodeImpl?: Function,
|
||||
* targetDx?: number, ttlMs?: number}} [options]
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
now = () => Date.now(),
|
||||
decodeImpl = decodeWindGribMessage,
|
||||
targetDx = 1,
|
||||
ttlMs = 3600_000,
|
||||
} = {}) {
|
||||
/** @type {?{id: string, idGrid: string, fetchedAt: number, manifest: object, grid: ?object}} */
|
||||
let cached = null;
|
||||
/** @type {?Promise<object>} */
|
||||
let loading = null;
|
||||
|
||||
const sendJson = (res, value, status = 200) => {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
|
||||
const cycleRunIso = (cycle) =>
|
||||
`${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`;
|
||||
|
||||
async function refresh() {
|
||||
const cycle = selectLatestGfsCycle(now());
|
||||
const id = `${cycle.date}-${cycle.hour}`;
|
||||
if (cached && cached.id === id && now() - cached.fetchedAt < ttlMs)
|
||||
return cached;
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
try {
|
||||
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
|
||||
const index = await fetchText({ url: `${base}.idx`, fetchImpl });
|
||||
const ranges = windMessageRanges(parseGfsIdx(index.toString()));
|
||||
const [uBuffer, vBuffer] = await Promise.all([
|
||||
fetchRange({ url: base, ...ranges.u, fetchImpl }),
|
||||
fetchRange({ url: base, ...ranges.v, fetchImpl }),
|
||||
]);
|
||||
const [u, v] = await Promise.all([
|
||||
decodeImpl(uBuffer),
|
||||
decodeImpl(vBuffer),
|
||||
]);
|
||||
const grid = resampleWindGrid({
|
||||
u: u.values,
|
||||
v: v.values,
|
||||
ni: u.ni,
|
||||
nj: u.nj,
|
||||
lo1: u.lo1,
|
||||
la1: u.la1,
|
||||
di: u.di,
|
||||
dj: u.dj,
|
||||
dx: targetDx,
|
||||
dy: targetDx,
|
||||
});
|
||||
const idGrid = `${id}-${targetDx}`;
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
model: 'gfs',
|
||||
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) },
|
||||
fetchedAt: now(),
|
||||
level: '10 m above ground',
|
||||
units: 'm/s',
|
||||
grid: {
|
||||
nx: grid.nx,
|
||||
ny: grid.ny,
|
||||
lo1: grid.lo1,
|
||||
la1: grid.la1,
|
||||
dx: grid.dx,
|
||||
dy: grid.dy,
|
||||
},
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
gridUrl: `/api/wind/grid/${idGrid}.bin`,
|
||||
};
|
||||
cached = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid };
|
||||
return cached;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
cached = {
|
||||
...cached,
|
||||
manifest: { ...cached.manifest, stale: true, reason: error.message },
|
||||
};
|
||||
} else {
|
||||
cached = {
|
||||
id: null,
|
||||
idGrid: null,
|
||||
fetchedAt: now(),
|
||||
grid: null,
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
model: 'gfs',
|
||||
stale: true,
|
||||
unavailable: true,
|
||||
reason: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
return cached;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
})();
|
||||
return loading;
|
||||
}
|
||||
|
||||
const handler = async (req, res) => {
|
||||
const path = new URL(req.url, 'http://localhost').pathname;
|
||||
const state = await refresh();
|
||||
if (path === '/status') {
|
||||
const { gridUrl, ...manifest } = state.manifest;
|
||||
return sendJson(res, manifest);
|
||||
}
|
||||
if (path.startsWith('/grid/')) {
|
||||
if (!state.grid || path !== `/grid/${state.idGrid}.bin`)
|
||||
return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength),
|
||||
Buffer.from(state.grid.v.buffer, state.grid.v.byteOffset, state.grid.v.byteLength),
|
||||
]);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=3600, immutable',
|
||||
});
|
||||
return res.end(payload);
|
||||
}
|
||||
return sendJson(res, state.manifest);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'wind',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/wind', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const GFS_BUCKET = 'noaa-gfs-bdp-pds';
|
||||
|
||||
/** Build the public GFS object key. */
|
||||
export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
|
||||
const hh = String(hour).padStart(2, '0');
|
||||
return `gfs.${date}/${hh}/atmos/gfs.t${hh}z.pgrb2.0p25.f${String(forecastHour).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** Select the latest GFS cycle that should be available. */
|
||||
export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } = {}) {
|
||||
const d = new Date(nowMs - availabilityLagMs);
|
||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
const hour = Math.floor(d.getUTCHours() / 6) * 6;
|
||||
date.setUTCHours(hour);
|
||||
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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`
|
||||
* module is only imported on first use so unit tests can inject a fake
|
||||
* decoder without loading the WASM (which requires Node >= 24).
|
||||
* @type {?Promise<object>}
|
||||
*/
|
||||
let eccodesPromise = null;
|
||||
|
||||
/** @returns {Promise<object>} The shared ecCodes WASM instance. */
|
||||
function loadEccodes() {
|
||||
eccodesPromise ??= (async () => {
|
||||
const module = await import('@meri-imperiumi/eccodes-wasm');
|
||||
return module.createEccodes();
|
||||
})();
|
||||
return eccodesPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one GRIB2 wind message into its grid metadata and values.
|
||||
*
|
||||
* The WASM decoder reads from its own mounted filesystem, so the message is
|
||||
* written to a temp file under `mountDir` and removed in `finally`.
|
||||
*
|
||||
* @param {Buffer} buffer - One GRIB2 message (already byte-range extracted).
|
||||
* @param {{ eccodesModule?: object, mountDir?: string }} [options]
|
||||
* @returns {Promise<{ni: number, nj: number, lo1: number, la1: number,
|
||||
* di: number, dj: number, values: ArrayLike<number>, shortName: string,
|
||||
* level: number, units: string}>}
|
||||
*/
|
||||
export async function decodeWindGribMessage(
|
||||
buffer,
|
||||
{ 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);
|
||||
let handle = null;
|
||||
try {
|
||||
eccodes.mountFilesystem(mountDir);
|
||||
handle = eccodes.openGrib(file);
|
||||
return {
|
||||
ni: handle.getLong('Ni'),
|
||||
nj: handle.getLong('Nj'),
|
||||
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
|
||||
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
|
||||
di: handle.getDouble('iDirectionIncrementInDegrees'),
|
||||
dj: handle.getDouble('jDirectionIncrementInDegrees'),
|
||||
values: handle.getDoubleArray('values'),
|
||||
shortName: handle.getString('shortName'),
|
||||
level: handle.getLong('level'),
|
||||
units: handle.getString('units'),
|
||||
};
|
||||
} finally {
|
||||
if (handle) handle.delete();
|
||||
await fs.unlink(file).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Parse a GFS `.idx` inventory into ordered message records.
|
||||
*
|
||||
* Each line is `msgNumber:byteOffset:d=YYYYMMDDHH:VAR:LEVEL:...:`. Malformed
|
||||
* lines are skipped; a payload with no valid lines is rejected so callers never
|
||||
* treat an HTML error page as an empty inventory.
|
||||
*
|
||||
* @param {string} text - Raw `.idx` body.
|
||||
* @returns {{ messages: Array<{index: number, offset: number, variable: string, level: string}> }}
|
||||
*/
|
||||
export function parseGfsIdx(text) {
|
||||
const messages = [];
|
||||
for (const line of String(text).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const parts = line.split(':');
|
||||
if (
|
||||
parts.length < 6 ||
|
||||
!/^\d+$/.test(parts[0]) ||
|
||||
!/^\d+$/.test(parts[1]) ||
|
||||
!parts[3] ||
|
||||
!parts[4]
|
||||
)
|
||||
continue;
|
||||
messages.push({
|
||||
index: Number(parts[0]),
|
||||
offset: Number(parts[1]),
|
||||
variable: parts[3],
|
||||
level: parts[4],
|
||||
});
|
||||
}
|
||||
if (!messages.length) throw new Error('malformed GFS index');
|
||||
return { messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte ranges for the two 10 m wind components. A message runs from its own
|
||||
* offset to one byte before the next message in file order.
|
||||
*
|
||||
* @param {{messages: Array<object>}} parsed - {@link parseGfsIdx} result.
|
||||
* @param {{level?: string}} [options]
|
||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||
*/
|
||||
export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) {
|
||||
const range = (variable) => {
|
||||
const index = parsed.messages.findIndex(
|
||||
(message) => message.variable === variable && message.level === level,
|
||||
);
|
||||
const message = parsed.messages[index];
|
||||
const next = parsed.messages[index + 1];
|
||||
if (!message || !next) throw new Error(`missing ${variable} at ${level}`);
|
||||
return { start: message.offset, end: next.offset - 1 };
|
||||
};
|
||||
return { u: range('UGRD'), v: range('VGRD') };
|
||||
}
|
||||
|
||||
/** 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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one bounded byte range. Rejects oversized ranges, non-206 responses
|
||||
* (except a whole-object 200 starting at zero), and short bodies.
|
||||
*/
|
||||
export async function fetchRange({
|
||||
url,
|
||||
start,
|
||||
end,
|
||||
fetchImpl = fetch,
|
||||
signal,
|
||||
maxBytes = 8 * 1024 * 1024,
|
||||
}) {
|
||||
if (end < start || end - start + 1 > maxBytes)
|
||||
throw new Error('range too large');
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
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 (buffer.length !== end - start + 1)
|
||||
throw new Error('invalid range length');
|
||||
return buffer;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Resample a GRIB wind grid into a compact equirectangular grid.
|
||||
*
|
||||
* Source grids are north-to-south (`la1` at the top, `dj` increasing toward the
|
||||
* south) and start at `lo1`. Longitude wraps across the seam; latitude clamps.
|
||||
*
|
||||
* @param {{u: ArrayLike<number>, v: ArrayLike<number>, ni: number, nj: number,
|
||||
* lo1: number, la1: number, di: number, dj: number, dx?: number, dy?: number}} input
|
||||
* @returns {{nx: number, ny: number, lo1: number, la1: number, dx: number,
|
||||
* dy: number, u: Float32Array, v: Float32Array}}
|
||||
*/
|
||||
export function resampleWindGrid({
|
||||
u,
|
||||
v,
|
||||
ni,
|
||||
nj,
|
||||
lo1,
|
||||
la1,
|
||||
di,
|
||||
dj,
|
||||
dx = 1,
|
||||
dy = 1,
|
||||
}) {
|
||||
const nx = Math.round(360 / dx);
|
||||
const ny = Math.round(180 / dy) + 1;
|
||||
const outU = new Float32Array(nx * ny);
|
||||
const outV = new Float32Array(nx * ny);
|
||||
const sample = (source, lon, lat) => {
|
||||
const x = ((lon - lo1) / di + ni) % ni;
|
||||
const y = Math.max(0, Math.min(nj - 1, (la1 - lat) / dj));
|
||||
const x0 = Math.floor(x);
|
||||
const x1 = (x0 + 1) % ni;
|
||||
const y0 = Math.floor(y);
|
||||
const y1 = Math.min(nj - 1, y0 + 1);
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
const top = source[y0 * ni + x0] * (1 - fx) + source[y0 * ni + x1] * fx;
|
||||
const bottom = source[y1 * ni + x0] * (1 - fx) + source[y1 * ni + x1] * fx;
|
||||
return top * (1 - fy) + bottom * fy;
|
||||
};
|
||||
for (let y = 0; y < ny; y += 1) {
|
||||
for (let x = 0; x < nx; x += 1) {
|
||||
const index = y * nx + x;
|
||||
const lon = x * dx;
|
||||
const lat = 90 - y * dy;
|
||||
outU[index] = sample(u, lon, lat);
|
||||
outV[index] = sample(v, lon, lat);
|
||||
}
|
||||
}
|
||||
return { nx, ny, lo1: 0, la1: 90, dx, dy, u: outU, v: outV };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
|
||||
import militaryInstallationsLayer from '../data/militaryInstallations.js';
|
||||
import militaryAwarenessLayer from '../data/militaryAwareness.js';
|
||||
import localDataLayers from '../data/localLayers.js';
|
||||
import windLayer from '../data/wind.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
@@ -50,6 +51,7 @@ export function createApplicationData({
|
||||
dataManager.register(militaryInstallationsLayer);
|
||||
dataManager.register(militaryAwarenessLayer);
|
||||
militaryAwarenessLayer.attachDataManager(dataManager);
|
||||
dataManager.register(windLayer);
|
||||
for (const layer of localDataLayers) {
|
||||
dataManager.register(layer);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
|
||||
import { configureSatelliteSource } from '../data/satellites.js';
|
||||
import { configureLaunchSource } from '../data/rocketLaunches.js';
|
||||
import { configureFirmsSource } from '../data/firmsHeatmap.js';
|
||||
import { configureWindSource } from '../data/wind.js';
|
||||
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
|
||||
const configure = {
|
||||
alpr: configureAlprSource,
|
||||
@@ -21,6 +22,7 @@ const configure = {
|
||||
satellites: configureSatelliteSource,
|
||||
launches: configureLaunchSource,
|
||||
firms: configureFirmsSource,
|
||||
wind: configureWindSource,
|
||||
};
|
||||
/** Configure sources before any registration or state restoration starts. */
|
||||
export function configureApplicationSources({
|
||||
|
||||
@@ -181,6 +181,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
|
||||
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
|
||||
},
|
||||
{
|
||||
key: 'wind-gfs',
|
||||
html:
|
||||
'Wind: NOAA Global Forecast System (GFS) 10 m wind via ' +
|
||||
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
|
||||
'(U.S. public domain; forecast, not observations)',
|
||||
},
|
||||
// ── Bundled snapshots ───────────────────────────────────────────
|
||||
{
|
||||
key: 'datacenters',
|
||||
|
||||
@@ -292,6 +292,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
||||
Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled-only' }),
|
||||
]);
|
||||
|
||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||
|
||||
@@ -157,8 +157,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 17);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 18);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 18);
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
() => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createSourceSlot } from '../app/sourceSlot.js';
|
||||
import { createWindLayer as createLayer } from '../layers/wind/index.js';
|
||||
import { createWindSource } from '../layers/wind/source.js';
|
||||
|
||||
const sourceSlot = createSourceSlot(createWindSource(), ['getSnapshot'], 'Wind source');
|
||||
export const configureWindSource = sourceSlot.configure;
|
||||
|
||||
/** Create a wind layer using the configured application source by default. */
|
||||
export function createWindLayer(options = {}) {
|
||||
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
|
||||
}
|
||||
|
||||
export default createWindLayer();
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { gfsObjectKey, selectLatestGfsCycle } from '../../server/providers/wind/catalog.js';
|
||||
import { parseGfsIdx, windMessageRanges, fetchRange } from '../../server/providers/wind/gfs.js';
|
||||
test('catalog and index helpers', async () => {
|
||||
assert.equal(gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }), 'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003');
|
||||
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), { date: '20260914', hour: 18 });
|
||||
const p = parseGfsIdx('1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z');
|
||||
assert.deepEqual(windMessageRanges(p), { u: { start: 10, end: 19 }, v: { start: 20, end: 29 } });
|
||||
const b = await fetchRange({ url: 'x', start: 0, end: 2, fetchImpl: async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }) });
|
||||
assert.deepEqual([...b], [1, 2, 3]);
|
||||
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resampleWindGrid } from '../../server/providers/wind/grid.js';
|
||||
|
||||
test('resamples and wraps a wind grid', () => {
|
||||
const r = resampleWindGrid({ ni: 4, nj: 3, lo1: 0, la1: 90, di: 90, dj: 90, u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], v: new Array(12).fill(1), dx: 90, dy: 90 });
|
||||
assert.deepEqual([r.nx, r.ny], [4, 3]);
|
||||
assert.equal(r.u[0], 0); assert.equal(r.u[3], 3); assert.equal(r.u[4], 4);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { windProxy } from '../../server/providers/wind.js';
|
||||
|
||||
/** Boot a mounted provider middleware without a real server. */
|
||||
function install(plugin) {
|
||||
const routes = new Map();
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(route, handler) {
|
||||
routes.set(route, handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(routes.size, 1);
|
||||
const handler = [...routes.values()][0];
|
||||
return async (url = '/', method = 'GET') => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
headersSent: false,
|
||||
writeHead(status, headers) {
|
||||
this.statusCode = status;
|
||||
for (const [name, value] of Object.entries(headers || {}))
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
this.headersSent = true;
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
this.headersSent = true;
|
||||
},
|
||||
};
|
||||
await handler({ url, method }, res);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
const IDX = [
|
||||
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
|
||||
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
|
||||
'3:10:d=2026091400:HGT:surface:anl:',
|
||||
].join('\n');
|
||||
|
||||
const DECODED_U = {
|
||||
ni: 4,
|
||||
nj: 3,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
di: 90,
|
||||
dj: 90,
|
||||
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
};
|
||||
const DECODED_V = { ...DECODED_U, values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32] };
|
||||
|
||||
/** Fetch double: `.idx` text, and range bodies of the requested length. */
|
||||
function makeFetch({ fail = false, counter } = {}) {
|
||||
return async (url, options = {}) => {
|
||||
if (counter) counter.calls += 1;
|
||||
if (fail) throw new Error('gfs upstream down');
|
||||
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
|
||||
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
|
||||
const length = Number(match[2]) - Number(match[1]) + 1;
|
||||
return new Response(new Uint8Array(length), { status: 206 });
|
||||
};
|
||||
}
|
||||
|
||||
function proxy(options = {}) {
|
||||
return windProxy({
|
||||
now: () => Date.UTC(2026, 8, 14, 12),
|
||||
targetDx: 90,
|
||||
fetchImpl: makeFetch(),
|
||||
decodeImpl: async (buffer) =>
|
||||
buffer[0] === undefined ? DECODED_U : DECODED_U,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
test('wind manifest describes the GFS cycle and resampled grid', async () => {
|
||||
const request = install(proxy());
|
||||
const res = await request('/');
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = JSON.parse(res.body);
|
||||
assert.equal(body.schemaVersion, 1);
|
||||
assert.equal(body.model, 'gfs');
|
||||
assert.equal(body.cycle.hour, 6);
|
||||
assert.equal(body.cycle.date, '20260914');
|
||||
assert.equal(body.units, 'm/s');
|
||||
assert.deepEqual(body.grid, { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 });
|
||||
assert.match(body.gridUrl, /^\/api\/wind\/grid\/20260914-6-90\.bin$/);
|
||||
assert.equal(body.stale, false);
|
||||
});
|
||||
|
||||
test('wind grid route returns Float32 U then V', async () => {
|
||||
let u = 0;
|
||||
const request = install(
|
||||
proxy({
|
||||
decodeImpl: async () => {
|
||||
u += 1;
|
||||
return u === 1 ? DECODED_U : DECODED_V;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const manifest = JSON.parse((await request('/')).body);
|
||||
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/octet-stream');
|
||||
assert.equal(res.body.length, 4 * 3 * 4 * 2);
|
||||
const floats = new Float32Array(
|
||||
res.body.buffer,
|
||||
res.body.byteOffset,
|
||||
res.body.length / 4,
|
||||
);
|
||||
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
|
||||
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
|
||||
});
|
||||
|
||||
test('wind status omits the grid url', async () => {
|
||||
const request = install(proxy());
|
||||
await request('/');
|
||||
const body = JSON.parse((await request('/status')).body);
|
||||
assert.equal('gridUrl' in body, false);
|
||||
assert.equal(body.model, 'gfs');
|
||||
});
|
||||
|
||||
test('wind refresh is cached within the TTL and refetches after it', async () => {
|
||||
const counter = { calls: 0 };
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
const request = install(
|
||||
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
|
||||
);
|
||||
await request('/');
|
||||
const afterFirst = counter.calls;
|
||||
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
|
||||
await request('/');
|
||||
assert.equal(counter.calls, afterFirst, 'a second request inside the TTL must not refetch');
|
||||
clock += 2 * 3600_000;
|
||||
await request('/');
|
||||
assert.equal(counter.calls, afterFirst + 3, 'a request past the TTL refetches');
|
||||
});
|
||||
|
||||
test('wind serves last-good with stale on upstream failure', async () => {
|
||||
let failing = false;
|
||||
let clock = Date.UTC(2026, 8, 14, 12);
|
||||
const request = install(
|
||||
proxy({
|
||||
now: () => clock,
|
||||
fetchImpl: async (...args) => {
|
||||
if (failing) throw new Error('gfs upstream down');
|
||||
return makeFetch()(...args);
|
||||
},
|
||||
}),
|
||||
);
|
||||
const good = JSON.parse((await request('/')).body);
|
||||
failing = true;
|
||||
clock += 2 * 3600_000;
|
||||
const degraded = JSON.parse((await request('/')).body);
|
||||
assert.equal(degraded.stale, true);
|
||||
assert.equal(degraded.reason, 'gfs upstream down');
|
||||
const frame = await request(good.gridUrl.replace('/api/wind', ''));
|
||||
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
|
||||
});
|
||||
|
||||
test('wind returns a JSON 404 for an unknown grid', async () => {
|
||||
const request = install(proxy());
|
||||
await request('/');
|
||||
const res = await request('/grid/nope.bin');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWindRendering } from './rendering.js';
|
||||
|
||||
/** Summarize a wind manifest for the layer status surface. */
|
||||
export function windStats(manifest) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the GFS wind data layer. */
|
||||
export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
let rendering = null;
|
||||
let manifest = null;
|
||||
let error = null;
|
||||
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(); },
|
||||
enable() { enabled = true; rendering?.start(); },
|
||||
disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); },
|
||||
async update(nextViewer, { signal } = {}) {
|
||||
if (!enabled) return false;
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
try {
|
||||
const snapshot = await feed.getSnapshot({ signal: signal || controller.signal });
|
||||
if (!enabled || controller.signal.aborted || signal?.aborted) return false;
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (!snapshot.unavailable) rendering.setField(snapshot);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || signal?.aborted) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
return true;
|
||||
} finally { if (request === controller) request = null; }
|
||||
},
|
||||
destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; },
|
||||
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; },
|
||||
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Normalize a longitude into the half-open world interval. */
|
||||
export function normalizeLongitude(lon) {
|
||||
return ((lon + 180) % 360 + 360) % 360 - 180;
|
||||
}
|
||||
|
||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||
export function sampleWind(field, lon, lat) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return { u: 0, v: 0 };
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = (la1 - lat) / dy;
|
||||
const x0 = Math.floor(x);
|
||||
const tx = x - x0;
|
||||
const yClamped = Math.max(0, Math.min(ny - 1, y));
|
||||
const y0 = Math.floor(yClamped);
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const ty = yClamped - y0;
|
||||
const column = (index) => ((index % nx) + nx) % nx;
|
||||
const value = (array) => {
|
||||
const a = array[y0 * nx + column(x0)];
|
||||
const b = array[y0 * nx + column(x0 + 1)];
|
||||
const c = array[y1 * nx + column(x0)];
|
||||
const d = array[y1 * nx + column(x0 + 1)];
|
||||
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
};
|
||||
return { u: value(u), v: value(v) };
|
||||
}
|
||||
|
||||
/** Return metres represented by one longitude degree at a latitude. */
|
||||
export function metersPerDegreeLon(lat) {
|
||||
return 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** Calculate wind magnitude. */
|
||||
export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp. */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = ['#4a5568', '#7fe3ff', '#7dffb0', '#ffd166', '#ff7a59'];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
const fraction = position - index;
|
||||
if (t === 1) return stops.at(-1);
|
||||
const parse = (color) => color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||
const a = parse(stops[index]);
|
||||
const b = parse(stops[index + 1]);
|
||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
||||
}
|
||||
|
||||
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||
export function advectParticle(particle, wind, dtSeconds, { speedScale = 1 } = {}) {
|
||||
particle.lon = normalizeLongitude(
|
||||
particle.lon + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||
);
|
||||
particle.lat = Math.max(-89, Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320));
|
||||
return particle;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { advectParticle, sampleWind, windColor } from './model.js';
|
||||
|
||||
const field = { u: Float32Array.from([1, 2, 3, 4]), v: Float32Array.from([5, 6, 7, 8]), nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 90 };
|
||||
|
||||
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
|
||||
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
|
||||
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
|
||||
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
|
||||
});
|
||||
|
||||
test('advectParticle moves and constrains particles', () => {
|
||||
const particle = { lon: 179, lat: 88 };
|
||||
advectParticle(particle, { u: 10, v: 100 }, 3600);
|
||||
assert.ok(particle.lon < 0);
|
||||
assert.equal(particle.lat, 89);
|
||||
});
|
||||
|
||||
test('windColor returns a monotonic ramp string', () => {
|
||||
assert.equal(typeof windColor(0), 'string');
|
||||
assert.notEqual(windColor(0), windColor(30));
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { advectParticle, sampleWind, windColor, windSpeed } from './model.js';
|
||||
|
||||
/**
|
||||
* Canvas particle renderer for a global wind field.
|
||||
*
|
||||
* Particles are advected in lon/lat by the sampled field and projected to the
|
||||
* screen through Cesium each frame. Particles hidden by the globe are skipped
|
||||
* with an `EllipsoidalOccluder`, and trails fade into a translucent backdrop.
|
||||
*
|
||||
* @param {{cesium: object, container: HTMLElement, getViewer: Function}} options
|
||||
*/
|
||||
export function createWindRendering({ cesium, container, getViewer } = {}) {
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
let field = null;
|
||||
let particles = [];
|
||||
let frame = null;
|
||||
let running = false;
|
||||
let lastTime = 0;
|
||||
let cssWidth = 1;
|
||||
let cssHeight = 1;
|
||||
|
||||
const randomParticle = () => ({
|
||||
lon: Math.random() * 360 - 180,
|
||||
lat: Math.random() * 178 - 89,
|
||||
age: Math.random() * 180,
|
||||
});
|
||||
|
||||
const budget = () =>
|
||||
Math.max(3000, Math.min(8000, Math.floor((cssWidth * cssHeight) / 1000)));
|
||||
|
||||
const seed = () => {
|
||||
particles = [];
|
||||
const limit = budget();
|
||||
for (let i = 0; i < limit; i += 1) particles.push(randomParticle());
|
||||
};
|
||||
|
||||
const resize = (viewer) => {
|
||||
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
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);
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
|
||||
};
|
||||
|
||||
/** Project lon/lat to CSS pixels, or null when hidden by the globe. */
|
||||
const project = (scene, occluder, lon, lat) => {
|
||||
const cartesian = cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
if (occluder && !occluder.isPointVisible(cartesian)) return null;
|
||||
return cesium.SceneTransforms.worldToWindowCoordinates(scene, cartesian);
|
||||
};
|
||||
|
||||
const draw = (time) => {
|
||||
frame = null;
|
||||
if (!running) return;
|
||||
const viewer = getViewer?.();
|
||||
if (!viewer || viewer.isDestroyed?.()) return;
|
||||
resize(viewer);
|
||||
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
|
||||
lastTime = time;
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
if (!field) {
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
const scene = viewer.scene;
|
||||
const occluder = cesium.EllipsoidalOccluder
|
||||
? new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC)
|
||||
: null;
|
||||
for (const particle of particles) {
|
||||
const wind = sampleWind(field, particle.lon, particle.lat);
|
||||
const before = { lon: particle.lon, lat: particle.lat };
|
||||
advectParticle(particle, wind, dt, { speedScale: 8 });
|
||||
particle.age += dt;
|
||||
const point = project(scene, occluder, particle.lon, particle.lat);
|
||||
const offscreen =
|
||||
!point ||
|
||||
point.x < 0 ||
|
||||
point.y < 0 ||
|
||||
point.x > cssWidth ||
|
||||
point.y > cssHeight;
|
||||
if (particle.age > 240 || offscreen) {
|
||||
Object.assign(particle, randomParticle());
|
||||
continue;
|
||||
}
|
||||
const previous = project(scene, occluder, before.lon, before.lat);
|
||||
if (!previous) continue;
|
||||
context.strokeStyle = windColor(windSpeed(wind.u, wind.v));
|
||||
context.beginPath();
|
||||
context.moveTo(previous.x, previous.y);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
}
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
return {
|
||||
/** Create and append the overlay canvas. */
|
||||
attach() {
|
||||
if (canvas) return;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.inset = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '1';
|
||||
context = canvas.getContext('2d');
|
||||
container.appendChild(canvas);
|
||||
},
|
||||
/** Install a wind field and (re)seed particles. */
|
||||
setField(next) {
|
||||
field = next;
|
||||
if (canvas) {
|
||||
resize(getViewer?.());
|
||||
seed();
|
||||
}
|
||||
},
|
||||
/** Start the animation loop. Safe to call before a field is installed. */
|
||||
start() {
|
||||
if (running || !canvas) return;
|
||||
running = true;
|
||||
lastTime = 0;
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
},
|
||||
/** Stop the animation loop. */
|
||||
stop() {
|
||||
running = false;
|
||||
if (frame !== null) globalThis.cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
},
|
||||
/** Clear the canvas and particles without removing the canvas. */
|
||||
clear() {
|
||||
particles = [];
|
||||
context?.clearRect(0, 0, cssWidth, cssHeight);
|
||||
},
|
||||
/** Stop and remove the overlay canvas. Idempotent. */
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.clear();
|
||||
canvas?.remove();
|
||||
canvas = null;
|
||||
context = null;
|
||||
},
|
||||
/** Test seam: the current particle count. */
|
||||
getParticleCount() {
|
||||
return particles.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createWindRendering } from './rendering.js';
|
||||
|
||||
/** Build a fake browser/Cesium environment for the overlay. */
|
||||
function harness({ occluded = false } = {}) {
|
||||
const strokes = [];
|
||||
const context = {
|
||||
setTransform() {},
|
||||
clearRect() {},
|
||||
fillRect() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
stroke() {
|
||||
strokes.push(true);
|
||||
},
|
||||
};
|
||||
const canvas = {
|
||||
style: {},
|
||||
width: 0,
|
||||
height: 0,
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
getContext: () => context,
|
||||
remove() {
|
||||
this.removed = true;
|
||||
},
|
||||
};
|
||||
const container = {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
appendChild(node) {
|
||||
this.node = node;
|
||||
},
|
||||
};
|
||||
const callbacks = [];
|
||||
globalThis.document = { createElement: () => canvas };
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
callbacks.push(callback);
|
||||
return callbacks.length;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = () => {
|
||||
globalThis.cancelled = true;
|
||||
};
|
||||
const viewer = {
|
||||
container,
|
||||
scene: { canvas, camera: { positionWC: {} } },
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const cesium = {
|
||||
Cartesian3: { fromDegrees: (lon, lat) => ({ lon, lat }) },
|
||||
Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class {
|
||||
isPointVisible() {
|
||||
return !occluded;
|
||||
}
|
||||
},
|
||||
SceneTransforms: {
|
||||
worldToWindowCoordinates: () => ({ x: 100, y: 100 }),
|
||||
},
|
||||
};
|
||||
const rendering = createWindRendering({ cesium, container, getViewer: () => viewer });
|
||||
return { rendering, canvas, container, callbacks, strokes };
|
||||
}
|
||||
|
||||
const FIELD = {
|
||||
u: Float32Array.from([10]),
|
||||
v: Float32Array.from([0]),
|
||||
nx: 1,
|
||||
ny: 1,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx: 360,
|
||||
dy: 180,
|
||||
};
|
||||
|
||||
test('wind rendering owns the canvas and particle lifecycle', () => {
|
||||
const { rendering, canvas, callbacks, strokes } = harness();
|
||||
rendering.attach();
|
||||
assert.equal(typeof canvas.getContext('2d'), 'object');
|
||||
rendering.setField(FIELD);
|
||||
assert.ok(rendering.getParticleCount() >= 3000);
|
||||
rendering.start();
|
||||
callbacks.shift()(16);
|
||||
assert.ok(strokes.length > 0, 'a driven frame draws trails');
|
||||
rendering.stop();
|
||||
assert.equal(globalThis.cancelled, true);
|
||||
rendering.clear();
|
||||
assert.equal(rendering.getParticleCount(), 0);
|
||||
rendering.destroy();
|
||||
rendering.destroy();
|
||||
assert.equal(canvas.removed, true);
|
||||
});
|
||||
|
||||
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');
|
||||
rendering.setField(FIELD);
|
||||
callbacks.shift()(32);
|
||||
assert.ok(strokes.length > 0, 'draws once the field is installed');
|
||||
rendering.stop();
|
||||
});
|
||||
|
||||
test('wind rendering skips particles hidden by the globe', () => {
|
||||
const { rendering, callbacks, strokes } = harness({ occluded: true });
|
||||
rendering.attach();
|
||||
rendering.setField(FIELD);
|
||||
rendering.start();
|
||||
callbacks.shift()(16);
|
||||
assert.equal(strokes.length, 0, 'no trail is drawn for an occluded point');
|
||||
rendering.stop();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Create a source that loads the current GFS wind field. */
|
||||
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) {
|
||||
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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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]);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
Reference in new issue
Block a user