mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(wind): add ECMWF IFS as a second wind model
Add ECMWF IFS 10 m wind alongside NOAA GFS, selectable at runtime. The /api/wind proxy becomes a model dispatcher: `?model=gfs|ifs` (default gfs, unknown → 400), cached per model+cycle+grid. IFS reads the keyless ECMWF Open Data JSON Lines `.index` (note: `<stem>.index`, not `<grib2>.index`), byte-range fetches only the `10u`/`10v` messages, decodes them with the same ecCodes WASM path, and resamples with the existing grid code (ECMWF grids start at lo1=180; the resampler already wraps columns). The client layer gains a `model` parameter (setParams/getParams) with a generation guard so a model switch aborts and refetches exactly once. Registered as an enabled+options layer with a `model` enum. Validated end-to-end against the real 2026-09-14 06z IFS cycle on Node 25 (180x91 grid at 2°, 131,040-byte payload, plausible mean |u|/|v|), and GFS still passes the same check. Attribution: contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. Verified: npm test (3412 pass, 0 fail), check:boundaries.
This commit is contained in:
1 parent
deb34ebe35
commit
bb18e39b7d
10 files changed
+216
-166
No files matched your search
@@ -655,3 +655,5 @@ represent previously published GitHub Releases.
|
|||||||
## [0.1.0] — 2026-02-09
|
## [0.1.0] — 2026-02-09
|
||||||
|
|
||||||
- Initial project version.
|
- Initial project version.
|
||||||
|
|
||||||
|
- Add ECMWF IFS as a selectable wind model alongside GFS.
|
||||||
@@ -190,3 +190,5 @@ Douglas-Peucker simplification, 6-decimal rounding).
|
|||||||
## In-app attribution
|
## In-app attribution
|
||||||
|
|
||||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||||
|
|
||||||
|
- ECMWF IFS: keyless CC BY 4.0 GRIB2 forecast data fetched via `.index` and HTTP Range. Attribution: "Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. Source: https://data.ecmwf.int/forecasts/".
|
||||||
@@ -3402,3 +3402,5 @@ releases imagery layers/listeners on replacement. Destroy invalidates pending
|
|||||||
work and releases owned resources, including late factory results. Supplied 3D
|
work and releases owned resources, including late factory results. Supplied 3D
|
||||||
tilesets remain owned by the caller; tilesets created through the controller's
|
tilesets remain owned by the caller; tilesets created through the controller's
|
||||||
factory are added to its viewer and removed on destruction.
|
factory are added to its viewer and removed on destruction.
|
||||||
|
|
||||||
|
Wind supports GFS/IFS models.
|
||||||
+36
-158
@@ -1,170 +1,48 @@
|
|||||||
import {
|
import { fetchGfsWind } from './wind/gfs.js';
|
||||||
GFS_BUCKET,
|
import { fetchIfsWind } from './wind/ifs.js';
|
||||||
gfsObjectKey,
|
|
||||||
selectLatestGfsCycle,
|
|
||||||
} from './wind/catalog.js';
|
|
||||||
import {
|
|
||||||
fetchText,
|
|
||||||
fetchRange,
|
|
||||||
parseGfsIdx,
|
|
||||||
windMessageRanges,
|
|
||||||
} from './wind/gfs.js';
|
|
||||||
import { decodeWindGribMessage } from './wind/decode.js';
|
import { decodeWindGribMessage } from './wind/decode.js';
|
||||||
import { resampleWindGrid } from './wind/grid.js';
|
|
||||||
|
|
||||||
/**
|
/** Dispatch cached wind requests by model and cycle. */
|
||||||
* NOAA GFS 10 m wind proxy.
|
export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) {
|
||||||
*
|
const caches = new Map();
|
||||||
* Selects the latest available GFS cycle, reads its `.idx` inventory, byte-range
|
const loadings = new Map();
|
||||||
* fetches only the UGRD/VGRD 10 m GRIB2 messages, decodes them with ecCodes
|
const sendJson = (res, value, status = 200) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)); };
|
||||||
* (WASM), resamples to a compact grid, and serves a manifest plus a Float32
|
const refresh = (model) => {
|
||||||
* grid (U values then V values). Keyless; cached per cycle for an hour.
|
const loader = models[model];
|
||||||
*
|
const options = { fetchImpl, now, decodeImpl, targetDx };
|
||||||
* Routes (mounted at `/api/wind`):
|
const promise = (async () => {
|
||||||
* 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 {
|
try {
|
||||||
const base = `https://${GFS_BUCKET}.s3.amazonaws.com/${gfsObjectKey(cycle)}`;
|
const value = await loader(options);
|
||||||
const index = await fetchText({ url: `${base}.idx`, fetchImpl });
|
const id = `${value.cycle.date}-${value.cycle.hour}`;
|
||||||
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 idGrid = `${id}-${targetDx}`;
|
||||||
const manifest = {
|
const manifest = { schemaVersion: 1, model, cycle: value.cycle, fetchedAt: now(), level: value.level, units: value.units, grid: { nx: value.grid.nx, ny: value.grid.ny, lo1: value.grid.lo1, la1: value.grid.la1, dx: value.grid.dx, dy: value.grid.dy }, stale: false, unavailable: false, reason: null, gridUrl: `/api/wind/grid/${idGrid}.bin` };
|
||||||
schemaVersion: 1,
|
const state = { id, idGrid, fetchedAt: manifest.fetchedAt, manifest, grid: value.grid };
|
||||||
model: 'gfs',
|
caches.set(model, state);
|
||||||
cycle: { ...cycle, forecastHour: 0, runIso: cycleRunIso(cycle) },
|
return state;
|
||||||
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) {
|
} catch (error) {
|
||||||
if (cached) {
|
const old = caches.get(model);
|
||||||
cached = {
|
if (old) { old.manifest = { ...old.manifest, stale: true, reason: error.message }; return old; }
|
||||||
...cached,
|
return { id: null, idGrid: null, grid: null, manifest: { schemaVersion: 1, model, stale: true, unavailable: true, reason: error.message } };
|
||||||
manifest: { ...cached.manifest, stale: true, reason: error.message },
|
} finally { loadings.delete(model); }
|
||||||
};
|
|
||||||
} 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;
|
loadings.set(model, promise);
|
||||||
}
|
return promise;
|
||||||
|
};
|
||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
const path = new URL(req.url, 'http://localhost').pathname;
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const state = await refresh();
|
const model = url.searchParams.get('model') || 'gfs';
|
||||||
if (path === '/status') {
|
if (!models[model]) return sendJson(res, { error: 'unknown_model' }, 400);
|
||||||
const { gridUrl, ...manifest } = state.manifest;
|
let state = caches.get(model);
|
||||||
return sendJson(res, manifest);
|
if (!state || now() - state.fetchedAt >= ttlMs) state = loadings.get(model) || refresh(model);
|
||||||
}
|
state = await state;
|
||||||
if (path.startsWith('/grid/')) {
|
if (url.pathname === '/status') { const { gridUrl, ...manifest } = state.manifest; return sendJson(res, manifest); }
|
||||||
if (!state.grid || path !== `/grid/${state.idGrid}.bin`)
|
if (url.pathname.startsWith('/grid/')) {
|
||||||
return sendJson(res, { error: 'unknown_grid' }, 404);
|
if (!state.grid || url.pathname !== `/grid/${state.idGrid}.bin`) return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||||
const payload = Buffer.concat([
|
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)]);
|
||||||
Buffer.from(state.grid.u.buffer, state.grid.u.byteOffset, state.grid.u.byteLength),
|
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=3600, immutable' });
|
||||||
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 res.end(payload);
|
||||||
}
|
}
|
||||||
return sendJson(res, state.manifest);
|
return sendJson(res, state.manifest);
|
||||||
};
|
};
|
||||||
|
return { name: 'wind', configureServer({ middlewares }) { middlewares.use('/api/wind', handler); }, configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); } };
|
||||||
return {
|
|
||||||
name: 'wind',
|
|
||||||
configureServer({ middlewares }) {
|
|
||||||
middlewares.use('/api/wind', handler);
|
|
||||||
},
|
|
||||||
configurePreviewServer({ middlewares }) {
|
|
||||||
middlewares.use('/api/wind', handler);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -87,3 +87,22 @@ export async function fetchRange({
|
|||||||
throw new Error('invalid range length');
|
throw new Error('invalid range length');
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle } from './catalog.js';
|
||||||
|
import { decodeWindGribMessage } from './decode.js';
|
||||||
|
import { resampleWindGrid } from './grid.js';
|
||||||
|
|
||||||
|
/** Fetch and decode the latest GFS 10 m wind field. */
|
||||||
|
export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage } = {}) {
|
||||||
|
const cycle = selectLatestGfsCycle(now());
|
||||||
|
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 });
|
||||||
|
return { cycle: { ...cycle, forecastHour: 0, runIso: `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z` }, level: '10 m above ground', units: 'm/s', grid };
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { fetchText, fetchRange } from './gfs.js';
|
||||||
|
import { decodeWindGribMessage } from './decode.js';
|
||||||
|
import { resampleWindGrid } from './grid.js';
|
||||||
|
|
||||||
|
/** Keyless ECMWF Open Data root for real-time IFS forecasts. */
|
||||||
|
export const IFS_BASE = 'https://data.ecmwf.int/forecasts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the latest IFS cycle that should be available.
|
||||||
|
* @param {number} nowMs - Reference epoch milliseconds.
|
||||||
|
* @param {{availabilityLagMs?: number}} [options]
|
||||||
|
* @returns {{date: string, hour: number}}
|
||||||
|
*/
|
||||||
|
export function selectLatestIfsCycle(
|
||||||
|
nowMs,
|
||||||
|
{ availabilityLagMs = 6 * 3600_000 } = {},
|
||||||
|
) {
|
||||||
|
const shifted = new Date(nowMs - availabilityLagMs);
|
||||||
|
return {
|
||||||
|
date: shifted.toISOString().slice(0, 10).replaceAll('-', ''),
|
||||||
|
hour: Math.floor(shifted.getUTCHours() / 6) * 6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IFS GRIB2 and `.index` URLs for one cycle and step.
|
||||||
|
* @param {{date: string, hour: number, step?: number}} cycle
|
||||||
|
* @returns {{grib: string, index: string}}
|
||||||
|
*/
|
||||||
|
export function ifsObjectUrls({ date, hour, step = 0 }) {
|
||||||
|
const hh = String(hour).padStart(2, '0');
|
||||||
|
const stem = `${IFS_BASE}/${date}/${hh}z/ifs/0p25/oper/${date}${hh}0000-${step}h-oper-fc`;
|
||||||
|
// The inventory sits beside the GRIB2 as `<stem>.index`, NOT `<grib2>.index`.
|
||||||
|
return { grib: `${stem}.grib2`, index: `${stem}.index` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the IFS JSON Lines inventory into `{param, offset, length}` records.
|
||||||
|
* Malformed lines are skipped; a payload with no valid lines is rejected.
|
||||||
|
* @param {string} text - Raw `.index` body.
|
||||||
|
* @returns {Array<{param: string, offset: number, length: number}>}
|
||||||
|
*/
|
||||||
|
export function parseIfsIndex(text) {
|
||||||
|
const entries = [];
|
||||||
|
for (const line of String(text).split(/\r?\n/)) {
|
||||||
|
if (!line) continue;
|
||||||
|
let value;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue; // non-JSON line in the index
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value?.param &&
|
||||||
|
Number.isFinite(value._offset) &&
|
||||||
|
Number.isFinite(value._length)
|
||||||
|
)
|
||||||
|
entries.push({
|
||||||
|
param: value.param,
|
||||||
|
offset: value._offset,
|
||||||
|
length: value._length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!entries.length) throw new Error('malformed IFS index');
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Byte ranges for the IFS 10 m wind components.
|
||||||
|
* @param {Array<{param: string, offset: number, length: number}>} entries
|
||||||
|
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||||
|
*/
|
||||||
|
export function ifsWindRanges(entries) {
|
||||||
|
const range = (param) => {
|
||||||
|
const item = entries.find((entry) => entry.param === param);
|
||||||
|
if (!item) throw new Error(`missing ${param}`);
|
||||||
|
return { start: item.offset, end: item.offset + item.length - 1 };
|
||||||
|
};
|
||||||
|
return { u: range('10u'), v: range('10v') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and decode the latest IFS 10 m wind field.
|
||||||
|
* @param {{fetchImpl?: Function, now?: Function, targetDx?: number,
|
||||||
|
* decodeImpl?: Function}} [options]
|
||||||
|
* @returns {Promise<{cycle: object, level: string, units: string, grid: object}>}
|
||||||
|
*/
|
||||||
|
export async function fetchIfsWind({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
now = () => Date.now(),
|
||||||
|
targetDx = 1,
|
||||||
|
decodeImpl = decodeWindGribMessage,
|
||||||
|
} = {}) {
|
||||||
|
const cycle = selectLatestIfsCycle(now());
|
||||||
|
const urls = ifsObjectUrls(cycle);
|
||||||
|
const index = await fetchText({ url: urls.index, fetchImpl });
|
||||||
|
const ranges = ifsWindRanges(parseIfsIndex(index.toString()));
|
||||||
|
const [uBuffer, vBuffer] = await Promise.all([
|
||||||
|
fetchRange({ url: urls.grib, ...ranges.u, fetchImpl }),
|
||||||
|
fetchRange({ url: urls.grib, ...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 runIso = `${cycle.date.slice(0, 4)}-${cycle.date.slice(4, 6)}-${cycle.date.slice(6)}T${String(cycle.hour).padStart(2, '0')}:00:00.000Z`;
|
||||||
|
return {
|
||||||
|
cycle: { ...cycle, forecastHour: 0, runIso },
|
||||||
|
level: '10 m above ground',
|
||||||
|
units: 'm/s',
|
||||||
|
grid,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -188,6 +188,13 @@ export const DATA_CREDITS = [
|
|||||||
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
|
'<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)',
|
'(U.S. public domain; forecast, not observations)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'ecmwf-ifs',
|
||||||
|
html:
|
||||||
|
'Contains modified Copernicus/ECMWF IFS forecast data, licensed under CC BY 4.0. ' +
|
||||||
|
'Source: https://data.ecmwf.int/forecasts/ ' +
|
||||||
|
'(<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a>)',
|
||||||
|
},
|
||||||
// ── Bundled snapshots ───────────────────────────────────────────
|
// ── Bundled snapshots ───────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: 'datacenters',
|
key: 'datacenters',
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ function integerOption(key, token, defaultValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OPTION_GROUPS = Object.freeze({
|
const OPTION_GROUPS = Object.freeze({
|
||||||
|
wind: Object.freeze([
|
||||||
|
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
|
||||||
|
]),
|
||||||
flights: Object.freeze([
|
flights: Object.freeze([
|
||||||
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
|
||||||
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
|
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
|
||||||
@@ -292,7 +295,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||||||
Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }),
|
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: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function windStats(manifest) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create the GFS wind data layer. */
|
/** Create the GFS/IFS wind data layer. */
|
||||||
export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) {
|
export function createWindLayer({ feed, cesium = Cesium, container, services } = {}) {
|
||||||
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
|
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
@@ -19,8 +19,10 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
|
|||||||
let rendering = null;
|
let rendering = null;
|
||||||
let manifest = null;
|
let manifest = null;
|
||||||
let error = null;
|
let error = null;
|
||||||
|
let model = 'gfs';
|
||||||
|
let generation = 0;
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS', updateInterval: 3600_000,
|
id: 'wind', name: 'Wind', icon: '🌬', source: 'NOAA GFS / ECMWF IFS', updateInterval: 3600_000,
|
||||||
init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); },
|
init(nextViewer) { viewer = nextViewer; rendering = createWindRendering({ cesium, container: nextViewer.container, getViewer: () => viewer }); rendering.attach(); },
|
||||||
enable() { enabled = true; rendering?.start(); },
|
enable() { enabled = true; rendering?.start(); },
|
||||||
disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); },
|
disable() { request?.abort(); request = null; enabled = false; rendering?.stop(); rendering?.clear(); },
|
||||||
@@ -30,7 +32,7 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
request = controller;
|
request = controller;
|
||||||
try {
|
try {
|
||||||
const snapshot = await feed.getSnapshot({ signal: signal || controller.signal });
|
const snapshot = await feed.getSnapshot({ signal: signal || controller.signal, model });
|
||||||
if (!enabled || controller.signal.aborted || signal?.aborted) return false;
|
if (!enabled || controller.signal.aborted || signal?.aborted) return false;
|
||||||
manifest = snapshot;
|
manifest = snapshot;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -42,8 +44,21 @@ export function createWindLayer({ feed, cesium = Cesium, container, services } =
|
|||||||
return true;
|
return true;
|
||||||
} finally { if (request === controller) request = null; }
|
} finally { if (request === controller) request = null; }
|
||||||
},
|
},
|
||||||
|
setParams(params = {}) {
|
||||||
|
if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return;
|
||||||
|
model = params.model;
|
||||||
|
generation += 1;
|
||||||
|
if (enabled) {
|
||||||
|
request?.abort();
|
||||||
|
const currentGeneration = generation;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (enabled && currentGeneration === generation) layer.update(viewer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getParams() { return { model }; },
|
||||||
destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; },
|
destroy() { request?.abort(); request = null; enabled = false; rendering?.destroy(); rendering = null; viewer = null; },
|
||||||
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error }; },
|
getStats() { return { ...windStats(manifest), error: error || windStats(manifest).error, model }; },
|
||||||
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
||||||
};
|
};
|
||||||
return layer;
|
return layer;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** Create a source that loads the current GFS wind field. */
|
/** Create a source that loads the current wind field. */
|
||||||
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) {
|
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args) } = {}) {
|
||||||
return {
|
return {
|
||||||
async getSnapshot({ signal } = {}) {
|
async getSnapshot({ signal, model = 'gfs' } = {}) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const response = await fetchImpl('/api/wind/manifest', {
|
const response = await fetchImpl(`/api/wind/manifest?model=${encodeURIComponent(model)}`, {
|
||||||
signal,
|
signal,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user