diff --git a/js/config.js b/js/config.js index bf21cb8..358b94f 100644 --- a/js/config.js +++ b/js/config.js @@ -69,24 +69,9 @@ export const CONFIG = { 'CFC', 'MC', 'SAM', 'EVAC'], }, - // Earthquakes — USGS GeoJSON. Direct browser fetch (ACAO:*), no proxy needed. - // Full https URL by design (cross-origin feed, not a same-origin path). - quakes: { - url: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson', - refreshMs: 300_000, // 5 min - cap: 400, // hard ceiling on rendered quakes (drop smallest mag first) - labelMinMag: 4.5, // only label quakes at/above this magnitude (declutter) - magRamp: [2, 6], // color/size lerp domain - }, - - // Wildfires — NASA EONET. Direct fetch (ACAO:*). NOTE: EONET mislabels its - // Content-Type as application/rss+xml, but the body is JSON — never gate on - // the header; res.json() works regardless. - fires: { - url: 'https://eonet.gsfc.nasa.gov/api/v3/events?category=wildfires&status=open&limit=500', - refreshMs: 900_000, // 15 min - cap: 500, - }, + // Earthquakes, wildfires, disasters, severe-weather, launches, NSW bushfires + // are now data-defined in js/registry.js (Wave 3). Their shared colors stay in + // CONFIG.colors below (quakeMin/quakeMax/fire). // Military aircraft — REAL unfiltered military traffic (the OSINT prize OpenSky // hides: RCH/Reach airlift, tankers, the Area 51 "Janet" shuttle). Now on the diff --git a/js/layers/fires.js b/js/layers/fires.js deleted file mode 100644 index ebc1ea2..0000000 --- a/js/layers/fires.js +++ /dev/null @@ -1,142 +0,0 @@ -// 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 = lib.safeUrl(ev.link || (ev.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(ev.id)}` : '')); - 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 | ${lib.escapeHtml(title)} |
|---|---|
| Category | ${lib.escapeHtml(category)} |
| Last report | ${dateTxt} |
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/layers/geojson-layer.js b/js/layers/geojson-layer.js new file mode 100644 index 0000000..4ed4404 --- /dev/null +++ b/js/layers/geojson-layer.js @@ -0,0 +1,190 @@ +// Generic GeoJSON-ish layer factory (Wave 3). One function that expresses what +// quakes.js and fires.js used to hand-roll, driven entirely by a `spec` object +// from js/registry.js — so adding a feed is data entry, not new code. +// +// A spec (all but id/name/url optional; sensible defaults shown in registry.js): +// { id, name, category, defaultOn, url, refreshMs, +// adapt(json) -> raw feature array (default: json.features) +// usable(f) -> bool (default: finite lon/lat) +// locate(f) -> {lon,lat} | null (default: GeoJSON geometry) +// featureId(f) -> string | null (null => rebuild wholesale) +// sig(f) -> string (revision key; default props+coords) +// timeAt(f) -> ms|Date|null (non-null => time-anchored availability) +// sortKey(f) -> number (cap keeps highest; default 0) +// cap -> number +// style(f, ctx) -> { point?, billboard?, label? } Cesium graphics options +// description(f, ctx) -> html +// status({shown, features}) -> string } + +export function createGeoJsonLayer(ctx, spec) { + const { viewer, Cesium, lib, ui, start, stop } = ctx; + const ds = new Cesium.CustomDataSource(spec.id); + viewer.dataSources.add(ds); + + const refreshMs = spec.refreshMs || 300000; + const adapt = spec.adapt || ((json) => (json && json.features) || []); + const locate = spec.locate || defaultLocate; + const usable = spec.usable || ((f) => { const p = locate(f); return !!p && isFinite(p.lon) && isFinite(p.lat); }); + const sig = spec.sig || ((f) => JSON.stringify([f.properties, f.geometry && f.geometry.coordinates])); + const status = spec.status || (({ shown }) => `${shown} features`); + + // id → { entity, sig } for dedupe + USGS-style revision detection. + const byId = spec.featureId ? new Map() : null; + + let enabled = spec.defaultOn !== false; + let timer = null; + let inFlight = false; + + ds.show = enabled; // datasources default to visible — honor a default-off layer + ui.addLayer(spec.id, spec.name, enabled, (on) => { enabled = on; ds.show = on; }, spec.category); + ui.setStatus(spec.id, 'loading…', 'warn'); + + // Build one Cesium.Entity from a feature via the spec's style/description. + function buildEntity(f, id) { + const p = locate(f); + const s = (spec.style && spec.style(f, ctx)) || {}; + const opts = { + position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0), + }; + if (id != null) opts.id = `${spec.id}:${id}`; + if (spec.timeAt) { + const when = spec.timeAt(f); + if (when != null) opts.availability = availabilityFor(when); + } + if (s.point) opts.point = s.point; + if (s.billboard) opts.billboard = s.billboard; + if (s.label) { + opts.label = { + font: '11px "Segoe UI", system-ui, sans-serif', + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.fromCssColorString('#0a0f14'), + outlineWidth: 3, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, -10), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + ...s.label, + }; + if (s.label.fade) { + const [near, far] = s.label.fade; + opts.label.translucencyByDistance = new Cesium.NearFarScalar(near, 1.0, far, 0.0); + delete opts.label.fade; + } + } + if (spec.description) opts.description = spec.description(f, ctx); + return new Cesium.Entity(opts); + } + + // Availability start clamped into the fixed clock window [start, stop], so a + // real event appears exactly as the slider crosses its moment. + function availabilityFor(when) { + const jd = Cesium.JulianDate.fromDate(when instanceof Date ? when : new Date(when)); + let sJd = jd; + if (Cesium.JulianDate.lessThan(sJd, start)) sJd = start.clone(); + if (Cesium.JulianDate.greaterThan(sJd, stop)) sJd = stop.clone(); + return new Cesium.TimeIntervalCollection([new Cesium.TimeInterval({ start: sJd, stop })]); + } + + function apply(rawFeatures) { + let features = (rawFeatures || []).filter(usable); + if (spec.sortKey) features.sort((a, b) => spec.sortKey(b) - spec.sortKey(a)); + if (spec.cap) features = features.slice(0, spec.cap); + + if (!byId) { + // Wholesale rebuild (feeds with no stable id, e.g. fires). + ds.entities.removeAll(); + for (const f of features) ds.entities.add(buildEntity(f, null)); + ui.setStatus(spec.id, status({ shown: features.length, features }), 'ok'); + return; + } + + // Dedupe by id + rebuild only revised entities (e.g. USGS magnitude updates). + const keep = new Map(features.map((f) => [`${spec.id}:${spec.featureId(f)}`, f])); + for (const [key, rec] of byId) { + if (!keep.has(key)) { ds.entities.remove(rec.entity); byId.delete(key); } + } + for (const [key, f] of keep) { + const s = sig(f); + const existing = byId.get(key); + if (existing) { + if (existing.sig === s) continue; + ds.entities.remove(existing.entity); + } + const ent = buildEntity(f, spec.featureId(f)); + ds.entities.add(ent); + byId.set(key, { entity: ent, sig: s }); + } + ui.setStatus(spec.id, status({ shown: byId.size, features }), 'ok'); + } + + async function poll() { + let res; + try { + res = await fetch(spec.url); + } catch { + ui.setStatus(spec.id, 'network error — retrying', 'warn'); + return; + } + if (!res.ok) { + ui.setStatus(spec.id, `HTTP ${res.status} — retrying`, 'warn'); + return; + } + let json; + try { + json = await res.json(); // never gate on Content-Type (EONET lies rss+xml) + } catch { + ui.setStatus(spec.id, 'bad response — retrying', 'err'); + return; + } + try { + apply(adapt(json)); + } catch (err) { + console.error(`[godsigh] ${spec.id} apply failed:`, err); + ui.setStatus(spec.id, 'render error', 'err'); + } + } + + // Self-scheduling loop; keeps polling regardless of scrub (historical feeds). + async function tick() { + timer = null; + if (inFlight) return; + inFlight = true; + try { await poll(); } finally { inFlight = false; } + timer = setTimeout(tick, refreshMs); + } + tick(); + + return { id: spec.id }; +} + +// Default geometry → {lon, lat}: Point, Polygon (ring centroid), or a +// GeometryCollection (first usable Point, else first Polygon centroid). +export function defaultLocate(f) { + const g = f && f.geometry; + if (!g) return null; + return fromGeometry(g); +} + +function fromGeometry(g) { + if (!g) return null; + if (g.type === 'Point' && Array.isArray(g.coordinates)) { + const [lon, lat] = g.coordinates; + return isFinite(lon) && isFinite(lat) ? { lon, lat } : null; + } + if (g.type === 'Polygon' && Array.isArray(g.coordinates) && Array.isArray(g.coordinates[0])) { + return centroid(g.coordinates[0]); + } + if (g.type === 'GeometryCollection' && Array.isArray(g.geometries)) { + for (const sub of g.geometries) if (sub.type === 'Point') { const p = fromGeometry(sub); if (p) return p; } + for (const sub of g.geometries) { const p = fromGeometry(sub); if (p) return p; } + } + return null; +} + +function centroid(ring) { + 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++; } + } + return n ? { lon: sx / n, lat: sy / n } : null; +} diff --git a/js/layers/quakes.js b/js/layers/quakes.js deleted file mode 100644 index d8c252a..0000000 --- a/js/layers/quakes.js +++ /dev/null @@ -1,206 +0,0 @@ -// Earthquakes layer (Wave 2) — REAL data from the USGS all_day GeoJSON feed. -// Time-anchored like the events layer, but with genuine events: a quake's -// availability starts at its own origin time, so scrubbing the slider back -// before it happened makes it vanish. Fetched directly (USGS sends ACAO:*), -// refreshed every 5 min, deduped by feature id across refreshes. - -export default function create(ctx) { - const { viewer, CONFIG, lib, ui, Cesium, start, stop } = ctx; - const Q = CONFIG.quakes; - - const ds = new Cesium.CustomDataSource('quakes'); - viewer.dataSources.add(ds); - - const amber = lib.cz(CONFIG.colors.quakeMin); - const red = lib.cz(CONFIG.colors.quakeMax); - const [magLo, magHi] = Q.magRamp; - - // id → { entity, sig }, so refreshes update the delta instead of duplicating. - // The sig lets us detect USGS revisions (magnitude/place/depth/time) and - // rebuild that entity, rather than leaving a stale one on screen. - const byId = new Map(); - const sigOf = (f) => { - const p = f.properties || {}; - const c = (f.geometry && f.geometry.coordinates) || []; - return `${p.mag}|${p.place}|${c[2]}|${p.time}`; - }; - - let enabled = true; - let timer = null; - let inFlight = false; - - ui.addLayer('quakes', 'Earthquakes (USGS)', true, (on) => { - enabled = on; - ds.show = on; - }); - ui.setStatus('quakes', 'loading USGS feed…', 'warn'); - - // Magnitude → color (amber→red over magRamp) and base pixel size. - function magColor(mag) { - const t = Cesium.Math.clamp((mag - magLo) / (magHi - magLo), 0, 1); - return Cesium.Color.lerp(amber, red, t, new Cesium.Color()); - } - const baseSize = (mag) => Math.max(3, 4 + mag * 2.2); - - // Availability start clamped into the fixed clock window [start, stop]. - // Older-than-window quakes are visible the whole time; in-window ones appear - // as the slider crosses their origin time; (impossible) future ones clamp out. - function availabilityFor(timeMs) { - const jd = Cesium.JulianDate.fromDate(new Date(timeMs)); - let s = jd; - if (Cesium.JulianDate.lessThan(s, start)) s = start.clone(); - if (Cesium.JulianDate.greaterThan(s, stop)) s = stop.clone(); - return new Cesium.TimeIntervalCollection([ - new Cesium.TimeInterval({ start: s, stop }), - ]); - } - - function buildEntity(f) { - const p = f.properties || {}; - const g = f.geometry || {}; - const coords = g.coordinates || []; - const lon = coords[0]; - const lat = coords[1]; - const depth = coords[2]; - const mag = p.mag; - const timeMs = p.time; - const place = p.place || 'Unknown location'; // raw: used in name/label (text contexts) - const url = lib.safeUrl(p.url); - const size = baseSize(mag); - const color = magColor(mag); - const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000; - - // Quakes under an hour old pulse; the callback is age-aware so it settles - // on its own once the quake passes the 1 h mark (no rebuild needed). - const pixelSize = recentAtBuild - ? new Cesium.CallbackProperty(() => { - const fresh = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000; - return fresh ? size + 3 * Math.sin(Date.now() / 300) : size; - }, false) - : size; - - const ent = new Cesium.Entity({ - id: `quake:${f.id}`, - name: `M${mag.toFixed(1)} — ${place}`, - position: Cesium.Cartesian3.fromDegrees(lon, lat), - availability: availabilityFor(timeMs), - point: { - pixelSize, - color, - outlineColor: Cesium.Color.BLACK, - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - description: - `| Magnitude | M${mag.toFixed(1)} |
|---|---|
| Place | ${lib.escapeHtml(place)} |
| Depth | ${depth != null ? depth.toFixed(1) + ' km' : '—'} |
| Time (UTC) | ${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'} |
| Magnitude | M${(p.mag ?? 0).toFixed(1)} |
|---|---|
| Place | ${lib.escapeHtml(p.place || '—')} |
| Depth | ${c[2] != null ? c[2].toFixed(1) + ' km' : '—'} |
| Time (UTC) | ${p.time != null ? iso(p.time) : '—'} |
| Event | ${lib.escapeHtml(f.title || '—')} |
|---|---|
| Category | ${lib.escapeHtml(cat)} |
Active wildfire event from NASA EONET (real data).
`; + }, + status: ({ shown }) => `${shown} active fires`, + }, + + // ─────────────────────────── Human ─────────────────────────── + { + id: 'gdacs', name: 'Disasters (GDACS)', category: 'Human', defaultOn: false, + url: 'https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP', + refreshMs: 900000, cap: 300, + adapt: (j) => j.features, + timeAt: (f) => f.properties && f.properties.fromdate, // clamps into the window + style: (f, { Cesium, lib }) => { + const p = f.properties || {}; + const c = { Green: '#46e08a', Orange: '#ff9f40', Red: '#ff453a' }[p.alertlevel] || '#9fb0bd'; + return { + point: { pixelSize: p.alertlevel === 'Red' ? 12 : 9, color: lib.cz(c), outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: `${TYPE[p.eventtype] || p.eventtype || ''} ${p.name || p.eventname || ''}`.trim(), fillColor: lib.cz(c), fade: [1.5e6, 1.4e7] }, + }; + }, + description: (f, { lib }) => { + const p = f.properties || {}; + return `| Event | ${lib.escapeHtml(p.name || p.eventname || '—')} |
|---|---|
| Type | ${lib.escapeHtml((TYPE[p.eventtype] || p.eventtype || '—'))} |
| Alert | ${lib.escapeHtml(p.alertlevel || '—')} |
| Since | ${p.fromdate ? iso(p.fromdate) : '—'} |
Global Disaster Alert & Coordination System (real data).
`; + }, + status: ({ shown }) => `${shown} active disasters`, + }, + + { + id: 'nws', name: 'Severe weather (US · NWS)', category: 'Human', defaultOn: false, + url: 'https://api.weather.gov/alerts/active?severity=Severe', + refreshMs: 300000, cap: 300, + adapt: (j) => j.features, + // Many alerts have null geometry (zone-only) — skip those (default usable does). + timeAt: (f) => f.properties && f.properties.onset, + style: (f, { Cesium, lib }) => ({ + point: { pixelSize: 8, color: lib.cz('#ffcf50'), outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: (f.properties && f.properties.event) || 'Alert', fillColor: lib.cz('#ffcf50'), fade: [4.0e5, 4.0e6] }, + }), + description: (f, { lib }) => { + const p = f.properties || {}; + return `| Event | ${lib.escapeHtml(p.event || '—')} |
|---|---|
| Severity | ${lib.escapeHtml(p.severity || '—')} |
| Area | ${lib.escapeHtml((p.areaDesc || '—').slice(0, 120))} |
| Onset | ${p.onset ? iso(p.onset) : '—'} |
${lib.escapeHtml((p.headline || '').slice(0, 200))}
` + + `US National Weather Service (real data; US only).
`; + }, + status: ({ shown }) => `${shown} severe alerts (US)`, + }, + + // ─────────────────────────── Space ─────────────────────────── + { + id: 'launches', name: 'Rocket launches', category: 'Space', defaultOn: false, + url: 'https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=30', + refreshMs: 1800000, // 30 min — LL2 free tier is rate-limited; launches move slowly + adapt: (j) => j.results, + // NOT time-anchored: most `net` are beyond the +6h window; render statically + // at the pad with a T- countdown in the label instead. + locate: (r) => { + const pad = r.pad || {}; + const lon = parseFloat(pad.longitude), lat = parseFloat(pad.latitude); + return isFinite(lon) && isFinite(lat) ? { lon, lat } : null; + }, + style: (r, { Cesium, lib }) => ({ + point: { pixelSize: 9, color: lib.cz('#35e0ff'), outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: `🚀 ${countdown(r.net)}`, fillColor: lib.cz('#35e0ff'), fade: [8.0e5, 2.0e7] }, + }), + description: (r, { lib }) => { + const pad = r.pad || {}; const loc = (pad.location || {}).name || ''; + return `| Mission | ${lib.escapeHtml(r.name || '—')} |
|---|---|
| When (UTC) | ${r.net ? iso(r.net) : '—'} |
| Pad | ${lib.escapeHtml(pad.name || '—')} |
| Site | ${lib.escapeHtml(loc)} |
| Status | ${lib.escapeHtml((r.status || {}).abbrev || '—')} |
Upcoming launch — The Space Devs / Launch Library 2.
`; + }, + status: ({ shown }) => `${shown} upcoming launches`, + }, + + // ──────────────────── Regional — Australia ──────────────────── + { + id: 'rfs', name: 'NSW bushfires (RFS)', category: 'Regional — Australia', defaultOn: false, + url: 'https://www.rfs.nsw.gov.au/feeds/majorIncidents.json', + refreshMs: 600000, cap: 300, + adapt: (j) => j.features, // features carry GeometryCollection — default locate handles it + style: (f, { Cesium, lib }) => { + const cat = (f.properties && f.properties.category) || ''; + const c = { 'Emergency Warning': '#ff453a', 'Watch and Act': '#ff9f40', 'Advice': '#ffcf50' }[cat] || '#9fb0bd'; + return { + point: { pixelSize: cat === 'Emergency Warning' ? 12 : 9, color: lib.cz(c), outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: (f.properties && f.properties.title) || 'Incident', fillColor: lib.cz(c), fade: [2.0e5, 3.0e6] }, + }; + }, + description: (f, { lib }) => { + const p = f.properties || {}; + // description is HTML from a GeoRSS feed — strip tags, then escape. + const txt = String(p.description || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + return `| Incident | ${lib.escapeHtml(p.title || '—')} |
|---|---|
| Alert | ${lib.escapeHtml(p.category || '—')} |
${lib.escapeHtml(txt.slice(0, 300))}
` + + `NSW Rural Fire Service — current major incidents (real data).
`; + }, + status: ({ shown }) => `${shown} NSW incidents`, + }, +]; + +// GDACS event-type codes → readable prefix. +const TYPE = { EQ: '🌍 Quake', TC: '🌀 Cyclone', FL: '🌊 Flood', DR: '🏜 Drought', VO: '🌋 Volcano', WF: '🔥 Wildfire', TS: '🌊 Tsunami' }; + +function countdown(net) { + const t = new Date(net).getTime(); + if (isNaN(t)) return 'launch'; + const dm = Math.round((t - Date.now()) / 60000); + if (dm < 0) return 'launched'; + if (dm < 60) return `T-${dm}m`; + if (dm < 1440) return `T-${Math.round(dm / 60)}h`; + return `T-${Math.round(dm / 1440)}d`; +}