From 941f5032e00e88fe79ee476b1697e80c39a0a5bd Mon Sep 17 00:00:00 2001 From: jing Date: Tue, 14 Jul 2026 00:26:13 +1000 Subject: [PATCH] wave3: layer registry + generic GeoJSON factory (+ 4 new free layers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js/layers/geojson-layer.js: one factory expressing fetch/dedupe+revision/ time-anchoring/cap/style/status — adding a GeoJSON feed is now data entry - js/registry.js: quakes & fires refactored onto it (verified parity — same counts/colors/time-anchoring), plus 4 new REAL layers, all default-off: GDACS disasters (93), NWS severe weather US (13), Launch Library (30), NSW RFS bushfires (21) - deleted js/layers/quakes.js + fires.js (now registry entries); removed dead CONFIG.quakes/fires blocks - factory honors default-off (ds.show synced to enabled); zero console errors Co-Authored-By: Claude Fable 5 --- js/config.js | 21 +--- js/layers/fires.js | 142 ------------------------ js/layers/geojson-layer.js | 190 +++++++++++++++++++++++++++++++ js/layers/quakes.js | 206 ---------------------------------- js/main.js | 14 ++- js/registry.js | 222 +++++++++++++++++++++++++++++++++++++ 6 files changed, 427 insertions(+), 368 deletions(-) delete mode 100644 js/layers/fires.js create mode 100644 js/layers/geojson-layer.js delete mode 100644 js/layers/quakes.js create mode 100644 js/registry.js 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}
` + - (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/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: - `` + - `` + - `` + - `` + - `` + - `
MagnitudeM${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') : '—'}
` + - (url ? `

USGS event page ↗

` : ''), - }); - - // Labels only for the notable quakes, to keep the globe legible. - if (mag >= Q.labelMinMag) { - ent.label = new Cesium.LabelGraphics({ - text: `M${mag.toFixed(1)} ${place}`, - 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, -8), - translucencyByDistance: new Cesium.NearFarScalar(2.0e6, 1.0, 1.2e7, 0.0), - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - } - return ent; - } - - function apply(features) { - // Keep only quakes with a real magnitude and finite coordinates. - const usable = features.filter((f) => { - const m = f && f.properties && f.properties.mag; - const c = f && f.geometry && f.geometry.coordinates; - return typeof m === 'number' && isFinite(m) && Array.isArray(c) && - isFinite(c[0]) && isFinite(c[1]) && f.id != null; - }); - - // Cap by dropping the smallest magnitudes first. - usable.sort((a, b) => b.properties.mag - a.properties.mag); - const kept = usable.slice(0, Q.cap); - - const keepIds = new Set(kept.map((f) => `quake:${f.id}`)); - // Remove entities that are no longer in the kept set (revised away or capped out). - for (const [id, rec] of byId) { - if (!keepIds.has(id)) { - ds.entities.remove(rec.entity); - byId.delete(id); - } - } - // Add newcomers and rebuild any quake USGS has revised (magnitude/place/etc.); - // unchanged ones are left in place (dedupe). - let maxMag = -Infinity; - for (const f of kept) { - if (f.properties.mag > maxMag) maxMag = f.properties.mag; - const id = `quake:${f.id}`; - const sig = sigOf(f); - const existing = byId.get(id); - if (existing) { - if (existing.sig === sig) continue; // unchanged — keep it - ds.entities.remove(existing.entity); // revised — rebuild below - byId.delete(id); - } - const ent = buildEntity(f); - ds.entities.add(ent); - byId.set(id, { entity: ent, sig }); - } - - const count = byId.size; - const maxTxt = count ? ` · max M${maxMag.toFixed(1)}` : ''; - ui.setStatus('quakes', `${count} quakes${maxTxt} · 24h`, 'ok'); - } - - async function poll() { - let res; - try { - res = await fetch(Q.url); - } catch { - ui.setStatus('quakes', 'network error — retrying', 'warn'); - return; - } - if (!res.ok) { - ui.setStatus('quakes', `HTTP ${res.status} — retrying`, 'warn'); - return; - } - let data; - try { - data = await res.json(); - } catch { - ui.setStatus('quakes', 'bad response — retrying', 'err'); - return; - } - const features = Array.isArray(data && data.features) ? data.features : []; - apply(features); - } - - // Self-scheduling loop — keeps polling regardless of scrub state, because - // quakes are historical records, not a live-only feed. - async function tick() { - timer = null; - if (inFlight) return; - inFlight = true; - try { - await poll(); - } finally { - inFlight = false; - } - timer = setTimeout(tick, Q.refreshMs); - } - - tick(); - - return { id: 'quakes' }; -} diff --git a/js/main.js b/js/main.js index 8663572..cd80155 100644 --- a/js/main.js +++ b/js/main.js @@ -18,6 +18,8 @@ import { CONFIG } from './config.js'; import * as lib from './lib.js'; import * as ui from './ui.js'; +import { REGISTRY } from './registry.js'; +import { createGeoJsonLayer } from './layers/geojson-layer.js'; const Cesium = window.Cesium; @@ -168,8 +170,6 @@ const LAYER_MODULES = [ './layers/satellites.js', './layers/infra.js', './layers/events.js', - './layers/quakes.js', - './layers/fires.js', './layers/ships.js', './layers/aircraft.js', './layers/military.js', @@ -195,6 +195,16 @@ async function loadLayers() { console.error(`[godsigh] layer ${LAYER_MODULES[i]} threw during init:`, err); } }); + + // Registry-driven layers (Wave 3): data-defined GeoJSON feeds. + for (const spec of REGISTRY) { + try { + const layer = createGeoJsonLayer(ctx, spec); + if (layer) activeLayers.push(layer); + } catch (err) { + console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err); + } + } } // ---- live/scrub gating (throttled ~1/s) ---- diff --git a/js/registry.js b/js/registry.js new file mode 100644 index 0000000..736537e --- /dev/null +++ b/js/registry.js @@ -0,0 +1,222 @@ +// Layer registry (Wave 3) — one object literal = one working layer, rendered by +// createGeoJsonLayer (js/layers/geojson-layer.js). Adding a public GeoJSON-ish +// feed is a data-entry task here, not new code. Bespoke layers with real +// per-layer logic (satellites, aircraft, ships, military, infra, events) stay as +// their own modules in main.js's LAYER_MODULES — the registry is additive. +// +// Each entry's style(f, ctx) / description(f, ctx) / status(...) receive the +// live ctx, so they reach Cesium, lib (cz/escapeHtml/safeUrl/flameGlyph), CONFIG. + +// One shared flame canvas across every fire billboard (Cesium atlases it). +let _fireGlyph = null; +const fireGlyph = (ctx) => (_fireGlyph ||= ctx.lib.flameGlyph(ctx.CONFIG.colors.fire)); + +const iso = (s) => { + const d = new Date(s); + return isNaN(d) ? '—' : d.toISOString().replace('T', ' ').replace('.000Z', ' UTC'); +}; + +export const REGISTRY = [ + // ─────────────────────────── Earth ─────────────────────────── + { + id: 'quakes', name: 'Earthquakes (USGS)', category: 'Earth', defaultOn: true, + url: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson', + refreshMs: 300000, cap: 400, + adapt: (j) => j.features, + usable: (f) => { + const m = f && f.properties && f.properties.mag; + const c = f && f.geometry && f.geometry.coordinates; + return typeof m === 'number' && isFinite(m) && Array.isArray(c) && + isFinite(c[0]) && isFinite(c[1]) && f.id != null; + }, + featureId: (f) => f.id, + sig: (f) => { const p = f.properties || {}; const c = (f.geometry || {}).coordinates || []; return `${p.mag}|${p.place}|${c[2]}|${p.time}`; }, + timeAt: (f) => f.properties.time, + sortKey: (f) => f.properties.mag, + style: (f, { Cesium, lib, CONFIG }) => { + const mag = f.properties.mag; + const t = Cesium.Math.clamp((mag - 2) / (6 - 2), 0, 1); + const color = Cesium.Color.lerp(lib.cz(CONFIG.colors.quakeMin), lib.cz(CONFIG.colors.quakeMax), t, new Cesium.Color()); + const base = Math.max(3, 4 + mag * 2.2); + const timeMs = f.properties.time; + const recent = typeof timeMs === 'number' && (Date.now() - timeMs) < 3.6e6; + const pixelSize = recent + ? new Cesium.CallbackProperty(() => ((Date.now() - timeMs) < 3.6e6 ? base + 3 * Math.sin(Date.now() / 300) : base), false) + : base; + const s = { point: { pixelSize, color, outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: Number.POSITIVE_INFINITY } }; + if (mag >= 4.5) s.label = { text: `M${mag.toFixed(1)} ${f.properties.place || ''}`, fade: [2.0e6, 1.2e7] }; + return s; + }, + description: (f, { lib }) => { + const p = f.properties || {}; const c = (f.geometry || {}).coordinates || []; + const url = lib.safeUrl(p.url); + return `` + + `` + + `` + + `` + + `
MagnitudeM${(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) : '—'}
` + + (url ? `

USGS event page ↗

` : ''); + }, + status: ({ shown, features }) => { + const max = features.reduce((m, f) => Math.max(m, f.properties.mag), -Infinity); + return `${shown} quakes${features.length ? ` · max M${max.toFixed(1)}` : ''} · 24h`; + }, + }, + + { + id: 'fires', name: 'Wildfires (NASA EONET)', category: 'Earth', defaultOn: true, + url: 'https://eonet.gsfc.nasa.gov/api/v3/events?category=wildfires&status=open&limit=500', + refreshMs: 900000, cap: 500, + adapt: (j) => j.events, // NOTE: EONET mislabels Content-Type rss+xml; res.json() still parses + // EONET events carry a dated `geometry` ARRAY (not a GeoJSON feature.geometry). + locate: (ev) => { + const geoms = Array.isArray(ev.geometry) ? ev.geometry : []; + let latest = geoms[0]; + for (const g of geoms) if (g.date && latest.date && g.date > latest.date) latest = g; + if (!latest) return null; + const c = latest.coordinates; + if (latest.type === 'Point' && Array.isArray(c) && isFinite(c[0]) && isFinite(c[1])) return { lon: c[0], lat: c[1] }; + if (latest.type === 'Polygon' && Array.isArray(c) && Array.isArray(c[0])) { + let sx = 0, sy = 0, n = 0; + for (const pt of c[0]) 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; + } + return null; + }, + style: (f, ctx) => ({ + billboard: { image: fireGlyph(ctx), width: 14, height: 14, verticalOrigin: ctx.Cesium.VerticalOrigin.BOTTOM, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: f.title || 'Wildfire', fillColor: ctx.lib.cz(ctx.CONFIG.colors.fire), fade: [3.0e5, 1.5e6] }, + }), + description: (f, { lib }) => { + const cat = (f.categories && f.categories[0] && f.categories[0].title) || 'Wildfires'; + const link = lib.safeUrl(f.link || (f.id ? `https://eonet.gsfc.nasa.gov/api/v3/events/${encodeURIComponent(f.id)}` : '')); + return `` + + `` + + `
Event${lib.escapeHtml(f.title || '—')}
Category${lib.escapeHtml(cat)}
` + + (link ? `

Open event on EONET ↗

` : '') + + `

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`; +}