mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(mobile): add the phone-tuned Cesium globe engine
createGlobe() implements the Globe API in mobile/DESIGN.md: Esri day imagery blended with NASA Black Marble night lights across the terminator, Google 3D tiles below 25 km when keyed, a single-pass grade with half-res bloom and lenses, an adaptive quality governor, live layers on the repo's /api routes and parsers, time scrubbing, picking, AR contact queries and live situation cards. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
19 files changed
+5816
No files matched your search
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* Shared, cached data feeds. Layers and situations read the same feed objects,
|
||||
* so a situation card never triggers a second download of data a visible layer
|
||||
* already holds, and a layer that is off costs nothing until something asks.
|
||||
*
|
||||
* Every feed resolves to `{ data, stale }` from its loader; `get()` returns the
|
||||
* cached data when it is younger than `maxAgeMs`, otherwise loads once (single
|
||||
* flight) and keeps the last good data when a refresh fails.
|
||||
*/
|
||||
import { parseTleText } from '../../src/sources/tle.js';
|
||||
import { normalizeEarthquakeSnapshot } from '../../src/layers/earthquakes/records.js';
|
||||
import { adaptFirmsRecords } from '../../src/data/firmsAdapt.js';
|
||||
import { validateCycloneSnapshot } from '../../src/layers/cyclones/source.js';
|
||||
import { classifyAircraft } from '../../src/data/aircraftClass.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from '../../src/data/bhoteKoshiFloodPath.js';
|
||||
import { FT_TO_M, KT_TO_MPS, finite } from './geo.js';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 25_000;
|
||||
|
||||
async function fetchWithTimeout(url, init = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedError extends Error {
|
||||
constructor(message, { keyRequired = false, status = 0 } = {}) {
|
||||
super(message);
|
||||
this.keyRequired = keyRequired;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** A cached single-flight feed. */
|
||||
export function createFeed(name, load, { ttlMs = 60_000 } = {}) {
|
||||
const listeners = new Set();
|
||||
const feed = {
|
||||
name,
|
||||
data: null,
|
||||
stale: false,
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
async get({ maxAgeMs = ttlMs, args } = {}) {
|
||||
if (feed.data && Date.now() - feed.updatedAt < maxAgeMs) return feed.data;
|
||||
// A server without the feed's key is not asked again for 10 minutes.
|
||||
if (feed.error?.keyRequired && Date.now() < feed._retryAt) {
|
||||
if (feed.data) return feed.data;
|
||||
throw feed.error;
|
||||
}
|
||||
if (!feed._inflight) {
|
||||
feed._inflight = (async () => {
|
||||
try {
|
||||
const result = await load(args);
|
||||
feed.data = result.data;
|
||||
feed.stale = Boolean(result.stale);
|
||||
feed.updatedAt = Date.now();
|
||||
feed.error = null;
|
||||
} catch (error) {
|
||||
feed.error = error;
|
||||
feed._retryAt = Date.now() + 10 * 60_000;
|
||||
if (feed.data) feed.stale = true;
|
||||
} finally {
|
||||
feed._inflight = null;
|
||||
for (const fn of listeners) fn(feed);
|
||||
}
|
||||
})();
|
||||
}
|
||||
await feed._inflight;
|
||||
if (!feed.data && feed.error) throw feed.error;
|
||||
return feed.data;
|
||||
},
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
_inflight: null,
|
||||
_retryAt: 0,
|
||||
};
|
||||
return feed;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Aircraft ───────────────────────── */
|
||||
|
||||
const REGISTRATION_CALLSIGN =
|
||||
/^(N\d{1,5}[A-Z]{0,2}|[CDFGI]-?[A-Z]{4}|VH-?[A-Z]{3}|D[A-Z]{4}|ZK-?[A-Z]{3}|JA\d{3,4}[A-Z]?)$/;
|
||||
|
||||
/** OpenSky state vector → flight record. */
|
||||
function flightFromState(s) {
|
||||
const lon = finite(s[5]);
|
||||
const lat = finite(s[6]);
|
||||
if (lon === null || lat === null) return null;
|
||||
const callsign = String(s[1] || '').trim();
|
||||
const category = finite(s[17]);
|
||||
const cls = classifyAircraft({ category: category ?? undefined });
|
||||
const squawk = s[14] ? String(s[14]).trim() : null;
|
||||
const altM = finite(s[13]) ?? finite(s[7]);
|
||||
const onGround = s[8] === true;
|
||||
return {
|
||||
id: String(s[0]),
|
||||
callsign: callsign || String(s[0]).toUpperCase(),
|
||||
country: s[2] || '',
|
||||
lon,
|
||||
lat,
|
||||
altM: onGround ? 0 : (altM ?? 10_000),
|
||||
baroAltM: finite(s[7]),
|
||||
onGround,
|
||||
speed: finite(s[9]),
|
||||
heading: finite(s[10]) ?? 0,
|
||||
vRate: finite(s[11]),
|
||||
squawk,
|
||||
category,
|
||||
cls,
|
||||
ga:
|
||||
category === 2 ||
|
||||
category === 3 ||
|
||||
REGISTRATION_CALLSIGN.test(callsign.replace(/\s+/g, '')),
|
||||
helicopter: category === 8 || cls === 'helicopter',
|
||||
emergency: squawk === '7500' || squawk === '7600' || squawk === '7700',
|
||||
seenAt: (finite(s[4]) ?? Date.now() / 1000) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFlightsFeed(getAnchor) {
|
||||
return createFeed(
|
||||
'flights',
|
||||
async () => {
|
||||
const anchor = getAnchor?.();
|
||||
const query = anchor
|
||||
? `?lat=${anchor.lat.toFixed(3)}&lon=${anchor.lon.toFixed(3)}`
|
||||
: '';
|
||||
const response = await fetchWithTimeout(`/api/flights${query}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`Flights HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.states))
|
||||
throw new FeedError('Malformed flight snapshot');
|
||||
const records = [];
|
||||
for (const s of payload.states) {
|
||||
const r = flightFromState(s);
|
||||
if (r) records.push(r);
|
||||
}
|
||||
const cache = response.headers.get('x-opensky-cache') || '';
|
||||
// When OpenSky is down the proxy answers with a 250 nm adsb.lol
|
||||
// snapshot around the anchor: real, but regional.
|
||||
const regional = /adsb\.lol/i.test(
|
||||
response.headers.get('x-flight-source') || '',
|
||||
);
|
||||
const source = regional
|
||||
? 'adsb.lol (regional fallback, 250 nm)'
|
||||
: 'OpenSky Network';
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source,
|
||||
regional,
|
||||
anchor: anchor || null,
|
||||
time: (payload.time || 0) * 1000,
|
||||
},
|
||||
stale: cache === 'STALE',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 12_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createMilitaryFeed() {
|
||||
return createFeed(
|
||||
'military',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/military', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`Military HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.ac))
|
||||
throw new FeedError('Malformed military snapshot');
|
||||
const records = [];
|
||||
for (const a of payload.ac) {
|
||||
const lon = finite(a.lon);
|
||||
const lat = finite(a.lat);
|
||||
if (lon === null || lat === null) continue;
|
||||
const onGround = a.alt_baro === 'ground';
|
||||
const altFt = finite(a.alt_geom) ?? finite(a.alt_baro);
|
||||
const cls = classifyAircraft({ typeCode: a.t, category: a.category });
|
||||
records.push({
|
||||
id: String(a.hex),
|
||||
callsign: String(a.flight || a.r || a.hex).trim(),
|
||||
registration: a.r || '',
|
||||
type: a.t || '',
|
||||
lon,
|
||||
lat,
|
||||
altM: onGround ? 0 : Math.max(0, (altFt ?? 15_000) * FT_TO_M),
|
||||
onGround,
|
||||
speed: finite(a.gs) !== null ? a.gs * KT_TO_MPS : null,
|
||||
heading: finite(a.track) ?? finite(a.true_heading) ?? 0,
|
||||
squawk: a.squawk || null,
|
||||
cls,
|
||||
helicopter: cls === 'helicopter',
|
||||
military: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: { records, source: 'adsb.lol' },
|
||||
stale: response.headers.get('x-feed-cache') === 'STALE',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Sea ───────────────────────── */
|
||||
|
||||
export function createVesselsFeed() {
|
||||
return createFeed(
|
||||
'vessels',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/vessels?maxRows=4000', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* status decides */
|
||||
}
|
||||
if (payload?.status === 'missing-key')
|
||||
throw new FeedError('AIS needs AISSTREAM_API_KEY on the server', {
|
||||
keyRequired: true,
|
||||
status: response.status,
|
||||
});
|
||||
if (!response.ok || !Array.isArray(payload?.rows))
|
||||
throw new FeedError(`Vessels HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const records = [];
|
||||
for (const row of payload.rows) {
|
||||
const lat = finite(row.lat);
|
||||
const lon = finite(row.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
records.push({
|
||||
id: String(row.mmsi || `${lat},${lon}`),
|
||||
name: String(row.name || row.mmsi || 'Vessel'),
|
||||
type: String(row.type || ''),
|
||||
destination: String(row.destination || ''),
|
||||
lon,
|
||||
lat,
|
||||
speed: finite(row.speed) !== null ? row.speed * KT_TO_MPS : null,
|
||||
heading: finite(row.heading) ?? finite(row.course) ?? 0,
|
||||
});
|
||||
}
|
||||
return {
|
||||
data: { records, source: payload.source || 'AISStream' },
|
||||
stale: payload.status === 'stale',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 20_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Orbit ───────────────────────── */
|
||||
|
||||
export function createTleFeed(group, ttlMs = 2 * 3600_000) {
|
||||
return createFeed(
|
||||
`tle:${group}`,
|
||||
async () => {
|
||||
const response = await fetchWithTimeout(`/api/celestrak/${group}`);
|
||||
if (!response.ok)
|
||||
throw new FeedError(`CelesTrak HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const entries = parseTleText(await response.text());
|
||||
if (!entries.length) throw new FeedError('Empty TLE catalog');
|
||||
return {
|
||||
data: { entries, source: 'CelesTrak' },
|
||||
stale: response.headers.get('x-tle-cache') === 'STALE-ERROR',
|
||||
};
|
||||
},
|
||||
{ ttlMs },
|
||||
);
|
||||
}
|
||||
|
||||
function launchRecord(r, upcoming) {
|
||||
const lat = finite(r?.pad?.latitude);
|
||||
const lon = finite(r?.pad?.longitude);
|
||||
const net = Date.parse(r?.net);
|
||||
if (lat === null || lon === null || !Number.isFinite(net)) return null;
|
||||
return {
|
||||
id: String(r.id),
|
||||
name: String(r.name || 'Launch'),
|
||||
net,
|
||||
status: r.status?.abbrev || r.status?.name || '',
|
||||
provider: r.launch_service_provider?.name || r.lsp_name || '',
|
||||
pad: r.pad?.name || '',
|
||||
location: r.pad?.location?.name || r.location || '',
|
||||
mission: r.mission?.name || '',
|
||||
orbit: r.mission?.orbit?.abbrev || '',
|
||||
image: r.image?.thumbnail_url || r.image?.image_url || null,
|
||||
lon,
|
||||
lat,
|
||||
upcoming,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent launches from the app's proxy plus upcoming launches straight from
|
||||
* Launch Library 2 (keyless, CORS-enabled, rate limited to ~15/h, so the
|
||||
* feed TTL is long).
|
||||
*/
|
||||
export function createLaunchesFeed() {
|
||||
return createFeed(
|
||||
'launches',
|
||||
async () => {
|
||||
const [recent, upcoming] = await Promise.allSettled([
|
||||
fetchWithTimeout('/api/launches').then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
|
||||
),
|
||||
fetchWithTimeout(
|
||||
'https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=12&mode=normal',
|
||||
).then((r) =>
|
||||
r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)),
|
||||
),
|
||||
]);
|
||||
const records = new Map();
|
||||
const add = (list, isUpcoming) => {
|
||||
for (const raw of list || []) {
|
||||
const rec = launchRecord(raw, isUpcoming);
|
||||
if (rec) records.set(rec.id, rec);
|
||||
}
|
||||
};
|
||||
if (recent.status === 'fulfilled')
|
||||
add(recent.value?.results ?? recent.value, false);
|
||||
if (upcoming.status === 'fulfilled') add(upcoming.value?.results, true);
|
||||
if (!records.size) throw new FeedError('Launch feeds unavailable');
|
||||
const now = Date.now();
|
||||
for (const r of records.values()) r.upcoming = r.net > now - 3600_000;
|
||||
return {
|
||||
data: {
|
||||
records: [...records.values()].sort((a, b) => a.net - b.net),
|
||||
source: 'The Space Devs · Launch Library 2',
|
||||
upcomingOk: upcoming.status === 'fulfilled',
|
||||
},
|
||||
stale: upcoming.status !== 'fulfilled',
|
||||
};
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Hazards ───────────────────────── */
|
||||
|
||||
export function createQuakesFeed() {
|
||||
return createFeed(
|
||||
'earthquakes',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout(
|
||||
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson',
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new FeedError(`USGS HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const rows = normalizeEarthquakeSnapshot(await response.json());
|
||||
if (!rows) throw new FeedError('Malformed USGS response');
|
||||
return { data: { records: rows, source: 'USGS' } };
|
||||
},
|
||||
{ ttlMs: 5 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** NASA EONET open events: wildfires, volcanoes and severe storms. */
|
||||
export function createEonetFeed() {
|
||||
return createFeed(
|
||||
'eonet',
|
||||
async () => {
|
||||
// Volcano events are curated slowly (often months old) but stay open,
|
||||
// so they are asked for without the 45-day window.
|
||||
const base = 'https://eonet.gsfc.nasa.gov/api/v3/events?status=open';
|
||||
const [recent, volcanoes] = await Promise.all([
|
||||
fetchWithTimeout(`${base}&days=45&category=wildfires,severeStorms`),
|
||||
fetchWithTimeout(`${base}&category=volcanoes&limit=80`).catch(
|
||||
() => null,
|
||||
),
|
||||
]);
|
||||
if (!recent.ok)
|
||||
throw new FeedError(`EONET HTTP ${recent.status}`, {
|
||||
status: recent.status,
|
||||
});
|
||||
const events = [...((await recent.json())?.events || [])];
|
||||
if (volcanoes?.ok)
|
||||
events.push(...((await volcanoes.json())?.events || []));
|
||||
const out = { wildfires: [], volcanoes: [], severeStorms: [] };
|
||||
for (const e of events) {
|
||||
const cat = e.categories?.[0]?.id;
|
||||
if (!out[cat]) continue;
|
||||
const points = (e.geometry || [])
|
||||
.filter((g) => g.type === 'Point' && Array.isArray(g.coordinates))
|
||||
.map((g) => ({
|
||||
lon: g.coordinates[0],
|
||||
lat: g.coordinates[1],
|
||||
t: Date.parse(g.date),
|
||||
magnitude: finite(g.magnitudeValue),
|
||||
unit: g.magnitudeUnit || '',
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.lon) && Number.isFinite(p.lat))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
if (!points.length) continue;
|
||||
const last = points.at(-1);
|
||||
out[cat].push({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
lon: last.lon,
|
||||
lat: last.lat,
|
||||
t: last.t,
|
||||
firstT: points[0].t,
|
||||
magnitude: last.magnitude,
|
||||
unit: last.unit,
|
||||
track: points,
|
||||
link: e.sources?.[0]?.url || e.link,
|
||||
});
|
||||
}
|
||||
return { data: { ...out, source: 'NASA EONET' } };
|
||||
},
|
||||
{ ttlMs: 20 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active fires: NASA FIRMS hotspots through the app's proxy when a MAP_KEY is
|
||||
* configured, else NASA EONET wildfire events (keyless) so the layer still
|
||||
* says something true.
|
||||
*/
|
||||
export function createFiresFeed(eonet) {
|
||||
return createFeed(
|
||||
'fires',
|
||||
async () => {
|
||||
let keyRequired = false;
|
||||
try {
|
||||
// Ask the proxy whether it has a MAP_KEY before asking for fires,
|
||||
// so a keyless server costs one quiet 200 instead of a 503.
|
||||
const status = await fetchWithTimeout('/api/firms/status', {
|
||||
cache: 'no-store',
|
||||
}).then((r) => (r.ok ? r.json() : null));
|
||||
keyRequired = status?.hasKey === false;
|
||||
if (!keyRequired) {
|
||||
const response = await fetchWithTimeout('/api/firms', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (response.ok && Array.isArray(payload?.fires)) {
|
||||
const records = adaptFirmsRecords(payload.fires).map((f) => ({
|
||||
id: `firms-${f.index}`,
|
||||
lon: f.lon,
|
||||
lat: f.lat,
|
||||
t: f.acqMs,
|
||||
frp: f.frp,
|
||||
confidence: f.confidence,
|
||||
title: 'Fire hotspot',
|
||||
}));
|
||||
return {
|
||||
data: { records, source: 'NASA FIRMS', mode: 'hotspots' },
|
||||
stale: Boolean(payload.stale),
|
||||
};
|
||||
}
|
||||
keyRequired = payload?.error === 'no_key';
|
||||
}
|
||||
} catch {
|
||||
/* fall through to EONET */
|
||||
}
|
||||
const ev = await eonet.get();
|
||||
const records = ev.wildfires.map((f) => ({
|
||||
id: f.id,
|
||||
lon: f.lon,
|
||||
lat: f.lat,
|
||||
t: f.t,
|
||||
frp: null,
|
||||
acres: f.unit === 'acres' ? f.magnitude : null,
|
||||
title: f.title,
|
||||
link: f.link,
|
||||
}));
|
||||
return {
|
||||
data: {
|
||||
records,
|
||||
source: 'NASA EONET wildfire events',
|
||||
mode: 'events',
|
||||
firmsKeyRequired: keyRequired,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: 10 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export function createCyclonesFeed(eonet) {
|
||||
return createFeed(
|
||||
'cyclones',
|
||||
async () => {
|
||||
const storms = [];
|
||||
let source = 'NOAA NHC / CPHC';
|
||||
let nhcOk = false;
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/cyclones');
|
||||
if (response.ok) {
|
||||
const raw = await response.json();
|
||||
let snap;
|
||||
try {
|
||||
snap = validateCycloneSnapshot(raw);
|
||||
} catch {
|
||||
snap = raw;
|
||||
}
|
||||
nhcOk = true;
|
||||
for (const s of snap.storms || []) {
|
||||
const lon = s.position?.longitude;
|
||||
const lat = s.position?.latitude;
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
storms.push({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
classification: s.classification,
|
||||
basin: s.basin,
|
||||
lon,
|
||||
lat,
|
||||
t: Date.parse(s.positionAt) || Date.now(),
|
||||
windKt: s.windKt,
|
||||
pressureHpa: s.pressureHpa,
|
||||
movement: s.movement,
|
||||
forecast: (s.forecastPoints || []).map((p) => ({
|
||||
lon: p.position.longitude,
|
||||
lat: p.position.latitude,
|
||||
tauHours: p.tauHours,
|
||||
windKt: p.windKt,
|
||||
})),
|
||||
cone: s.cone,
|
||||
track: s.track,
|
||||
link: s.advisoryUrl,
|
||||
source: 'NOAA NHC',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* EONET below */
|
||||
}
|
||||
// EONET severe storms add the basins NHC does not cover (W. Pacific,
|
||||
// Indian Ocean, Southern Hemisphere).
|
||||
try {
|
||||
const ev = await eonet.get();
|
||||
for (const s of ev.severeStorms) {
|
||||
if (Date.now() - s.t > 3 * 86_400_000) continue;
|
||||
const dup = storms.some(
|
||||
(n) => Math.abs(n.lat - s.lat) < 3 && Math.abs(n.lon - s.lon) < 3,
|
||||
);
|
||||
if (dup) continue;
|
||||
const nameMatch =
|
||||
/(Super Typhoon|Typhoon|Hurricane|Tropical Cyclone|Cyclone|Tropical Storm|Tropical Depression|Storm|Depression)\s+([\w-]+)/i.exec(
|
||||
s.title,
|
||||
);
|
||||
storms.push({
|
||||
id: s.id,
|
||||
name: nameMatch ? nameMatch[2] : s.title,
|
||||
label: nameMatch
|
||||
? nameMatch[1].replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: null,
|
||||
classification: /typhoon/i.test(s.title)
|
||||
? 'TY'
|
||||
: /cyclone/i.test(s.title)
|
||||
? 'TC'
|
||||
: 'TS',
|
||||
basin: '',
|
||||
title: s.title,
|
||||
lon: s.lon,
|
||||
lat: s.lat,
|
||||
t: s.t,
|
||||
windKt: s.unit === 'kts' ? s.magnitude : null,
|
||||
pastTrack: s.track,
|
||||
forecast: [],
|
||||
link: s.link,
|
||||
source: 'NASA EONET',
|
||||
});
|
||||
}
|
||||
if (!nhcOk) source = 'NASA EONET';
|
||||
else source = 'NOAA NHC / CPHC + NASA EONET';
|
||||
} catch {
|
||||
if (!nhcOk) throw new FeedError('Cyclone feeds unavailable');
|
||||
}
|
||||
return { data: { records: storms, source } };
|
||||
},
|
||||
{ ttlMs: 15 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────── Eyes ───────────────────────── */
|
||||
|
||||
export function createCctvFeed() {
|
||||
return createFeed(
|
||||
'cctv',
|
||||
async () => {
|
||||
const response = await fetchWithTimeout('/api/cctv/sources', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new FeedError(`CCTV HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload?.sources))
|
||||
throw new FeedError('Malformed camera catalog');
|
||||
const records = [];
|
||||
for (const c of payload.sources) {
|
||||
const lat = finite(c.lat);
|
||||
const lon = finite(c.lon);
|
||||
if (lat === null || lon === null) continue;
|
||||
records.push({
|
||||
id: String(c.id),
|
||||
name: String(c.name || c.code || 'Camera'),
|
||||
city: c.city || '',
|
||||
provider: c.provider || '',
|
||||
lon,
|
||||
lat,
|
||||
heading: finite(c.headingDeg) ?? 0,
|
||||
fovDeg: finite(c.fovDeg) ?? 50,
|
||||
pitchDeg: finite(c.pitchDeg) ?? -10,
|
||||
feedType: c.feedType || 'image',
|
||||
});
|
||||
}
|
||||
return { data: { records, source: 'Public traffic cameras' } };
|
||||
},
|
||||
{ ttlMs: 30 * 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Same frame URL family the production CCTV layer uses. */
|
||||
export function cctvFrameUrl(camera) {
|
||||
const params = new URLSearchParams({
|
||||
label: camera.name,
|
||||
city: camera.city,
|
||||
lat: camera.lat.toFixed(6),
|
||||
lon: camera.lon.toFixed(6),
|
||||
heading: String(Math.round(camera.heading)),
|
||||
fov: String(Math.round(camera.fovDeg)),
|
||||
pitch: String(Math.round(camera.pitchDeg || -10)),
|
||||
ts: String(Math.floor(Date.now() / 10_000)),
|
||||
});
|
||||
return `/api/cctv/frame/${encodeURIComponent(camera.id)}?${params}`;
|
||||
}
|
||||
|
||||
/* ───────────────────────── Bundled event ───────────────────────── */
|
||||
|
||||
export function createFloodFeed() {
|
||||
return createFeed(
|
||||
'bhote-koshi',
|
||||
async () => {
|
||||
let event = null;
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
'/events/bhote-koshi-2026/event.json',
|
||||
);
|
||||
if (response.ok) event = await response.json();
|
||||
} catch {
|
||||
/* the bundled centerline below still renders */
|
||||
}
|
||||
const corridor = event?.reconstruction?.corridor?.map((c) => [
|
||||
c.lon,
|
||||
c.lat,
|
||||
c.elevationM,
|
||||
]);
|
||||
return {
|
||||
data: {
|
||||
event,
|
||||
path: BHOTE_KOSHI_FLOOD_PATH.map(([lon, lat]) => [lon, lat]),
|
||||
corridor: corridor?.length ? corridor : null,
|
||||
source: 'GeoPera river centerline (CC BY-NC 4.0) · Vantor Open Data',
|
||||
},
|
||||
};
|
||||
},
|
||||
{ ttlMs: Infinity },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user