diff --git a/js/config.js b/js/config.js index 4dfe173..63ad929 100644 --- a/js/config.js +++ b/js/config.js @@ -61,6 +61,31 @@ export const CONFIG = { // any large bbox). Or set {lamin, lomin, lamax, lomax}. bbox: null, pollMs: 90_000, + // Callsign prefixes flagged as military (highlighted + optionally filtered). + // Trimmed OpenSky callsigns are matched by prefix. Curated list of common + // NATO/allied air-transport & tanker call words; extend freely. + militaryPrefixes: ['RCH', 'REACH', 'LAGR', 'DUKE', 'POLO', 'CNV', 'RRR', + 'ASCOT', 'GAF', 'BAF', 'IAM', 'PLF', 'HKY', 'NATO', + '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, }, // Optional live AIS. Leave blank to use the demo fleet only (roadmap feature). @@ -82,6 +107,12 @@ export const CONFIG = { aircraftLow: '#ffb347', aircraftMid: '#ffe74d', aircraftHigh: '#4dd2ff', + aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band) + // earthquakes (magnitude ramp: amber → red) + quakeMin: '#ffcf50', + quakeMax: '#ff3b30', + // wildfires + fire: '#ff7a1a', // ships shipNormal: '#7bff9d', shipToll: '#ffe74d', diff --git a/js/layers/quakes.js b/js/layers/quakes.js new file mode 100644 index 0000000..d9e30e1 --- /dev/null +++ b/js/layers/quakes.js @@ -0,0 +1,191 @@ +// 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, so refreshes update the delta instead of duplicating. + const byId = new Map(); + + 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, ent] of byId) { + if (!keepIds.has(id)) { + ds.entities.remove(ent); + byId.delete(id); + } + } + // Add newcomers (existing ids stay untouched — dedupe). + let maxMag = -Infinity; + for (const f of kept) { + if (f.properties.mag > maxMag) maxMag = f.properties.mag; + const id = `quake:${f.id}`; + if (byId.has(id)) continue; + const ent = buildEntity(f); + ds.entities.add(ent); + byId.set(id, ent); + } + + 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 2452aa5..65a6d52 100644 --- a/js/main.js +++ b/js/main.js @@ -91,6 +91,7 @@ const LAYER_MODULES = [ './layers/satellites.js', './layers/infra.js', './layers/events.js', + './layers/quakes.js', './layers/ships.js', './layers/aircraft.js', ]; diff --git a/serve.py b/serve.py index 41b6684..30b5b23 100644 --- a/serve.py +++ b/serve.py @@ -15,6 +15,12 @@ UPSTREAMS = { class Handler(SimpleHTTPRequestHandler): + def end_headers(self): + # Dev server: never let the browser cache our static assets, so edits to + # js/css always take effect on reload (module graphs cache aggressively). + self.send_header("Cache-Control", "no-store") + super().end_headers() + def do_GET(self): if not self.path.startswith("/proxy/"): return super().do_GET() @@ -47,7 +53,6 @@ class Handler(SimpleHTTPRequestHandler): self.send_header("Content-Type", ctype) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Expose-Headers", "X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After") - self.send_header("Cache-Control", "no-store") for k, v in passthrough.items(): self.send_header(k, v) self.send_header("Content-Length", str(len(body)))