// 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'; 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${place}
Depth${depth != null ? depth.toFixed(1) + ' km' : '—'}
Time (UTC)${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'}
` + (p.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' }; }