GODSIGH/js/layers/fires.js
jing 3ca9409f31 wave2 phase 2: wildfires layer (real NASA EONET)
New js/layers/fires.js — fetches EONET open wildfire events directly (ACAO:*),
refreshes every 15 min, rebuilt wholesale (no time dynamics — EONET events are
slow-moving). Uses the latest dated geometry per event; Point coords direct,
Polygon reduced to first-ring centroid. Flame-teardrop billboard (new
lib.flameGlyph, drawn in #ff7a1a with dark outline, shared canvas), aggressive
label translucency so dense California/Australia clusters stay legible, cap 500.
HUD row "Wildfires (NASA EONET)".

PITFALL handled: EONET mislabels Content-Type as application/rss+xml but the
body is JSON — we call res.json() and never gate on the header.

Verified in browser: 500 fires, all finite positions, glyphs render over
North America, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:05:28 +10:00

143 lines
4.9 KiB
JavaScript

// Wildfires layer (Wave 2) — REAL data from NASA's EONET events API.
// No time dynamics: EONET events are slow-moving, so availability games would
// mislead more than inform. Refreshed every 15 min, rebuilt wholesale.
//
// PITFALL baked in: EONET mislabels its Content-Type as `application/rss+xml`,
// but the body is JSON. fetch's res.json() ignores the header, so it works —
// we simply never gate on the content type.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium } = ctx;
const F = CONFIG.fires;
const ds = new Cesium.CustomDataSource('fires');
viewer.dataSources.add(ds);
const glyph = lib.flameGlyph(CONFIG.colors.fire); // one shared canvas
const fireColor = lib.cz(CONFIG.colors.fire);
let enabled = true;
let timer = null;
let inFlight = false;
ui.addLayer('fires', 'Wildfires (NASA EONET)', true, (on) => {
enabled = on;
ds.show = on;
});
ui.setStatus('fires', 'loading EONET feed…', 'warn');
// Pick the most recent dated geometry and reduce it to a {lon, lat, date}.
function locate(event) {
const geoms = Array.isArray(event.geometry) ? event.geometry : [];
if (!geoms.length) return null;
// Latest by date (EONET lists chronologically; be robust to ordering).
let latest = geoms[0];
for (const gm of geoms) {
if (gm.date && latest.date && gm.date > latest.date) latest = gm;
}
const c = latest.coordinates;
if (latest.type === 'Point' && Array.isArray(c) && isFinite(c[0]) && isFinite(c[1])) {
return { lon: c[0], lat: c[1], date: latest.date };
}
if (latest.type === 'Polygon' && Array.isArray(c) && Array.isArray(c[0])) {
const ring = c[0];
let sx = 0, sy = 0, n = 0;
for (const pt of ring) {
if (Array.isArray(pt) && isFinite(pt[0]) && isFinite(pt[1])) { sx += pt[0]; sy += pt[1]; n++; }
}
if (n) return { lon: sx / n, lat: sy / n, date: latest.date };
}
return null;
}
function build(events) {
ds.entities.removeAll();
let count = 0;
for (const ev of events) {
if (count >= F.cap) break;
const loc = locate(ev);
if (!loc) continue;
const title = ev.title || 'Wildfire';
const category = (ev.categories && ev.categories[0] && ev.categories[0].title) || 'Wildfires';
const link = ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${ev.id}` : null);
const dateTxt = loc.date ? new Date(loc.date).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—';
ds.entities.add({
name: title,
position: Cesium.Cartesian3.fromDegrees(loc.lon, loc.lat),
billboard: {
image: glyph,
width: 14,
height: 14,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: title,
font: '11px "Segoe UI", system-ui, sans-serif',
fillColor: fireColor,
outlineColor: Cesium.Color.fromCssColorString('#0a0f14'),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, -12),
// Aggressive fade: fires cluster hard (California, Australia), so labels
// only resolve once you've zoomed well into a region.
translucencyByDistance: new Cesium.NearFarScalar(3.0e5, 1.0, 1.5e6, 0.0),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
description:
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>Event</th><td>${title}</td></tr>` +
`<tr><th>Category</th><td>${category}</td></tr>` +
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
`</tbody></table>` +
(link ? `<p><a href="${link}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`,
});
count++;
}
ui.setStatus('fires', `${count} active fires`, 'ok');
}
async function poll() {
let res;
try {
res = await fetch(F.url);
} catch {
ui.setStatus('fires', 'network error — retrying', 'warn');
return;
}
if (!res.ok) {
ui.setStatus('fires', `HTTP ${res.status} — retrying`, 'warn');
return;
}
let data;
try {
// Body is JSON despite the rss+xml Content-Type — do NOT check the header.
data = await res.json();
} catch {
ui.setStatus('fires', 'bad response — retrying', 'err');
return;
}
const events = Array.isArray(data && data.events) ? data.events : [];
build(events);
}
async function tick() {
timer = null;
if (inFlight) return;
inFlight = true;
try {
await poll();
} finally {
inFlight = false;
}
timer = setTimeout(tick, F.refreshMs);
}
tick();
return { id: 'fires' };
}