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>
143 lines
4.9 KiB
JavaScript
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' };
|
|
}
|