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>
This commit is contained in:
parent
dadc068271
commit
3ca9409f31
142
js/layers/fires.js
Normal file
142
js/layers/fires.js
Normal file
@ -0,0 +1,142 @@
|
||||
// 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' };
|
||||
}
|
||||
18
js/lib.js
18
js/lib.js
@ -25,6 +25,24 @@ export function aircraftGlyph() {
|
||||
});
|
||||
}
|
||||
|
||||
// Upward teardrop/flame (wildfires). Drawn directly in `hex` with a dark
|
||||
// outline — one shared canvas reused across every fire billboard.
|
||||
export function flameGlyph(hex) {
|
||||
return makeGlyphCanvas(28, (g, s) => {
|
||||
const cx = s / 2;
|
||||
g.beginPath();
|
||||
g.moveTo(cx, 3); // pointed top
|
||||
g.bezierCurveTo(s * 0.86, s * 0.34, s * 0.80, s * 0.72, cx, s - 4);
|
||||
g.bezierCurveTo(s * 0.20, s * 0.72, s * 0.14, s * 0.34, cx, 3);
|
||||
g.closePath();
|
||||
g.fillStyle = hex;
|
||||
g.fill();
|
||||
g.lineWidth = 2;
|
||||
g.strokeStyle = '#0a0f14';
|
||||
g.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// White diamond (ships).
|
||||
export function shipGlyph() {
|
||||
return makeGlyphCanvas(28, (g) => {
|
||||
|
||||
@ -92,6 +92,7 @@ const LAYER_MODULES = [
|
||||
'./layers/infra.js',
|
||||
'./layers/events.js',
|
||||
'./layers/quakes.js',
|
||||
'./layers/fires.js',
|
||||
'./layers/ships.js',
|
||||
'./layers/aircraft.js',
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user