feat: Wave 6.1 keyed layers — NASA FIRMS hotspots + Windy webcams
- firms (bespoke): raw VIIRS S-NPP fire detections, world, 24h. CSV via new serve.py proxy/firms (key from gitignored firmscredentials.json, injected server-side, 1h cache + stale-on-error). Points colored/sized by fire radiative power; click -> FRP/brightness/time/satellite overlay. Verified live: 1412 detections, hottest 88 MW seen 00:35 UTC today. - windycams (bespoke): viewport-driven Windy Webcams v3 — nearest ~50 cams wherever the camera stops, entities with live preview img in InfoBox. proxy/windy validates nearby=lat,lon,km, injects x-windy-api-key header, 10min coarsened cache. Degrades cleanly: 'key rejected' status until a real WEBCAMS-product key lands in windycredentials.json (re-read per request, no restart needed). - key loader tolerant of JSON, brace-less fragments, or raw pasted tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ccb2214f9
commit
f55d3202e0
90
js/layers/firms.js
Normal file
90
js/layers/firms.js
Normal file
@ -0,0 +1,90 @@
|
||||
// NASA FIRMS layer (Wave 6.1) — raw VIIRS S-NPP satellite fire detections,
|
||||
// worldwide, last 24 h. This is the sensor-level truth EONET curates from:
|
||||
// every thermal anomaly the satellite saw, colored/sized by fire radiative
|
||||
// power. CSV comes via proxy/firms (key injected server-side, 1 h cache).
|
||||
|
||||
export default function create(ctx) {
|
||||
const { viewer, Cesium, ui } = ctx;
|
||||
const pts = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
|
||||
pts.show = false;
|
||||
|
||||
let enabled = false;
|
||||
let loaded = false;
|
||||
let timer = null;
|
||||
const REFRESH_MS = 1800000; // 30 min; server caches 1 h anyway
|
||||
|
||||
const DIM = Cesium.Color.fromCssColorString('#b3572c').withAlpha(0.8);
|
||||
const MID = Cesium.Color.fromCssColorString('#ff7a2f').withAlpha(0.9);
|
||||
const HOT = Cesium.Color.fromCssColorString('#ffd24a');
|
||||
const OUTLINE = Cesium.Color.BLACK;
|
||||
|
||||
ui.addLayer('firms', 'Fire hotspots (NASA FIRMS)', false, (on) => {
|
||||
enabled = on;
|
||||
pts.show = on;
|
||||
if (on && !loaded) refresh();
|
||||
if (on && !timer) timer = setInterval(refresh, REFRESH_MS);
|
||||
if (!on && timer) { clearInterval(timer); timer = null; }
|
||||
if (!on) { ui.hidePickOverlay(); ui.setStatus('firms', '', 'ok'); }
|
||||
else if (loaded) ui.setStatus('firms', statusText(), 'ok');
|
||||
}, 'Earth & hazards');
|
||||
|
||||
function statusText() { return `${pts.length} detections · 24h · VIIRS`; }
|
||||
|
||||
async function refresh() {
|
||||
if (!loaded) ui.setStatus('firms', 'fetching detections…', 'warn');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('proxy/firms');
|
||||
} catch {
|
||||
ui.setStatus('firms', 'network error', 'err'); return;
|
||||
}
|
||||
if (res.status === 503) { ui.setStatus('firms', 'no key — add firmscredentials.json', 'err'); return; }
|
||||
if (!res.ok) { ui.setStatus('firms', `HTTP ${res.status}`, 'warn'); return; }
|
||||
const text = await res.text();
|
||||
const lines = text.trim().split('\n');
|
||||
const head = (lines.shift() || '').split(',');
|
||||
const col = (n) => head.indexOf(n);
|
||||
const iLat = col('latitude'), iLon = col('longitude'), iFrp = col('frp');
|
||||
const iBright = col('bright_ti4'), iDate = col('acq_date'), iTime = col('acq_time');
|
||||
const iSat = col('satellite'), iDn = col('daynight'), iConf = col('confidence');
|
||||
if (iLat < 0 || iLon < 0) { ui.setStatus('firms', 'unexpected CSV shape', 'err'); return; }
|
||||
pts.removeAll();
|
||||
for (const line of lines) {
|
||||
const c = line.split(',');
|
||||
const lat = +c[iLat], lon = +c[iLon], frp = +c[iFrp] || 0;
|
||||
if (!isFinite(lat) || !isFinite(lon)) continue;
|
||||
pts.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||
pixelSize: frp > 50 ? 6 : frp > 5 ? 4 : 3,
|
||||
color: frp > 50 ? HOT : frp > 5 ? MID : DIM,
|
||||
outlineColor: OUTLINE,
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 50000,
|
||||
id: {
|
||||
layer: 'firms', frp,
|
||||
bright: +c[iBright] || null, date: c[iDate], time: c[iTime],
|
||||
sat: c[iSat], dn: c[iDn], conf: c[iConf],
|
||||
},
|
||||
});
|
||||
}
|
||||
loaded = true;
|
||||
if (enabled) ui.setStatus('firms', statusText(), 'ok');
|
||||
}
|
||||
|
||||
function handlePick(picked) {
|
||||
if (!enabled || picked?.id?.layer !== 'firms') return false;
|
||||
const d = picked.id;
|
||||
const hhmm = String(d.time || '').padStart(4, '0');
|
||||
ui.showPickOverlay('🛰 VIIRS fire detection', [
|
||||
['FRP', d.frp ? `${d.frp.toFixed(1)} MW` : '—'],
|
||||
['Brightness', d.bright ? `${d.bright.toFixed(0)} K` : '—'],
|
||||
['Seen', `${d.date || '—'} ${hhmm.slice(0, 2)}:${hhmm.slice(2)} UTC`],
|
||||
['Satellite', `${d.sat === 'N' ? 'Suomi NPP' : d.sat || '—'} · ${d.dn === 'D' ? 'day' : 'night'}`],
|
||||
['Confidence', { l: 'low', n: 'nominal', h: 'high' }[d.conf] || d.conf || '—'],
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
function clearPick() { ui.hidePickOverlay(); }
|
||||
|
||||
return { id: 'firms', handlePick, clearPick };
|
||||
}
|
||||
87
js/layers/windycams.js
Normal file
87
js/layers/windycams.js
Normal file
@ -0,0 +1,87 @@
|
||||
// Windy webcams layer (Wave 6.1) — live webcams near wherever you're looking,
|
||||
// worldwide. Viewport-driven like the radius layer: each time the camera stops
|
||||
// moving we ask proxy/windy for the ~50 nearest cams (key injected server-side,
|
||||
// 10 min cache, coarsened cache key so nearby pans reuse entries). Cams render
|
||||
// as entities so the InfoBox shows the live preview image + a player link.
|
||||
|
||||
export default function create(ctx) {
|
||||
const { viewer, Cesium, lib, ui } = ctx;
|
||||
const ds = new Cesium.CustomDataSource('windycams');
|
||||
ds.show = false;
|
||||
viewer.dataSources.add(ds);
|
||||
|
||||
let enabled = false;
|
||||
let inFlight = false;
|
||||
let debounce = null;
|
||||
let moveUnsub = null;
|
||||
|
||||
ui.addLayer('windycams', 'Webcams (Windy)', false, (on) => {
|
||||
enabled = on;
|
||||
ds.show = on;
|
||||
if (on) {
|
||||
moveUnsub = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
|
||||
refresh();
|
||||
} else {
|
||||
if (moveUnsub) { moveUnsub(); moveUnsub = null; }
|
||||
if (debounce) { clearTimeout(debounce); debounce = null; }
|
||||
ui.setStatus('windycams', '', 'ok');
|
||||
}
|
||||
}, 'Context');
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (!enabled) return;
|
||||
if (debounce) clearTimeout(debounce);
|
||||
debounce = setTimeout(refresh, 400);
|
||||
}
|
||||
|
||||
function viewCenter() {
|
||||
const c = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
||||
const km = Math.min(250, Math.max(15, Math.round(c.height / 2000)));
|
||||
return { lat: Cesium.Math.toDegrees(c.latitude), lon: Cesium.Math.toDegrees(c.longitude), km };
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!enabled || inFlight) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const { lat, lon, km } = viewCenter();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`proxy/windy?nearby=${lat.toFixed(2)},${lon.toFixed(2)},${km}`);
|
||||
} catch {
|
||||
ui.setStatus('windycams', 'network error', 'warn'); return;
|
||||
}
|
||||
if (res.status === 401) { ui.setStatus('windycams', 'key rejected — needs a Webcams API key', 'err'); return; }
|
||||
if (res.status === 503) { ui.setStatus('windycams', 'no key — add windycredentials.json', 'err'); return; }
|
||||
if (!res.ok) { ui.setStatus('windycams', `HTTP ${res.status}`, 'warn'); return; }
|
||||
let j;
|
||||
try { j = await res.json(); } catch { ui.setStatus('windycams', 'bad response', 'err'); return; }
|
||||
const cams = j.webcams || [];
|
||||
ds.entities.removeAll();
|
||||
for (const w of cams) {
|
||||
const loc = w.location || {};
|
||||
if (!isFinite(loc.latitude) || !isFinite(loc.longitude)) continue;
|
||||
const img = lib.safeUrl(((w.images || {}).current || {}).preview);
|
||||
const detail = lib.safeUrl((w.urls || {}).detail || ((w.player || {}).day));
|
||||
ds.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(loc.longitude, loc.latitude),
|
||||
point: {
|
||||
pixelSize: 6, color: lib.cz('#ffd166'),
|
||||
outlineColor: Cesium.Color.BLACK, outlineWidth: 1,
|
||||
disableDepthTestDistance: 50000,
|
||||
},
|
||||
description:
|
||||
`<p><b>${lib.escapeHtml(w.title || 'Webcam')}</b></p>` +
|
||||
(img ? `<p><img src="${lib.escapeHtml(img)}" style="width:100%;border-radius:6px" alt="webcam preview"/></p>` : '') +
|
||||
`<p style="opacity:.7">${lib.escapeHtml([loc.city, loc.country].filter(Boolean).join(' · '))}</p>` +
|
||||
(detail ? `<p><a href="${lib.escapeHtml(detail)}" target="_blank" rel="noopener">Open live player ↗</a></p>` : ''),
|
||||
});
|
||||
}
|
||||
ui.setStatus('windycams', `${ds.entities.values.length} cams near view${j.total ? ` · ${j.total} in area` : ''}`, 'ok');
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { id: 'windycams' };
|
||||
}
|
||||
@ -184,6 +184,8 @@ const LAYER_MODULES = [
|
||||
'./layers/radius.js',
|
||||
'./layers/celestial.js',
|
||||
'./layers/radiogarden.js',
|
||||
'./layers/firms.js',
|
||||
'./layers/windycams.js',
|
||||
];
|
||||
const activeLayers = [];
|
||||
|
||||
|
||||
4
js/ui.js
4
js/ui.js
@ -13,9 +13,9 @@ const CATEGORY_BY_ID = {
|
||||
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
||||
ships: 'Sea',
|
||||
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
||||
infra: 'Context', events: 'Context', radio: 'Context',
|
||||
infra: 'Context', events: 'Context', radio: 'Context', windycams: 'Context',
|
||||
rfs: 'Regional — Australia',
|
||||
jamcams: 'Regional — London', inciweb: 'Earth & hazards',
|
||||
jamcams: 'Regional — London', inciweb: 'Earth & hazards', firms: 'Earth & hazards',
|
||||
};
|
||||
|
||||
const sections = new Map(); // category → { body }
|
||||
|
||||
90
serve.py
90
serve.py
@ -93,6 +93,34 @@ RG_PATH_RE = re.compile(r'^ara/content/(places|page/[A-Za-z0-9]{4,16}/channels)$
|
||||
RG_CACHE_SEC = {"ara/content/places": 43200} # default for others: 3600
|
||||
_rg_cache = {} # path -> (body, ts)
|
||||
|
||||
# ---- Keyed upstreams: NASA FIRMS + Windy webcams -----------------------------
|
||||
# Keys live in gitignored files at repo root and are injected SERVER-SIDE only
|
||||
# (never reach the browser). Files are re-read on every request (they're tiny),
|
||||
# so dropping in a fixed key needs no server restart. The loader is tolerant:
|
||||
# full JSON, brace-less "key": "value" fragments, or a raw pasted token all work.
|
||||
def read_key_file(path):
|
||||
try:
|
||||
raw = open(path).read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
for attempt in (raw, "{" + raw + "}"):
|
||||
try:
|
||||
d = json.loads(attempt)
|
||||
if isinstance(d, dict):
|
||||
for v in d.values():
|
||||
if isinstance(v, str) and len(v) >= 16:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
m = re.findall(r'[A-Za-z0-9_-]{16,}', raw)
|
||||
return m[0] if m else None
|
||||
|
||||
FIRMS_CACHE_SEC = 3600 # FIRMS refreshes ~3-hourly; 1h cache is generous
|
||||
_firms_cache = [None, 0.0] # (body, ts)
|
||||
WINDY_NEARBY_RE = re.compile(r'^-?\d{1,2}(\.\d+)?,-?\d{1,3}(\.\d+)?,\d{1,3}$')
|
||||
WINDY_CACHE_SEC = 600
|
||||
_windy_cache = {} # rounded-nearby -> (body, ts)
|
||||
|
||||
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
||||
# HARD-caches the /v2/mil/ response — the quota guard. The $10 BASIC tier bills
|
||||
@ -213,6 +241,10 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self._serve_adsb(name[len("adsb/"):])
|
||||
if name.startswith("rg/"):
|
||||
return self._serve_rg(name[len("rg/"):])
|
||||
if name == "firms":
|
||||
return self._serve_firms()
|
||||
if name == "windy":
|
||||
return self._serve_windy(query)
|
||||
if name == "adsbx-mil":
|
||||
return self._serve_adsbx_mil()
|
||||
base = UPSTREAMS.get(name)
|
||||
@ -401,6 +433,64 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(502, {"error": str(e)})
|
||||
|
||||
def _serve_firms(self):
|
||||
# NASA FIRMS: VIIRS S-NPP near-real-time detections, world, last 24 h.
|
||||
# CSV upstream, key injected server-side, 1 h cache + stale-on-error.
|
||||
body, ts = _firms_cache
|
||||
if body is not None and time.time() - ts < FIRMS_CACHE_SEC:
|
||||
return self._send(200, "text/csv", body, {"X-Godsigh-Cache": "hit"})
|
||||
key = read_key_file("firmscredentials.json")
|
||||
if not key:
|
||||
return self._send_json(503, {"error": "no FIRMS key (firmscredentials.json)"})
|
||||
try:
|
||||
url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/VIIRS_SNPP_NRT/world/1"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "godsigh/1.0 (partly.party)"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = r.read()
|
||||
_firms_cache[0], _firms_cache[1] = data, time.time()
|
||||
return self._send(200, "text/csv", data, {"X-Godsigh-Cache": "miss"})
|
||||
except Exception as e:
|
||||
if body is not None:
|
||||
return self._send(200, "text/csv", body, {"X-Godsigh-Cache": "stale"})
|
||||
code = getattr(e, "code", 502)
|
||||
return self._send_json(code if isinstance(code, int) else 502, {"error": "FIRMS upstream failed"})
|
||||
|
||||
def _serve_windy(self, query):
|
||||
# Windy Webcams v3: cams near a point. Query is nearby=lat,lon,radius_km
|
||||
# (validated), key goes in the x-windy-api-key HEADER server-side. Cache
|
||||
# keyed on a coarsened nearby so panning nearby reuses entries.
|
||||
params = urllib.parse.parse_qs(query)
|
||||
nearby = (params.get("nearby") or [""])[0]
|
||||
if not WINDY_NEARBY_RE.match(nearby):
|
||||
return self._send_json(400, {"error": "bad nearby=lat,lon,radius_km"})
|
||||
lat, lon, rad = nearby.split(",")
|
||||
ck = f"{round(float(lat) * 4) / 4},{round(float(lon) * 4) / 4},{rad}"
|
||||
hit = _windy_cache.get(ck)
|
||||
if hit and time.time() - hit[1] < WINDY_CACHE_SEC:
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
|
||||
key = read_key_file("windycredentials.json")
|
||||
if not key:
|
||||
return self._send_json(503, {"error": "no Windy key (windycredentials.json)"})
|
||||
try:
|
||||
url = ("https://api.windy.com/webcams/api/v3/webcams?"
|
||||
f"nearby={nearby}&limit=50&include=location,images,player,urls")
|
||||
req = urllib.request.Request(url, headers={
|
||||
"User-Agent": "godsigh/1.0 (partly.party)", "x-windy-api-key": key})
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = r.read()
|
||||
_windy_cache[ck] = (data, time.time())
|
||||
return self._send(200, "application/json", data, {"X-Godsigh-Cache": "miss"})
|
||||
except urllib.error.HTTPError as e:
|
||||
if hit is not None:
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
if e.code == 401:
|
||||
return self._send_json(401, {"error": "Windy key rejected — needs a WEBCAMS API key (api.windy.com/webcams)"})
|
||||
return self._send_json(e.code, {"error": f"windy upstream {e.code}"})
|
||||
except Exception:
|
||||
if hit is not None:
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(502, {"error": "windy upstream failed"})
|
||||
|
||||
def _serve_adsbx_mil(self):
|
||||
host, key = load_adsbx_creds()
|
||||
if not host or not key:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user