wave3: layer registry + generic GeoJSON factory (+ 4 new free layers)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
96926091a6
commit
941f5032e0
21
js/config.js
21
js/config.js
@ -69,24 +69,9 @@ export const CONFIG = {
|
|||||||
'CFC', 'MC', 'SAM', 'EVAC'],
|
'CFC', 'MC', 'SAM', 'EVAC'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Earthquakes — USGS GeoJSON. Direct browser fetch (ACAO:*), no proxy needed.
|
// Earthquakes, wildfires, disasters, severe-weather, launches, NSW bushfires
|
||||||
// Full https URL by design (cross-origin feed, not a same-origin path).
|
// are now data-defined in js/registry.js (Wave 3). Their shared colors stay in
|
||||||
quakes: {
|
// CONFIG.colors below (quakeMin/quakeMax/fire).
|
||||||
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,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Military aircraft — REAL unfiltered military traffic (the OSINT prize OpenSky
|
// Military aircraft — REAL unfiltered military traffic (the OSINT prize OpenSky
|
||||||
// hides: RCH/Reach airlift, tankers, the Area 51 "Janet" shuttle). Now on the
|
// hides: RCH/Reach airlift, tankers, the Area 51 "Janet" shuttle). Now on the
|
||||||
|
|||||||
@ -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:
|
|
||||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
|
||||||
`<tr><th>Event</th><td>${lib.escapeHtml(title)}</td></tr>` +
|
|
||||||
`<tr><th>Category</th><td>${lib.escapeHtml(category)}</td></tr>` +
|
|
||||||
`<tr><th>Last report</th><td>${dateTxt}</td></tr>` +
|
|
||||||
`</tbody></table>` +
|
|
||||||
(link ? `<p><a href="${lib.escapeHtml(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' };
|
|
||||||
}
|
|
||||||
190
js/layers/geojson-layer.js
Normal file
190
js/layers/geojson-layer.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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:
|
|
||||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
|
||||||
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
|
|
||||||
`<tr><th>Place</th><td>${lib.escapeHtml(place)}</td></tr>` +
|
|
||||||
`<tr><th>Depth</th><td>${depth != null ? depth.toFixed(1) + ' km' : '—'}</td></tr>` +
|
|
||||||
`<tr><th>Time (UTC)</th><td>${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}</td></tr>` +
|
|
||||||
`</tbody></table>` +
|
|
||||||
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : ''),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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' };
|
|
||||||
}
|
|
||||||
14
js/main.js
14
js/main.js
@ -18,6 +18,8 @@
|
|||||||
import { CONFIG } from './config.js';
|
import { CONFIG } from './config.js';
|
||||||
import * as lib from './lib.js';
|
import * as lib from './lib.js';
|
||||||
import * as ui from './ui.js';
|
import * as ui from './ui.js';
|
||||||
|
import { REGISTRY } from './registry.js';
|
||||||
|
import { createGeoJsonLayer } from './layers/geojson-layer.js';
|
||||||
|
|
||||||
const Cesium = window.Cesium;
|
const Cesium = window.Cesium;
|
||||||
|
|
||||||
@ -168,8 +170,6 @@ const LAYER_MODULES = [
|
|||||||
'./layers/satellites.js',
|
'./layers/satellites.js',
|
||||||
'./layers/infra.js',
|
'./layers/infra.js',
|
||||||
'./layers/events.js',
|
'./layers/events.js',
|
||||||
'./layers/quakes.js',
|
|
||||||
'./layers/fires.js',
|
|
||||||
'./layers/ships.js',
|
'./layers/ships.js',
|
||||||
'./layers/aircraft.js',
|
'./layers/aircraft.js',
|
||||||
'./layers/military.js',
|
'./layers/military.js',
|
||||||
@ -195,6 +195,16 @@ async function loadLayers() {
|
|||||||
console.error(`[godsigh] layer ${LAYER_MODULES[i]} threw during init:`, err);
|
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) ----
|
// ---- live/scrub gating (throttled ~1/s) ----
|
||||||
|
|||||||
222
js/registry.js
Normal file
222
js/registry.js
Normal file
@ -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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Magnitude</th><td>M${(p.mag ?? 0).toFixed(1)}</td></tr>` +
|
||||||
|
`<tr><th>Place</th><td>${lib.escapeHtml(p.place || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Depth</th><td>${c[2] != null ? c[2].toFixed(1) + ' km' : '—'}</td></tr>` +
|
||||||
|
`<tr><th>Time (UTC)</th><td>${p.time != null ? iso(p.time) : '—'}</td></tr></tbody></table>` +
|
||||||
|
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">USGS event page ↗</a></p>` : '');
|
||||||
|
},
|
||||||
|
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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Event</th><td>${lib.escapeHtml(f.title || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Category</th><td>${lib.escapeHtml(cat)}</td></tr></tbody></table>` +
|
||||||
|
(link ? `<p><a href="${lib.escapeHtml(link)}" target="_blank" rel="noopener">Open event on EONET ↗</a></p>` : '') +
|
||||||
|
`<p style="opacity:.7">Active wildfire event from NASA EONET (real data).</p>`;
|
||||||
|
},
|
||||||
|
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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Event</th><td>${lib.escapeHtml(p.name || p.eventname || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Type</th><td>${lib.escapeHtml((TYPE[p.eventtype] || p.eventtype || '—'))}</td></tr>` +
|
||||||
|
`<tr><th>Alert</th><td>${lib.escapeHtml(p.alertlevel || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Since</th><td>${p.fromdate ? iso(p.fromdate) : '—'}</td></tr></tbody></table>` +
|
||||||
|
`<p style="opacity:.7">Global Disaster Alert & Coordination System (real data).</p>`;
|
||||||
|
},
|
||||||
|
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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Event</th><td>${lib.escapeHtml(p.event || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Severity</th><td>${lib.escapeHtml(p.severity || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Area</th><td>${lib.escapeHtml((p.areaDesc || '—').slice(0, 120))}</td></tr>` +
|
||||||
|
`<tr><th>Onset</th><td>${p.onset ? iso(p.onset) : '—'}</td></tr></tbody></table>` +
|
||||||
|
`<p>${lib.escapeHtml((p.headline || '').slice(0, 200))}</p>` +
|
||||||
|
`<p style="opacity:.7">US National Weather Service (real data; US only).</p>`;
|
||||||
|
},
|
||||||
|
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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Mission</th><td>${lib.escapeHtml(r.name || '—')}</td></tr>` +
|
||||||
|
`<tr><th>When (UTC)</th><td>${r.net ? iso(r.net) : '—'}</td></tr>` +
|
||||||
|
`<tr><th>Pad</th><td>${lib.escapeHtml(pad.name || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Site</th><td>${lib.escapeHtml(loc)}</td></tr>` +
|
||||||
|
`<tr><th>Status</th><td>${lib.escapeHtml((r.status || {}).abbrev || '—')}</td></tr></tbody></table>` +
|
||||||
|
`<p style="opacity:.7">Upcoming launch — The Space Devs / Launch Library 2.</p>`;
|
||||||
|
},
|
||||||
|
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 `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||||
|
`<tr><th>Incident</th><td>${lib.escapeHtml(p.title || '—')}</td></tr>` +
|
||||||
|
`<tr><th>Alert</th><td>${lib.escapeHtml(p.category || '—')}</td></tr></tbody></table>` +
|
||||||
|
`<p>${lib.escapeHtml(txt.slice(0, 300))}</p>` +
|
||||||
|
`<p style="opacity:.7">NSW Rural Fire Service — current major incidents (real data).</p>`;
|
||||||
|
},
|
||||||
|
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`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user