mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
import { readResponseTextCapped } from './common/http.js';
|
|
|
|
const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
|
|
const GIS =
|
|
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
|
const HOUR = 3600_000;
|
|
// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in
|
|
// msg() and translates it when shown (likewise 'Cyclone data unavailable').
|
|
const COVERAGE =
|
|
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
|
const LAYERS = [
|
|
{ id: 5, cap: 512 * 1024, count: 500, kind: 'points' },
|
|
{ id: 6, cap: 512 * 1024, count: 32, kind: 'track' },
|
|
{ id: 7, cap: 2 * 1024 * 1024, count: 32, kind: 'cone' },
|
|
];
|
|
|
|
function invalid() {
|
|
return new Error('invalid_cyclone_data');
|
|
}
|
|
function text(value, max = 80) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!value.trim() ||
|
|
value.length > max ||
|
|
/[\u0000-\u001f<>]/.test(value)
|
|
)
|
|
throw invalid();
|
|
return value.trim();
|
|
}
|
|
function advisory(value) {
|
|
if (typeof value !== 'string' || !/^\d{1,3}[A-Z]?$/i.test(value))
|
|
throw invalid();
|
|
return value.replace(/^0+(?=\d)/, '').toUpperCase();
|
|
}
|
|
function number(value, min, max) {
|
|
if (
|
|
value === null ||
|
|
value === undefined ||
|
|
value === '' ||
|
|
!['string', 'number'].includes(typeof value)
|
|
)
|
|
return null;
|
|
const n = Number(value);
|
|
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
|
}
|
|
// NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it
|
|
// up to about half an hour before; allow that, but not a clock far ahead.
|
|
const EARLY_ADVISORY_MS = 90 * 60_000;
|
|
function iso(value, now) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value)
|
|
)
|
|
throw invalid();
|
|
const n = Date.parse(value);
|
|
if (
|
|
!Number.isFinite(n) ||
|
|
n > now + EARLY_ADVISORY_MS ||
|
|
now - n > 12 * HOUR ||
|
|
new Date(n).toISOString().replace('.000Z', 'Z') !==
|
|
value.replace('.000Z', 'Z')
|
|
)
|
|
throw invalid();
|
|
return new Date(n).toISOString();
|
|
}
|
|
function position(coordinates) {
|
|
if (
|
|
!Array.isArray(coordinates) ||
|
|
coordinates.length !== 2 ||
|
|
!coordinates.every((n) => typeof n === 'number' && Number.isFinite(n)) ||
|
|
Math.abs(coordinates[0]) > 180 ||
|
|
Math.abs(coordinates[1]) > 90
|
|
)
|
|
throw invalid();
|
|
return { longitude: coordinates[0], latitude: coordinates[1] };
|
|
}
|
|
function officialLink(value) {
|
|
if (typeof value !== 'string' || value.length > 256) return null;
|
|
try {
|
|
const url = new URL(value);
|
|
return url.origin === 'https://www.nhc.noaa.gov' &&
|
|
!url.username &&
|
|
!url.password &&
|
|
!url.search &&
|
|
!url.hash &&
|
|
/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
|
|
? url.href
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parseCycloneStatus(payload, now = Date.now()) {
|
|
if (!Array.isArray(payload?.activeStorms) || payload.activeStorms.length > 32)
|
|
throw invalid();
|
|
const seen = new Set();
|
|
return payload.activeStorms.map((raw) => {
|
|
if (
|
|
typeof raw?.id !== 'string' ||
|
|
!/^(?:al|ep|cp)\d{6}$/.test(raw.id) ||
|
|
seen.has(raw.id)
|
|
)
|
|
throw invalid();
|
|
seen.add(raw.id);
|
|
const forecast = raw.forecastAdvisory;
|
|
const issuedAt = iso(forecast?.issuance, now);
|
|
const advisoryNumber = advisory(forecast?.advNum);
|
|
const positionAt = iso(raw.lastUpdate, now);
|
|
return {
|
|
id: raw.id,
|
|
name: text(raw.name),
|
|
classification: text(raw.classification, 16),
|
|
basin: raw.id.slice(0, 2).toUpperCase(),
|
|
position: position([raw.longitudeNumeric, raw.latitudeNumeric]),
|
|
positionAt,
|
|
advisoryNumber,
|
|
issuedAt,
|
|
windKt: number(raw.intensity, 0, 300),
|
|
pressureHpa: number(raw.pressure, 800, 1100),
|
|
movement: {
|
|
directionDegrees: number(raw.movementDir, 0, 360),
|
|
speedKt: number(raw.movementSpeed, 0, 200),
|
|
},
|
|
advisoryUrl: officialLink(forecast.url),
|
|
outlookUrl: raw.id.startsWith('al')
|
|
? 'https://www.nhc.noaa.gov/gtwo.php?basin=atlc&fdays=7'
|
|
: raw.id.startsWith('cp')
|
|
? 'https://www.nhc.noaa.gov/gtwo.php?basin=cpac&fdays=7'
|
|
: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
|
|
geometryStatus: 'pending',
|
|
geometryAdvisoryNumber: null,
|
|
forecastPoints: [],
|
|
track: null,
|
|
cone: null,
|
|
};
|
|
});
|
|
}
|
|
|
|
function geometry(raw, kind, budget) {
|
|
const types =
|
|
kind === 'points'
|
|
? ['Point']
|
|
: kind === 'track'
|
|
? ['LineString', 'MultiLineString']
|
|
: ['Polygon', 'MultiPolygon'];
|
|
if (!types.includes(raw?.type)) throw invalid();
|
|
const point = (value) => {
|
|
position(value);
|
|
if (++budget.count > 25_000) throw invalid();
|
|
return [...value];
|
|
};
|
|
const line = (value, ring = false) => {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
value.length < (ring ? 4 : 2) ||
|
|
value.length > 10_000
|
|
)
|
|
throw invalid();
|
|
const result = value.map(point);
|
|
if (
|
|
ring &&
|
|
(result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
|
|
)
|
|
throw invalid();
|
|
return result;
|
|
};
|
|
const list = (value, read) => {
|
|
if (!Array.isArray(value) || !value.length || value.length > 128)
|
|
throw invalid();
|
|
return value.map(read);
|
|
};
|
|
const polygon = (value) => list(value, (ring) => line(ring, true));
|
|
const coordinates =
|
|
raw.type === 'Point'
|
|
? point(raw.coordinates)
|
|
: raw.type === 'LineString'
|
|
? line(raw.coordinates)
|
|
: raw.type === 'MultiLineString'
|
|
? list(raw.coordinates, (part) => line(part))
|
|
: raw.type === 'Polygon'
|
|
? polygon(raw.coordinates)
|
|
: list(raw.coordinates, polygon);
|
|
return { type: raw.type, coordinates };
|
|
}
|
|
|
|
/** Never relabel earlier GIS geometry with the newer status advisory. */
|
|
export function attachCycloneGeometry(storms, collections) {
|
|
const budget = { count: 0 };
|
|
const parsed = LAYERS.map((spec, index) => {
|
|
const payload = collections[index];
|
|
if (
|
|
payload?.type !== 'FeatureCollection' ||
|
|
payload.exceededTransferLimit ||
|
|
!Array.isArray(payload.features) ||
|
|
payload.features.length > spec.count
|
|
)
|
|
throw invalid();
|
|
return payload.features.map((feature) => {
|
|
if (feature?.type !== 'Feature') throw invalid();
|
|
const p = feature.properties;
|
|
const source =
|
|
typeof p?.idp_source === 'string' &&
|
|
p.idp_source.match(
|
|
/^((?:al|ep|cp)\d{6})-(\d{1,3}[a-z]?)_5day_(pts|lin|pgn)$/i,
|
|
);
|
|
if (!source || source[3].toLowerCase() !== ['pts', 'lin', 'pgn'][index])
|
|
throw invalid();
|
|
const adv = advisory(p.advisnum);
|
|
if (adv !== advisory(source[2])) throw invalid();
|
|
return {
|
|
id: source[1].toLowerCase(),
|
|
advisoryNumber: adv,
|
|
geometry: geometry(feature.geometry, spec.kind, budget),
|
|
tauHours: number(p.tau, 0, 168),
|
|
windKt: number(p.maxwind, 0, 300),
|
|
gustKt: number(p.gust, 0, 350),
|
|
};
|
|
});
|
|
});
|
|
return storms.map((storm) => {
|
|
const groups = parsed.map((items) =>
|
|
items.filter(
|
|
(item) =>
|
|
item.id === storm.id && item.advisoryNumber === storm.advisoryNumber,
|
|
),
|
|
);
|
|
if (groups[1].length > 1 || groups[2].length > 1) throw invalid();
|
|
if (!groups[0].length || groups[1].length !== 1 || groups[2].length !== 1)
|
|
return storm;
|
|
const taus = new Set();
|
|
const forecastPoints = groups[0]
|
|
.map((item) => {
|
|
if (item.tauHours === null || taus.has(item.tauHours)) throw invalid();
|
|
taus.add(item.tauHours);
|
|
return {
|
|
position: position(item.geometry.coordinates),
|
|
tauHours: item.tauHours,
|
|
windKt: item.windKt,
|
|
gustKt: item.gustKt,
|
|
};
|
|
})
|
|
.sort((a, b) => a.tauHours - b.tauHours);
|
|
return {
|
|
...storm,
|
|
geometryStatus: 'current',
|
|
geometryAdvisoryNumber: storm.advisoryNumber,
|
|
forecastPoints,
|
|
track: groups[1][0].geometry,
|
|
cone: groups[2][0].geometry,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Fixed official endpoints; one shared bounded refresh, no user destinations. */
|
|
export function cycloneProxy({
|
|
fetchImpl = fetch,
|
|
now = () => Date.now(),
|
|
timeoutMs = 12_000,
|
|
} = {}) {
|
|
let cache = null;
|
|
let operation = null;
|
|
let attemptedAt = -Infinity;
|
|
async function upstream(url, cap, signal) {
|
|
signal.throwIfAborted();
|
|
const response = await fetchImpl(url, {
|
|
signal,
|
|
redirect: 'error',
|
|
headers: {
|
|
Accept: 'application/geo+json,application/json',
|
|
'User-Agent': 'Gods Eye View (public NOAA weather context)',
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
await response.body?.cancel();
|
|
throw new Error('cyclone_upstream_unavailable');
|
|
}
|
|
const result = JSON.parse(
|
|
await readResponseTextCapped(response, cap, signal),
|
|
);
|
|
signal.throwIfAborted();
|
|
return result;
|
|
}
|
|
async function refresh(signal) {
|
|
let storms = parseCycloneStatus(
|
|
await upstream(STATUS_URL, 128 * 1024, signal),
|
|
now(),
|
|
);
|
|
if (storms.length) {
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
LAYERS.map(async (spec) => {
|
|
const url = new URL(`${GIS}/${spec.id}/query`);
|
|
url.search = new URLSearchParams({
|
|
where: '1=1',
|
|
outFields:
|
|
spec.kind === 'points'
|
|
? 'idp_source,advisnum,tau,maxwind,gust'
|
|
: 'idp_source,advisnum',
|
|
outSR: '4326',
|
|
resultRecordCount: String(spec.count),
|
|
geometryPrecision: '4',
|
|
f: 'geojson',
|
|
}).toString();
|
|
return upstream(url.href, spec.cap, signal);
|
|
}),
|
|
);
|
|
if (results.some((result) => result.status === 'rejected'))
|
|
throw invalid();
|
|
storms = attachCycloneGeometry(
|
|
storms,
|
|
results.map((result) => result.value),
|
|
);
|
|
} catch {
|
|
storms = storms.map((storm) => ({
|
|
...storm,
|
|
geometryStatus: 'unavailable',
|
|
}));
|
|
}
|
|
}
|
|
signal.throwIfAborted();
|
|
cache = { storms, fetchedAt: now() };
|
|
return cache;
|
|
}
|
|
async function acquire(signal) {
|
|
signal.throwIfAborted();
|
|
if (cache && now() - cache.fetchedAt < 300_000) return cache;
|
|
if (operation?.controller.signal.aborted) operation = null;
|
|
if (!operation) {
|
|
if (now() - attemptedAt < 60_000) throw new Error('cyclone_retry_later');
|
|
attemptedAt = now();
|
|
const controller = new AbortController();
|
|
const owned = { controller, waiters: 0 };
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
owned.promise = refresh(controller.signal).finally(() => {
|
|
clearTimeout(timer);
|
|
if (operation === owned) operation = null;
|
|
});
|
|
operation = owned;
|
|
}
|
|
const owned = operation;
|
|
if (owned.waiters >= 32)
|
|
throw Object.assign(new Error('cyclone_busy'), { status: 429 });
|
|
owned.waiters++;
|
|
let abort;
|
|
const cancelled = new Promise((_, reject) => {
|
|
abort = () => reject(signal.reason ?? new Error('cancelled'));
|
|
signal.addEventListener('abort', abort, { once: true });
|
|
});
|
|
try {
|
|
return await Promise.race([owned.promise, cancelled]);
|
|
} finally {
|
|
signal.removeEventListener('abort', abort);
|
|
if (--owned.waiters === 0 && operation === owned) {
|
|
owned.controller.abort();
|
|
if (signal.aborted) attemptedAt = -Infinity;
|
|
}
|
|
}
|
|
}
|
|
function describe(value, stale = false) {
|
|
return {
|
|
schemaVersion: 1,
|
|
source: 'NOAA NHC / CPHC',
|
|
attribution:
|
|
'NOAA/NWS National Hurricane Center / Central Pacific Hurricane Center',
|
|
coverage: COVERAGE,
|
|
fetchedAt: value?.fetchedAt ?? null,
|
|
stale: stale || !value,
|
|
unavailable: !value,
|
|
reason: !value
|
|
? 'Cyclone data unavailable'
|
|
: stale
|
|
? 'Cached cyclone advisory; upstream unavailable'
|
|
: null,
|
|
storms: value?.storms ?? [],
|
|
};
|
|
}
|
|
async function handler(req, res) {
|
|
const controller = new AbortController();
|
|
const close = () => controller.abort();
|
|
res.once?.('close', close);
|
|
const json = (status, value) => {
|
|
if (controller.signal.aborted) return;
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...(status === 429 ? { 'Retry-After': '2' } : {}),
|
|
});
|
|
res.end(JSON.stringify(value));
|
|
};
|
|
try {
|
|
if (req.method !== 'GET')
|
|
return json(405, { error: 'method_not_allowed' });
|
|
if (req.url !== '/' && req.url !== '')
|
|
return json(400, { error: 'invalid_cyclone_query' });
|
|
try {
|
|
json(200, describe(await acquire(controller.signal)));
|
|
} catch (error) {
|
|
if (error.status === 429) return json(429, { error: 'cyclone_busy' });
|
|
const usable =
|
|
cache &&
|
|
now() - cache.fetchedAt <= 12 * HOUR &&
|
|
cache.storms.every(
|
|
(storm) => now() - Date.parse(storm.issuedAt) <= 12 * HOUR,
|
|
);
|
|
json(200, describe(usable ? cache : null, true));
|
|
}
|
|
} finally {
|
|
res.removeListener?.('close', close);
|
|
}
|
|
}
|
|
return {
|
|
name: 'cyclones',
|
|
configureServer({ middlewares }) {
|
|
middlewares.use('/api/cyclones', handler);
|
|
},
|
|
configurePreviewServer({ middlewares }) {
|
|
middlewares.use('/api/cyclones', handler);
|
|
},
|
|
};
|
|
}
|