wave2 phase 1: earthquakes layer (real USGS, time-anchored)
New js/layers/quakes.js — fetches the USGS all_day GeoJSON directly (ACAO:*, no proxy), refreshes every 5 min regardless of scrub state. Quakes are time-anchored via availability: in-window quakes appear as the slider crosses their origin time, older-than-window quakes stay visible the whole window (intervals clamped to [start, stop]). Magnitude ramps amber→red, size scales with magnitude, quakes <1h old pulse (age-aware callback), labels only for M>=4.5. Deduped by feature id across refreshes, capped at 400 (smallest mag dropped first). HUD row "Earthquakes (USGS)" with count/max-mag status. Also: serve.py now sends Cache-Control: no-store on all responses (was proxy-only) so iterative js/css edits always take effect on reload — the module graph was caching stale copies during dev. Verified in browser: 223 quakes, 11 labeled, time-anchoring confirmed (187 visible at -6h → 223 at now), zero console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ac811a75fe
commit
dadc068271
31
js/config.js
31
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',
|
||||
|
||||
191
js/layers/quakes.js
Normal file
191
js/layers/quakes.js
Normal file
@ -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:
|
||||
`<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||
`<tr><th>Magnitude</th><td>M${mag.toFixed(1)}</td></tr>` +
|
||||
`<tr><th>Place</th><td>${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>` +
|
||||
(p.url ? `<p><a href="${p.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, 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' };
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
|
||||
7
serve.py
7
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)))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user