mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
style(wind): adopt automatically discovered runtime formatting
This commit is contained in:
1 parent
a5dad07cc4
commit
9ae79cf34a
13 files changed
+421
-97
No files matched your search
+99
-22
@@ -3,14 +3,33 @@ import { fetchIfsWind } from './wind/ifs.js';
|
|||||||
import { decodeWindGribMessage } from './wind/decode.js';
|
import { decodeWindGribMessage } from './wind/decode.js';
|
||||||
|
|
||||||
/** Serve bounded, single-flight, per-model forecast snapshots from fixed providers. */
|
/** Serve bounded, single-flight, per-model forecast snapshots from fixed providers. */
|
||||||
export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImpl = decodeWindGribMessage, targetDx = 1, ttlMs = 3600_000, timeoutMs = 40_000, models = { gfs: fetchGfsWind, ifs: fetchIfsWind } } = {}) {
|
export function windProxy({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
now = () => Date.now(),
|
||||||
|
decodeImpl = decodeWindGribMessage,
|
||||||
|
targetDx = 1,
|
||||||
|
ttlMs = 3600_000,
|
||||||
|
timeoutMs = 40_000,
|
||||||
|
models = { gfs: fetchGfsWind, ifs: fetchIfsWind },
|
||||||
|
} = {}) {
|
||||||
const caches = new Map();
|
const caches = new Map();
|
||||||
const loadings = new Map();
|
const loadings = new Map();
|
||||||
const grids = new Map();
|
const grids = new Map();
|
||||||
const attempts = new Map();
|
const attempts = new Map();
|
||||||
const unavailable = (model) => ({ manifest: { model, schemaVersion: 1, unavailable: true, stale: true, reason: 'Wind upstream unavailable' } });
|
const unavailable = (model) => ({
|
||||||
|
manifest: {
|
||||||
|
model,
|
||||||
|
schemaVersion: 1,
|
||||||
|
unavailable: true,
|
||||||
|
stale: true,
|
||||||
|
reason: 'Wind upstream unavailable',
|
||||||
|
},
|
||||||
|
});
|
||||||
const sendJson = (res, value, status = 200) => {
|
const sendJson = (res, value, status = 200) => {
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
res.end(JSON.stringify(value));
|
res.end(JSON.stringify(value));
|
||||||
};
|
};
|
||||||
function refresh(model) {
|
function refresh(model) {
|
||||||
@@ -21,15 +40,36 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp
|
|||||||
operation.promise = (async () => {
|
operation.promise = (async () => {
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const value = await models[model]({ fetchImpl, now, decodeImpl, targetDx, signal: controller.signal });
|
const value = await models[model]({
|
||||||
|
fetchImpl,
|
||||||
|
now,
|
||||||
|
decodeImpl,
|
||||||
|
targetDx,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
const { grid, cycle } = value;
|
const { grid, cycle } = value;
|
||||||
if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite)) throw new Error('Invalid wind grid');
|
if (!grid.u.every(Number.isFinite) || !grid.v.every(Number.isFinite))
|
||||||
|
throw new Error('Invalid wind grid');
|
||||||
const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`;
|
const id = `${model}-${cycle.date}-${cycle.hour}-f${cycle.forecastHour || 0}-${targetDx}`;
|
||||||
const manifest = {
|
const manifest = {
|
||||||
schemaVersion: 1, model, cycle, fetchedAt: now(), level: value.level, units: value.units,
|
schemaVersion: 1,
|
||||||
grid: { nx: grid.nx, ny: grid.ny, lo1: grid.lo1, la1: grid.la1, dx: grid.dx, dy: grid.dy },
|
model,
|
||||||
stale: false, unavailable: false, reason: null,
|
cycle,
|
||||||
|
fetchedAt: now(),
|
||||||
|
level: value.level,
|
||||||
|
units: value.units,
|
||||||
|
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/${id}.bin?model=${model}`,
|
gridUrl: `/api/wind/grid/${id}.bin?model=${model}`,
|
||||||
};
|
};
|
||||||
const state = { id, grid, manifest, fetchedAt: now() };
|
const state = { id, grid, manifest, fetchedAt: now() };
|
||||||
@@ -47,7 +87,11 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp
|
|||||||
}
|
}
|
||||||
const old = caches.get(model);
|
const old = caches.get(model);
|
||||||
if (old) {
|
if (old) {
|
||||||
old.manifest = { ...old.manifest, stale: true, reason: 'Wind upstream unavailable' };
|
old.manifest = {
|
||||||
|
...old.manifest,
|
||||||
|
stale: true,
|
||||||
|
reason: 'Wind upstream unavailable',
|
||||||
|
};
|
||||||
return old;
|
return old;
|
||||||
}
|
}
|
||||||
return unavailable(model);
|
return unavailable(model);
|
||||||
@@ -62,38 +106,71 @@ export function windProxy({ fetchImpl = fetch, now = () => Date.now(), decodeImp
|
|||||||
const handler = async (req, res) => {
|
const handler = async (req, res) => {
|
||||||
const url = new URL(req.url, 'http://localhost');
|
const url = new URL(req.url, 'http://localhost');
|
||||||
const model = url.searchParams.get('model') || 'gfs';
|
const model = url.searchParams.get('model') || 'gfs';
|
||||||
if (req.method !== 'GET') return sendJson(res, { error: 'method_not_allowed' }, 405);
|
if (req.method !== 'GET')
|
||||||
if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model)) return sendJson(res, { error: 'unknown_model' }, 400);
|
return sendJson(res, { error: 'method_not_allowed' }, 405);
|
||||||
|
if (!['gfs', 'ifs'].includes(model) || !Object.hasOwn(models, model))
|
||||||
|
return sendJson(res, { error: 'unknown_model' }, 400);
|
||||||
if (url.pathname.startsWith('/grid/')) {
|
if (url.pathname.startsWith('/grid/')) {
|
||||||
const id = url.pathname.slice(6).replace(/\.bin$/, '');
|
const id = url.pathname.slice(6).replace(/\.bin$/, '');
|
||||||
const state = url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id);
|
const state =
|
||||||
|
url.pathname === `/grid/${id}.bin` && grids.get(model)?.get(id);
|
||||||
if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
|
if (!state) return sendJson(res, { error: 'unknown_grid' }, 404);
|
||||||
const bytes = 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)]);
|
const bytes = Buffer.concat([
|
||||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'public, max-age=3600, immutable' });
|
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(bytes);
|
return res.end(bytes);
|
||||||
}
|
}
|
||||||
if (!['/', '/manifest', '/status'].includes(url.pathname)) return sendJson(res, { error: 'not_found' }, 404);
|
if (!['/', '/manifest', '/status'].includes(url.pathname))
|
||||||
|
return sendJson(res, { error: 'not_found' }, 404);
|
||||||
let state = caches.get(model);
|
let state = caches.get(model);
|
||||||
if (!state || now() - state.fetchedAt >= ttlMs) {
|
if (!state || now() - state.fetchedAt >= ttlMs) {
|
||||||
let operation = loadings.get(model);
|
let operation = loadings.get(model);
|
||||||
if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000) operation = refresh(model);
|
if (!operation && now() - (attempts.get(model) ?? -Infinity) >= 60_000)
|
||||||
|
operation = refresh(model);
|
||||||
if (operation) {
|
if (operation) {
|
||||||
let disconnected = false;
|
let disconnected = false;
|
||||||
operation.waiters += 1;
|
operation.waiters += 1;
|
||||||
const close = () => { disconnected = true; if (--operation.waiters === 0) operation.controller.abort(); };
|
const close = () => {
|
||||||
|
disconnected = true;
|
||||||
|
if (--operation.waiters === 0) operation.controller.abort();
|
||||||
|
};
|
||||||
res.once?.('close', close);
|
res.once?.('close', close);
|
||||||
try { state = await operation.promise; }
|
try {
|
||||||
finally { res.removeListener?.('close', close); if (!disconnected) operation.waiters -= 1; }
|
state = await operation.promise;
|
||||||
|
} finally {
|
||||||
|
res.removeListener?.('close', close);
|
||||||
|
if (!disconnected) operation.waiters -= 1;
|
||||||
|
}
|
||||||
if (disconnected) return;
|
if (disconnected) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const manifest = state?.manifest || unavailable(model).manifest;
|
const manifest = state?.manifest || unavailable(model).manifest;
|
||||||
if (url.pathname === '/status') { const { gridUrl, ...status } = manifest; return sendJson(res, status); }
|
if (url.pathname === '/status') {
|
||||||
|
const { gridUrl, ...status } = manifest;
|
||||||
|
return sendJson(res, status);
|
||||||
|
}
|
||||||
return sendJson(res, manifest);
|
return sendJson(res, manifest);
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
name: 'wind',
|
name: 'wind',
|
||||||
configureServer({ middlewares }) { middlewares.use('/api/wind', handler); },
|
configureServer({ middlewares }) {
|
||||||
configurePreviewServer({ middlewares }) { middlewares.use('/api/wind', handler); },
|
middlewares.use('/api/wind', handler);
|
||||||
|
},
|
||||||
|
configurePreviewServer({ middlewares }) {
|
||||||
|
middlewares.use('/api/wind', handler);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,14 @@ export function gfsObjectKey({ date, hour, forecastHour = 0 }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Select the latest GFS cycle that should be available. */
|
/** Select the latest GFS cycle that should be available. */
|
||||||
export function selectLatestGfsCycle(nowMs, { availabilityLagMs = 5 * 3600_000 } = {}) {
|
export function selectLatestGfsCycle(
|
||||||
|
nowMs,
|
||||||
|
{ availabilityLagMs = 5 * 3600_000 } = {},
|
||||||
|
) {
|
||||||
const d = new Date(nowMs - availabilityLagMs);
|
const d = new Date(nowMs - availabilityLagMs);
|
||||||
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
const date = new Date(
|
||||||
|
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||||
|
);
|
||||||
const hour = Math.floor(d.getUTCHours() / 6) * 6;
|
const hour = Math.floor(d.getUTCHours() / 6) * 6;
|
||||||
date.setUTCHours(hour);
|
date.setUTCHours(hour);
|
||||||
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
|
return { date: date.toISOString().slice(0, 10).replaceAll('-', ''), hour };
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
|
/** Lazy shared ecCodes WASM instance, created only for an active wind request. */
|
||||||
let eccodesPromise = null;
|
let eccodesPromise = null;
|
||||||
function loadEccodes() {
|
function loadEccodes() {
|
||||||
eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm').then(module => module.createEccodes()).catch(error => {
|
eccodesPromise ??= import('@meri-imperiumi/eccodes-wasm')
|
||||||
eccodesPromise = null;
|
.then((module) => module.createEccodes())
|
||||||
throw error;
|
.catch((error) => {
|
||||||
});
|
eccodesPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
return eccodesPromise;
|
return eccodesPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode one bounded message in WASM memory without mounting the host filesystem. */
|
/** Decode one bounded message in WASM memory without mounting the host filesystem. */
|
||||||
export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
||||||
if (buffer.byteLength > 8 * 1024 * 1024) throw new Error('Wind GRIB exceeds byte budget');
|
if (buffer.byteLength > 8 * 1024 * 1024)
|
||||||
const eccodes = eccodesModule ?? await loadEccodes();
|
throw new Error('Wind GRIB exceeds byte budget');
|
||||||
|
const eccodes = eccodesModule ?? (await loadEccodes());
|
||||||
// Everything after this await is synchronous. A shared virtual file is safe
|
// Everything after this await is synchronous. A shared virtual file is safe
|
||||||
// across U/V and model requests and its storage is released after each decode.
|
// across U/V and model requests and its storage is released after each decode.
|
||||||
const file = '/gev-wind.grib2';
|
const file = '/gev-wind.grib2';
|
||||||
@@ -21,9 +24,17 @@ export async function decodeWindGribMessage(buffer, { eccodesModule } = {}) {
|
|||||||
handle = eccodes.openGrib(file);
|
handle = eccodes.openGrib(file);
|
||||||
const ni = handle.getLong('Ni');
|
const ni = handle.getLong('Ni');
|
||||||
const nj = handle.getLong('Nj');
|
const nj = handle.getLong('Nj');
|
||||||
if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000) throw new Error('Invalid wind GRIB geometry');
|
if (
|
||||||
|
!Number.isInteger(ni) ||
|
||||||
|
!Number.isInteger(nj) ||
|
||||||
|
ni < 1 ||
|
||||||
|
nj < 1 ||
|
||||||
|
ni * nj > 2_000_000
|
||||||
|
)
|
||||||
|
throw new Error('Invalid wind GRIB geometry');
|
||||||
return {
|
return {
|
||||||
ni, nj,
|
ni,
|
||||||
|
nj,
|
||||||
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
|
lo1: handle.getDouble('longitudeOfFirstGridPointInDegrees'),
|
||||||
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
|
la1: handle.getDouble('latitudeOfFirstGridPointInDegrees'),
|
||||||
di: handle.getDouble('iDirectionIncrementInDegrees'),
|
di: handle.getDouble('iDirectionIncrementInDegrees'),
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export function parseGfsIdx(text) {
|
|||||||
* @param {{level?: string}} [options]
|
* @param {{level?: string}} [options]
|
||||||
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
* @returns {{u: {start: number, end: number}, v: {start: number, end: number}}}
|
||||||
*/
|
*/
|
||||||
export function windMessageRanges(parsed, { level = '10 m above ground' } = {}) {
|
export function windMessageRanges(
|
||||||
|
parsed,
|
||||||
|
{ level = '10 m above ground' } = {},
|
||||||
|
) {
|
||||||
const range = (variable) => {
|
const range = (variable) => {
|
||||||
const index = parsed.messages.findIndex(
|
const index = parsed.messages.findIndex(
|
||||||
(message) => message.variable === variable && message.level === level,
|
(message) => message.variable === variable && message.level === level,
|
||||||
@@ -57,7 +60,10 @@ export function windMessageRanges(parsed, { level = '10 m above ground' } = {})
|
|||||||
/** Fetch a bounded text response as a Buffer. */
|
/** Fetch a bounded text response as a Buffer. */
|
||||||
export async function fetchText({ url, fetchImpl = fetch, signal }) {
|
export async function fetchText({ url, fetchImpl = fetch, signal }) {
|
||||||
const response = await fetchImpl(url, { signal, redirect: 'error' });
|
const response = await fetchImpl(url, { signal, redirect: 'error' });
|
||||||
if (!response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); }
|
if (!response.ok) {
|
||||||
|
await response.body?.cancel();
|
||||||
|
throw new Error('Wind upstream unavailable');
|
||||||
|
}
|
||||||
return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
|
return Buffer.from(await readWindBody(response, 2 * 1024 * 1024, signal));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +79,13 @@ export async function fetchRange({
|
|||||||
signal,
|
signal,
|
||||||
maxBytes = 8 * 1024 * 1024,
|
maxBytes = 8 * 1024 * 1024,
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end - start + 1 > maxBytes)
|
if (
|
||||||
|
!Number.isSafeInteger(start) ||
|
||||||
|
!Number.isSafeInteger(end) ||
|
||||||
|
start < 0 ||
|
||||||
|
end < start ||
|
||||||
|
end - start + 1 > maxBytes
|
||||||
|
)
|
||||||
throw new Error('range too large');
|
throw new Error('range too large');
|
||||||
const response = await fetchImpl(url, {
|
const response = await fetchImpl(url, {
|
||||||
signal,
|
signal,
|
||||||
@@ -82,19 +94,35 @@ export async function fetchRange({
|
|||||||
});
|
});
|
||||||
const validStatus =
|
const validStatus =
|
||||||
response.status === 206 || (response.status === 200 && start === 0);
|
response.status === 206 || (response.status === 200 && start === 0);
|
||||||
if (!validStatus || !response.ok) { await response.body?.cancel(); throw new Error('Wind upstream unavailable'); }
|
if (!validStatus || !response.ok) {
|
||||||
const buffer = Buffer.from(await readWindBody(response, end - start + 1, signal));
|
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)
|
if (buffer.length !== end - start + 1)
|
||||||
throw new Error('invalid range length');
|
throw new Error('invalid range length');
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { GFS_BUCKET, gfsObjectKey, selectLatestGfsCycle, nearestGfsStep } from './catalog.js';
|
import {
|
||||||
|
GFS_BUCKET,
|
||||||
|
gfsObjectKey,
|
||||||
|
selectLatestGfsCycle,
|
||||||
|
nearestGfsStep,
|
||||||
|
} from './catalog.js';
|
||||||
import { decodeWindGribMessage } from './decode.js';
|
import { decodeWindGribMessage } from './decode.js';
|
||||||
import { resampleWindGrid } from './grid.js';
|
import { resampleWindGrid } from './grid.js';
|
||||||
|
|
||||||
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
|
/** Fetch and decode the GFS 10 m wind field valid closest to now. */
|
||||||
export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(), targetDx = 1, decodeImpl = decodeWindGribMessage, signal } = {}) {
|
export async function fetchGfsWind({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
now = () => Date.now(),
|
||||||
|
targetDx = 1,
|
||||||
|
decodeImpl = decodeWindGribMessage,
|
||||||
|
signal,
|
||||||
|
} = {}) {
|
||||||
const nowMs = now();
|
const nowMs = now();
|
||||||
const cycle = selectLatestGfsCycle(nowMs);
|
const cycle = selectLatestGfsCycle(nowMs);
|
||||||
const runMs = Date.UTC(
|
const runMs = Date.UTC(
|
||||||
@@ -114,8 +142,24 @@ export async function fetchGfsWind({ fetchImpl = fetch, now = () => Date.now(),
|
|||||||
fetchRange({ url: base, ...ranges.v, fetchImpl, signal }),
|
fetchRange({ url: base, ...ranges.v, fetchImpl, signal }),
|
||||||
]);
|
]);
|
||||||
const [u, v] = await Promise.all([decodeImpl(uBuffer), decodeImpl(vBuffer)]);
|
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 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 = new Date(runMs).toISOString();
|
const runIso = new Date(runMs).toISOString();
|
||||||
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
const validIso = new Date(runMs + forecastHour * 3600_000).toISOString();
|
||||||
return { cycle: { ...cycle, forecastHour, runIso, validIso }, level: '10 m above ground', units: 'm/s', grid };
|
return {
|
||||||
|
cycle: { ...cycle, forecastHour, runIso, validIso },
|
||||||
|
level: '10 m above ground',
|
||||||
|
units: 'm/s',
|
||||||
|
grid,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,22 @@ export function resampleWindGrid({
|
|||||||
dx = 1,
|
dx = 1,
|
||||||
dy = 1,
|
dy = 1,
|
||||||
}) {
|
}) {
|
||||||
if (!Number.isInteger(ni) || !Number.isInteger(nj) || ni < 1 || nj < 1 || ni * nj > 2_000_000 || u?.length !== ni * nj || v?.length !== ni * nj || ![lo1, la1, di, dj, dx, dy].every(Number.isFinite) || di <= 0 || dj <= 0 || dx < 0.25 || dy < 0.25 || dx > 180 || dy > 180)
|
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');
|
throw new Error('Invalid wind grid geometry');
|
||||||
const nx = Math.round(360 / dx);
|
const nx = Math.round(360 / dx);
|
||||||
const ny = Math.round(180 / dy) + 1;
|
const ny = Math.round(180 / dy) + 1;
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ export function ifsWindRanges(entries) {
|
|||||||
export function nearestIfsStep(hours, cycleHour) {
|
export function nearestIfsStep(hours, cycleHour) {
|
||||||
const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360;
|
const max = cycleHour === 6 || cycleHour === 18 ? 144 : 360;
|
||||||
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
const value = Math.max(0, Number.isFinite(hours) ? hours : 0);
|
||||||
const step = value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6;
|
const step =
|
||||||
|
value <= 144 ? Math.round(value / 3) * 3 : Math.round(value / 6) * 6;
|
||||||
return Math.max(0, Math.min(max, step));
|
return Math.max(0, Math.min(max, step));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,7 +387,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||||||
}),
|
}),
|
||||||
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
|
||||||
Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind' }),
|
Object.freeze({
|
||||||
|
id: 'wind',
|
||||||
|
token: 'k',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'wind',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const REGISTERED_LAYER_IDS = Object.freeze(
|
export const REGISTERED_LAYER_IDS = Object.freeze(
|
||||||
|
|||||||
+5
-1
@@ -2,7 +2,11 @@ import { createSourceSlot } from '../sources/sourceSlot.js';
|
|||||||
import { createWindLayer as createLayer } from '../layers/wind/index.js';
|
import { createWindLayer as createLayer } from '../layers/wind/index.js';
|
||||||
import { createWindSource } from '../layers/wind/source.js';
|
import { createWindSource } from '../layers/wind/source.js';
|
||||||
|
|
||||||
const sourceSlot = createSourceSlot(createWindSource(), ['getSnapshot'], 'Wind source');
|
const sourceSlot = createSourceSlot(
|
||||||
|
createWindSource(),
|
||||||
|
['getSnapshot'],
|
||||||
|
'Wind source',
|
||||||
|
);
|
||||||
export const configureWindSource = sourceSlot.configure;
|
export const configureWindSource = sourceSlot.configure;
|
||||||
|
|
||||||
/** Create a wind layer using the configured application source by default. */
|
/** Create a wind layer using the configured application source by default. */
|
||||||
|
|||||||
+109
-27
@@ -4,7 +4,9 @@ import { createWindRendering } from './rendering.js';
|
|||||||
/** Format a forecast timestamp explicitly in UTC. */
|
/** Format a forecast timestamp explicitly in UTC. */
|
||||||
export function formatWindValidTime(value) {
|
export function formatWindValidTime(value) {
|
||||||
const ms = Date.parse(value);
|
const ms = Date.parse(value);
|
||||||
return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : null;
|
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. */
|
/** Forecast issue time is source age; valid time is reported separately. */
|
||||||
@@ -13,13 +15,20 @@ export function windStats(manifest) {
|
|||||||
return {
|
return {
|
||||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
||||||
lastUpdate: Number.isFinite(run) ? run : null,
|
lastUpdate: Number.isFinite(run) ? run : null,
|
||||||
error: manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
error:
|
||||||
|
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construct one wind layer with an explicit source and owned animation. */
|
/** Construct one wind layer with an explicit source and owned animation. */
|
||||||
export function createWindLayer({ feed, cesium = Cesium, container, createRendering = createWindRendering } = {}) {
|
export function createWindLayer({
|
||||||
if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source');
|
feed,
|
||||||
|
cesium = Cesium,
|
||||||
|
container,
|
||||||
|
createRendering = createWindRendering,
|
||||||
|
} = {}) {
|
||||||
|
if (typeof feed?.getSnapshot !== 'function')
|
||||||
|
throw new TypeError('Wind requires a snapshot source');
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
let request = null;
|
let request = null;
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
@@ -32,18 +41,32 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
|||||||
let rowControlsListener = null;
|
let rowControlsListener = null;
|
||||||
const notify = () => rowControlsListener?.();
|
const notify = () => rowControlsListener?.();
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'wind', name: 'Wind', icon: '🌬', source: 'GFS / ECMWF IFS · FORECAST', updateInterval: 3600_000,
|
id: 'wind',
|
||||||
|
name: 'Wind',
|
||||||
|
icon: '🌬',
|
||||||
|
source: 'GFS / ECMWF IFS · FORECAST',
|
||||||
|
updateInterval: 3600_000,
|
||||||
init(nextViewer) {
|
init(nextViewer) {
|
||||||
viewer = nextViewer;
|
viewer = nextViewer;
|
||||||
rendering = createRendering({ cesium, container: container ?? nextViewer.container, getViewer: () => viewer });
|
rendering = createRendering({
|
||||||
|
cesium,
|
||||||
|
container: container ?? nextViewer.container,
|
||||||
|
getViewer: () => viewer,
|
||||||
|
});
|
||||||
rendering.attach();
|
rendering.attach();
|
||||||
},
|
},
|
||||||
enable() { enabled = true; rendering?.start(); },
|
enable() {
|
||||||
|
enabled = true;
|
||||||
|
rendering?.start();
|
||||||
|
},
|
||||||
disable() {
|
disable() {
|
||||||
enabled = false;
|
enabled = false;
|
||||||
generation += 1;
|
generation += 1;
|
||||||
request?.abort(); request = null; loading = false;
|
request?.abort();
|
||||||
rendering?.stop(); rendering?.clear();
|
request = null;
|
||||||
|
loading = false;
|
||||||
|
rendering?.stop();
|
||||||
|
rendering?.clear();
|
||||||
},
|
},
|
||||||
async update(nextViewer, { signal } = {}) {
|
async update(nextViewer, { signal } = {}) {
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
@@ -57,12 +80,21 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
|||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const snapshot = await feed.getSnapshot({ signal: controller.signal, model });
|
const snapshot = await feed.getSnapshot({
|
||||||
if (!enabled || controller.signal.aborted || request !== controller) return false;
|
signal: controller.signal,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
if (!enabled || controller.signal.aborted || request !== controller)
|
||||||
|
return false;
|
||||||
manifest = snapshot;
|
manifest = snapshot;
|
||||||
error = null;
|
error = null;
|
||||||
if (snapshot.unavailable) { rendering.stop(); rendering.clear(); }
|
if (snapshot.unavailable) {
|
||||||
else { rendering.setField(snapshot); rendering.start(); }
|
rendering.stop();
|
||||||
|
rendering.clear();
|
||||||
|
} else {
|
||||||
|
rendering.setField(snapshot);
|
||||||
|
rendering.start();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (controller.signal.aborted || request !== controller) return false;
|
if (controller.signal.aborted || request !== controller) return false;
|
||||||
@@ -70,31 +102,81 @@ export function createWindLayer({ feed, cesium = Cesium, container, createRender
|
|||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
if (request === controller) { request = null; loading = false; notify(); }
|
if (request === controller) {
|
||||||
|
request = null;
|
||||||
|
loading = false;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setParams(params = {}) {
|
setParams(params = {}) {
|
||||||
if (!['gfs', 'ifs'].includes(params.model) || params.model === model) return;
|
if (!['gfs', 'ifs'].includes(params.model) || params.model === model)
|
||||||
|
return;
|
||||||
model = params.model;
|
model = params.model;
|
||||||
request?.abort(); request = null;
|
request?.abort();
|
||||||
manifest = null; error = null; loading = enabled;
|
request = null;
|
||||||
rendering?.stop(); rendering?.clear();
|
manifest = null;
|
||||||
|
error = null;
|
||||||
|
loading = enabled;
|
||||||
|
rendering?.stop();
|
||||||
|
rendering?.clear();
|
||||||
const current = ++generation;
|
const current = ++generation;
|
||||||
notify();
|
notify();
|
||||||
if (enabled) queueMicrotask(() => {
|
if (enabled)
|
||||||
if (enabled && generation === current) void layer.update(viewer);
|
queueMicrotask(() => {
|
||||||
});
|
if (enabled && generation === current) void layer.update(viewer);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getParams() {
|
||||||
|
return { model };
|
||||||
},
|
},
|
||||||
getParams() { return { model }; },
|
|
||||||
getRowControls() {
|
getRowControls() {
|
||||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||||
return { chips: ['gfs', 'ifs'].map((value) => ({ id: `model-${value}`, label: value.toUpperCase(), active: model === value, params: { model: value }, title: value === 'ifs' ? 'ECMWF IFS 10 m forecast' : 'NOAA GFS 10 m forecast' })), legend: [{ label: '0', color: '#1e3a8a' }, { label: '10', color: '#22d3ee' }, { label: '20', color: '#fbbf24' }, { label: '30+ m/s', color: '#ef4444' }], info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · Valid: ${valid || 'Unavailable'} · Issued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}` };
|
return {
|
||||||
|
chips: ['gfs', 'ifs'].map((value) => ({
|
||||||
|
id: `model-${value}`,
|
||||||
|
label: value.toUpperCase(),
|
||||||
|
active: model === value,
|
||||||
|
params: { model: value },
|
||||||
|
title:
|
||||||
|
value === 'ifs'
|
||||||
|
? 'ECMWF IFS 10 m forecast'
|
||||||
|
: 'NOAA GFS 10 m forecast',
|
||||||
|
})),
|
||||||
|
legend: [
|
||||||
|
{ label: '0', color: '#1e3a8a' },
|
||||||
|
{ label: '10', color: '#22d3ee' },
|
||||||
|
{ label: '20', color: '#fbbf24' },
|
||||||
|
{ label: '30+ m/s', color: '#ef4444' },
|
||||||
|
],
|
||||||
|
info: `${model === 'ifs' ? 'ECMWF IFS' : '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,
|
||||||
|
model: model.toUpperCase(),
|
||||||
|
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||||
|
validTime:
|
||||||
|
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||||
|
error: error || windStats(manifest).error,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getParticleCount() {
|
||||||
|
return rendering?.getParticleCount() || 0;
|
||||||
},
|
},
|
||||||
setRowControlsListener(listener) { rowControlsListener = typeof listener === 'function' ? listener : null; },
|
|
||||||
destroy() { layer.disable(); rendering?.destroy(); rendering = null; viewer = null; rowControlsListener = null; },
|
|
||||||
getStats() { return { ...windStats(manifest), loading, model: model.toUpperCase(), source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS', validTime: formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable', error: error || windStats(manifest).error }; },
|
|
||||||
getParticleCount() { return rendering?.getParticleCount() || 0; },
|
|
||||||
};
|
};
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Normalize a longitude into the half-open world interval. */
|
/** Normalize a longitude into the half-open world interval. */
|
||||||
export function normalizeLongitude(lon) {
|
export function normalizeLongitude(lon) {
|
||||||
return ((lon + 180) % 360 + 360) % 360 - 180;
|
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||||
@@ -38,23 +38,41 @@ export function windSpeed(u, v) {
|
|||||||
|
|
||||||
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
||||||
export function windColor(speed, { max = 30 } = {}) {
|
export function windColor(speed, { max = 30 } = {}) {
|
||||||
const stops = ['#1e3a8a', '#2563eb', '#22d3ee', '#34d399', '#fbbf24', '#f97316', '#ef4444'];
|
const stops = [
|
||||||
|
'#1e3a8a',
|
||||||
|
'#2563eb',
|
||||||
|
'#22d3ee',
|
||||||
|
'#34d399',
|
||||||
|
'#fbbf24',
|
||||||
|
'#f97316',
|
||||||
|
'#ef4444',
|
||||||
|
];
|
||||||
const t = Math.max(0, Math.min(1, speed / max));
|
const t = Math.max(0, Math.min(1, speed / max));
|
||||||
const position = t * (stops.length - 1);
|
const position = t * (stops.length - 1);
|
||||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||||
const fraction = position - index;
|
const fraction = position - index;
|
||||||
if (t === 1) return stops.at(-1);
|
if (t === 1) return stops.at(-1);
|
||||||
const parse = (color) => color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
const parse = (color) =>
|
||||||
|
color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||||
const a = parse(stops[index]);
|
const a = parse(stops[index]);
|
||||||
const b = parse(stops[index + 1]);
|
const b = parse(stops[index + 1]);
|
||||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
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. */
|
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||||
export function advectParticle(particle, wind, dtSeconds, { speedScale = 1 } = {}) {
|
export function advectParticle(
|
||||||
|
particle,
|
||||||
|
wind,
|
||||||
|
dtSeconds,
|
||||||
|
{ speedScale = 1 } = {},
|
||||||
|
) {
|
||||||
particle.lon = normalizeLongitude(
|
particle.lon = normalizeLongitude(
|
||||||
particle.lon + (wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
particle.lon +
|
||||||
|
(wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||||
|
);
|
||||||
|
particle.lat = Math.max(
|
||||||
|
-89,
|
||||||
|
Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320),
|
||||||
);
|
);
|
||||||
particle.lat = Math.max(-89, Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320));
|
|
||||||
return particle;
|
return particle;
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
const lon = west + Math.random() * (east - west);
|
const lon = west + Math.random() * (east - west);
|
||||||
const lat = south + Math.random() * (north - south);
|
const lat = south + Math.random() * (north - south);
|
||||||
return {
|
return {
|
||||||
lon: ((lon + 180) % 360 + 360) % 360 - 180,
|
lon: ((((lon + 180) % 360) + 360) % 360) - 180,
|
||||||
lat: Math.max(-89, Math.min(89, lat)),
|
lat: Math.max(-89, Math.min(89, lat)),
|
||||||
age: Math.random() * 30,
|
age: Math.random() * 30,
|
||||||
};
|
};
|
||||||
@@ -103,12 +103,21 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
resize(viewer);
|
resize(viewer);
|
||||||
if (particles.length !== budget()) seed();
|
if (particles.length !== budget()) seed();
|
||||||
const camera = viewer.scene.camera;
|
const camera = viewer.scene.camera;
|
||||||
const signature = [camera?.positionWC?.x, camera?.positionWC?.y, camera?.positionWC?.z, camera?.heading, camera?.pitch, camera?.roll].join(',');
|
const signature = [
|
||||||
|
camera?.positionWC?.x,
|
||||||
|
camera?.positionWC?.y,
|
||||||
|
camera?.positionWC?.z,
|
||||||
|
camera?.heading,
|
||||||
|
camera?.pitch,
|
||||||
|
camera?.roll,
|
||||||
|
].join(',');
|
||||||
if (signature !== cameraSignature) {
|
if (signature !== cameraSignature) {
|
||||||
context.clearRect(0, 0, cssWidth, cssHeight);
|
context.clearRect(0, 0, cssWidth, cssHeight);
|
||||||
cameraSignature = signature;
|
cameraSignature = signature;
|
||||||
}
|
}
|
||||||
const dt = lastTime ? Math.min(1, Math.max(0, (time - lastTime) / 1000)) : 0.016;
|
const dt = lastTime
|
||||||
|
? Math.min(1, Math.max(0, (time - lastTime) / 1000))
|
||||||
|
: 0.016;
|
||||||
lastTime = time;
|
lastTime = time;
|
||||||
// Fade previous trails by ERASING them (destination-out). A translucent
|
// Fade previous trails by ERASING them (destination-out). A translucent
|
||||||
// black fill would instead accumulate alpha and saturate the overlay.
|
// black fill would instead accumulate alpha and saturate the overlay.
|
||||||
@@ -124,8 +133,15 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
|
const speedScale = Math.max(1, Math.min(4000, cameraHeight / 400));
|
||||||
let occluder = null;
|
let occluder = null;
|
||||||
try {
|
try {
|
||||||
if (cesium.EllipsoidalOccluder && cesium.Ellipsoid && scene.camera?.positionWC)
|
if (
|
||||||
occluder = new cesium.EllipsoidalOccluder(cesium.Ellipsoid.WGS84, scene.camera.positionWC);
|
cesium.EllipsoidalOccluder &&
|
||||||
|
cesium.Ellipsoid &&
|
||||||
|
scene.camera?.positionWC
|
||||||
|
)
|
||||||
|
occluder = new cesium.EllipsoidalOccluder(
|
||||||
|
cesium.Ellipsoid.WGS84,
|
||||||
|
scene.camera.positionWC,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
occluder = null; // fall back to frustum-only culling
|
occluder = null; // fall back to frustum-only culling
|
||||||
}
|
}
|
||||||
@@ -194,7 +210,8 @@ export function createWindRendering({ cesium, container, getViewer } = {}) {
|
|||||||
if (canvas) {
|
if (canvas) {
|
||||||
resize(getViewer?.());
|
resize(getViewer?.());
|
||||||
seed();
|
seed();
|
||||||
if (running && frame === null) frame = globalThis.requestAnimationFrame(draw);
|
if (running && frame === null)
|
||||||
|
frame = globalThis.requestAnimationFrame(draw);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/** Start the animation loop. Safe to call before a field is installed. */
|
/** Start the animation loop. Safe to call before a field is installed. */
|
||||||
|
|||||||
+51
-11
@@ -1,32 +1,72 @@
|
|||||||
import { readWindBody } from '../../sources/windBody.js';
|
import { readWindBody } from '../../sources/windBody.js';
|
||||||
|
|
||||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||||
export function createWindSource({ fetchImpl = (...args) => globalThis.fetch(...args), timeoutMs = 45_000 } = {}) {
|
export function createWindSource({
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
async getSnapshot({ signal, model = 'gfs' } = {}) {
|
async getSnapshot({ signal, model = 'gfs' } = {}) {
|
||||||
if (!['gfs', 'ifs'].includes(model)) throw new Error('Unknown wind model');
|
if (!['gfs', 'ifs'].includes(model))
|
||||||
|
throw new Error('Unknown wind model');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const abort = () => controller.abort(signal.reason);
|
const abort = () => controller.abort(signal.reason);
|
||||||
signal?.addEventListener('abort', abort, { once: true });
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
const timer = setTimeout(() => controller.abort(new Error('Wind request timed out')), timeoutMs);
|
const timer = setTimeout(
|
||||||
|
() => controller.abort(new Error('Wind request timed out')),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
const active = controller.signal;
|
const active = controller.signal;
|
||||||
try {
|
try {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const response = await fetchImpl(`/api/wind/manifest?model=${model}`, { signal: active, cache: 'no-store', redirect: 'error' });
|
const response = await fetchImpl(`/api/wind/manifest?model=${model}`, {
|
||||||
|
signal: active,
|
||||||
|
cache: 'no-store',
|
||||||
|
redirect: 'error',
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||||
const manifest = JSON.parse(new TextDecoder().decode(await readWindBody(response, 16_384, active)));
|
const manifest = JSON.parse(
|
||||||
|
new TextDecoder().decode(
|
||||||
|
await readWindBody(response, 16_384, active),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (manifest?.unavailable) return manifest;
|
if (manifest?.unavailable) return manifest;
|
||||||
const grid = manifest?.grid;
|
const grid = manifest?.grid;
|
||||||
if (manifest?.model !== model || !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 || !new RegExp(`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`).test(manifest.gridUrl))
|
if (
|
||||||
|
manifest?.model !== model ||
|
||||||
|
!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 ||
|
||||||
|
!new RegExp(
|
||||||
|
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?model=${model}$`,
|
||||||
|
).test(manifest.gridUrl)
|
||||||
|
)
|
||||||
throw new Error('Malformed wind manifest');
|
throw new Error('Malformed wind manifest');
|
||||||
const count = grid.nx * grid.ny;
|
const count = grid.nx * grid.ny;
|
||||||
const gridResponse = await fetchImpl(manifest.gridUrl, { signal: active, redirect: 'error' });
|
const gridResponse = await fetchImpl(manifest.gridUrl, {
|
||||||
if (!gridResponse.ok) throw new Error(`Wind HTTP ${gridResponse.status}`);
|
signal: active,
|
||||||
|
redirect: 'error',
|
||||||
|
});
|
||||||
|
if (!gridResponse.ok)
|
||||||
|
throw new Error(`Wind HTTP ${gridResponse.status}`);
|
||||||
const bytes = await readWindBody(gridResponse, count * 8, active);
|
const bytes = await readWindBody(gridResponse, count * 8, active);
|
||||||
if (bytes.byteLength !== count * 8) throw new Error('Malformed wind grid');
|
if (bytes.byteLength !== count * 8)
|
||||||
|
throw new Error('Malformed wind grid');
|
||||||
const values = new Float32Array(bytes.buffer);
|
const values = new Float32Array(bytes.buffer);
|
||||||
if (!values.every(Number.isFinite)) throw new Error('Malformed wind grid');
|
if (!values.every(Number.isFinite))
|
||||||
return { ...manifest, u: values.slice(0, count), v: values.slice(count) };
|
throw new Error('Malformed wind grid');
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
u: values.slice(0, count),
|
||||||
|
v: values.slice(count),
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
signal?.removeEventListener('abort', abort);
|
signal?.removeEventListener('abort', abort);
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export async function readWindBody(response, maxBytes, signal) {
|
|||||||
if (!reader) throw new Error('Wind response has no readable body');
|
if (!reader) throw new Error('Wind response has no readable body');
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
const cancel = () => { void reader.cancel().catch(() => {}); };
|
const cancel = () => {
|
||||||
|
void reader.cancel().catch(() => {});
|
||||||
|
};
|
||||||
signal?.addEventListener('abort', cancel, { once: true });
|
signal?.addEventListener('abort', cancel, { once: true });
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -23,7 +25,10 @@ export async function readWindBody(response, maxBytes, signal) {
|
|||||||
}
|
}
|
||||||
const bytes = new Uint8Array(size);
|
const bytes = new Uint8Array(size);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cancel();
|
cancel();
|
||||||
|
|||||||
Reference in new issue
Block a user