GODSIGH/js/layers/quakes.js
jing 403884e480 wave2: adversarial-review fixes (9 confirmed findings)
Ran a 7-reviewer × per-finding-verifier workflow over the Wave 2 code; 9 of 13
raw findings survived adversarial verification. Fixes:

- quakes.js: refresh now UPDATES revised quakes, not just dedupes — a signature
  (mag|place|depth|time) per id detects USGS revisions and rebuilds that entity,
  so a quake revised M4.4→M5.2 no longer stays visually understated (SPEC2 §2
  "update, don't duplicate").
- aircraft.js: (a) poll() drops its result if the clock scrubbed off-live mid-
  fetch, so late live data never paints over the replay view; (b) re-enabling the
  aircraft layer while scrubbed now restores the replayed frame instead of
  leaving it hidden; (c) transient (non-404) history errors schedule a bounded
  retry so a paused clock doesn't wedge on an error frame.
- satellites.js: ground track centers on the VIEWER clock (not wall-clock now)
  so it stays under a scrubbed satellite; and it hides when the Satellites layer
  is toggled off (no stray dashed line).
- main.js: malformed camera hash with blank tokens (#c=,,,,) is now rejected
  instead of applying a bogus (0,0,0) camera — falls back to the default view.
- serve.py: history t-parse catches OverflowError (t=inf/1e999 → 400, not a
  crashed handler); stale-on-error now also covers upstream 5xx, not just 429.

Verified: t=inf/-inf/1e999/nan → 400; malformed hash → default Gulf camera;
ground track 427 km from the scrubbed dot (was ~2 orbits off) and hidden on
layer-off; aircraft re-enable during replay restores 6062 billboards; zero
console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:16:24 +10:00

206 lines
7.3 KiB
JavaScript

// 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:
`<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, 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' };
}