GODSIGH/js/layers/firms.js
type-two fcdcef760a fix: all data fetches use cache:'no-store' — browser-cache poisoning guard
During the nginx outage the proxy URLs briefly served the landing page
WITH cacheable headers; browsers cached that HTML for the proxy URLs and
kept reading it after the origin recovered (satellites 'no satellites',
empty aircraft). no-store on every feed fetch means the browser cache can
never serve (or store) a poisoned body for data endpoints again. The
proxies are no-store server-side anyway — this closes the outage-window
loophole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 09:59:59 +10:00

91 lines
3.8 KiB
JavaScript

// 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', { cache: 'no-store' });
} 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 };
}