From f55d3202e0acd60606ca501fa799b180357bc366 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 29 Jul 2026 14:14:32 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20Wave=206.1=20keyed=20layers=20=E2=80=94?= =?UTF-8?q?=20NASA=20FIRMS=20hotspots=20+=20Windy=20webcams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- js/layers/firms.js | 90 ++++++++++++++++++++++++++++++++++++++++++ js/layers/windycams.js | 87 ++++++++++++++++++++++++++++++++++++++++ js/main.js | 2 + js/ui.js | 4 +- serve.py | 90 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 js/layers/firms.js create mode 100644 js/layers/windycams.js diff --git a/js/layers/firms.js b/js/layers/firms.js new file mode 100644 index 0000000..563e63b --- /dev/null +++ b/js/layers/firms.js @@ -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 }; +} diff --git a/js/layers/windycams.js b/js/layers/windycams.js new file mode 100644 index 0000000..c7248c8 --- /dev/null +++ b/js/layers/windycams.js @@ -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: + `

${lib.escapeHtml(w.title || 'Webcam')}

` + + (img ? `

webcam preview

` : '') + + `

${lib.escapeHtml([loc.city, loc.country].filter(Boolean).join(' · '))}

` + + (detail ? `

Open live player ↗

` : ''), + }); + } + ui.setStatus('windycams', `${ds.entities.values.length} cams near view${j.total ? ` · ${j.total} in area` : ''}`, 'ok'); + } finally { + inFlight = false; + } + } + + return { id: 'windycams' }; +} diff --git a/js/main.js b/js/main.js index 4d03c46..f560aea 100644 --- a/js/main.js +++ b/js/main.js @@ -184,6 +184,8 @@ const LAYER_MODULES = [ './layers/radius.js', './layers/celestial.js', './layers/radiogarden.js', + './layers/firms.js', + './layers/windycams.js', ]; const activeLayers = []; diff --git a/js/ui.js b/js/ui.js index c181026..a3e2d70 100644 --- a/js/ui.js +++ b/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 } diff --git a/serve.py b/serve.py index ba3d327..74be6e9 100644 --- a/serve.py +++ b/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: