From 3ca9409f31702970cbdd5497521f55f6e6f6e63a Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 16:05:28 +1000 Subject: [PATCH] wave2 phase 2: wildfires layer (real NASA EONET) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/layers/fires.js | 142 +++++++++++++++++++++++++++++++++++++++++++++ js/lib.js | 18 ++++++ js/main.js | 1 + 3 files changed, 161 insertions(+) create mode 100644 js/layers/fires.js diff --git a/js/layers/fires.js b/js/layers/fires.js new file mode 100644 index 0000000..01062b0 --- /dev/null +++ b/js/layers/fires.js @@ -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: + `` + + `` + + `` + + `` + + `
Event${title}
Category${category}
Last report${dateTxt}
` + + (link ? `

Open event on EONET ↗

` : '') + + `

Active wildfire event from NASA EONET (real data).

`, + }); + 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' }; +} diff --git a/js/lib.js b/js/lib.js index e355c33..8d6b939 100644 --- a/js/lib.js +++ b/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) => { diff --git a/js/main.js b/js/main.js index 65a6d52..021d8c3 100644 --- a/js/main.js +++ b/js/main.js @@ -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', ];