refactor: separate earthquake source and display ownership

This commit is contained in:
Sameh Khamis committed 2026-09-13 12:14:44 -07:00
1 parent 965a44860f
commit d1d938babe
11 files changed
+540 -339

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
import * as Cesium from 'cesium';
import {
EARTHQUAKE_OVERLAY_SOURCE_ID,
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
depthColor,
createEarthquakeOverlayEntry,
selectEarthquakeOverlayCohort,
mapAnalystRecord,
} from './model.js';
export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js';
/** Own one earthquake display and its refresh lifecycle. */
export function createEarthquakesLayer({ source, overlayHost } = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Earthquakes require a snapshot source');
if (!overlayHost) throw new TypeError('Earthquakes require an overlay host');
let _viewer = null;
let _request = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
const layer = {
id: 'earthquakes',
name: 'Earthquakes (24h)',
icon: '🌋',
source: 'USGS',
updateInterval: 60000,
init(viewer) {
if (_viewer) throw new Error('Earthquake layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('earthquakes');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
console.log('[Data:Earthquakes] Initialized');
},
enable(viewer) {
_enabled = true;
// No continuous-render hold: the discs are static geometry now, so the
// layer has no per-frame animator to keep the render loop alive for.
if (_dataSource) _dataSource.show = true;
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
},
disable(viewer) {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
},
async update(viewer) {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
try {
const rows = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
const nextEntities = [];
let count = 0;
const overlayEntries = [];
for (const {
stableId,
usgsId,
lon,
lat,
depthKm,
mag,
place,
time,
} of rows) {
count++;
const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0);
const isSignificant = mag >= 5.0;
const fillAlpha = isSignificant ? 0.4 : 0.3;
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
nextEntities.push(
new Cesium.Entity({
id: `earthquake:${stableId}`,
position,
ellipse: {
// Static axes — see the module header. A CallbackProperty here
// re-tessellates the clamped ground geometry every frame.
semiMajorAxis: baseRadius,
semiMinorAxis: baseRadius,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(fillAlpha),
),
outline: true,
outlineColor: color.withAlpha(outlineAlpha),
outlineWidth: isSignificant ? 3 : 2,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
},
properties: {
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
usgsId,
mag,
place,
time,
depth: depthKm,
},
}),
);
overlayEntries.push(
createEarthquakeOverlayEntry({
id: String(stableId),
position,
magnitude: mag,
accent: color.toCssColorString(),
}),
);
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
selectEarthquakeOverlayCohort(overlayEntries),
{
cohortLimit: EARTHQUAKE_OVERLAY_COHORT_LIMIT,
collisionCapacity: EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
moving: false,
},
);
}
_count = count;
_lastUpdate = Date.now();
_lastError = null;
console.log(`[Data:Earthquakes] Updated: ${_count} events (M2.5+)`);
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:Earthquakes] Fetch error:', e);
_lastError = e?.message || 'Earthquake source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
_viewer = null;
_enabled = false;
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
/**
* Snapshot the layer's in-memory earthquake records as plain JSON-safe
* objects for the analyst query engine. On-demand only (called at most
* once per spoken query) — zero per-frame cost, no listeners, no caching.
* Returns [] while the layer is disabled or empty.
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const entities = _dataSource.entities.values;
if (!entities.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = Cesium.JulianDate.now();
const result = [];
for (const entity of entities) {
if (result.length >= limit) break;
const cartesian = entity.position
? entity.position.getValue(now)
: null;
const carto = cartesian
? Cesium.Cartographic.fromCartesian(cartesian)
: null;
const p = entity.properties;
result.push(
mapAnalystRecord(
{
id: p?.usgsId?.getValue(now) ?? null,
mag: p?.mag?.getValue(now),
place: p?.place?.getValue(now),
time: p?.time?.getValue(now),
depth: p?.depth?.getValue(now),
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
},
result.length,
),
);
}
return result;
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
+150
View File
@@ -0,0 +1,150 @@
import * as Cesium from 'cesium';
export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
/**
* Color by depth:
* - Shallow (<70km): Red
* - Intermediate (70-300km): Orange
* - Deep (>300km): Yellow
*/
export function depthColor(depthKm) {
if (depthKm < 70) return Cesium.Color.RED;
if (depthKm < 300) return Cesium.Color.ORANGE;
return Cesium.Color.YELLOW;
}
/**
* Build the source-owned presentation for one ambient magnitude label.
* Magnitude formatting deliberately remains here instead of moving into the
* shared renderer.
* @param {object} input
* @param {string} input.id Stable USGS or deterministic fallback id.
* @param {Cesium.Cartesian3} input.position Ground anchor shared with the pulse.
* @param {number} input.magnitude USGS magnitude.
* @param {string} input.accent Source-owned depth-band color.
* @returns {object}
*/
export function createEarthquakeOverlayEntry({
id,
position,
magnitude,
accent,
}) {
const mag = Number(magnitude);
return {
id: String(id),
position,
variant: 'label',
title: `M${mag.toFixed(1)}`,
accent,
priority: Math.round(mag * 1000),
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
/** Keep the largest events, with stable identity as the tie-break. */
export function selectEarthquakeOverlayCohort(
entries,
limit = EARTHQUAKE_OVERLAY_COHORT_LIMIT,
) {
const cap = Math.max(
0,
Math.min(EARTHQUAKE_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(entries) || cap === 0) return [];
return entries
.slice()
.sort(
(a, b) =>
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
)
.slice(0, cap);
}
/**
* Map one earthquake's raw plain values to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Falls back to an index-based id
* when the USGS event id is absent.
* @param {Object|null|undefined} raw - Plain values pulled off the entity:
* {id, mag, place, time, depth, lat, lon}.
* @param {number} [index=0] - Position in the snapshot (fallback id only).
* @returns {{id: string, magnitude: number|null, depthKm: number|null,
* lat: number|null, lon: number|null, timeMs: number|null, place: string|null}}
*/
export function mapAnalystRecord(raw, index = 0) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
return {
id: text(raw?.id) || `QUAKE-${String(index).padStart(4, '0')}`,
magnitude: num(raw?.mag),
depthKm: num(raw?.depth),
lat: num(raw?.lat),
lon: num(raw?.lon),
timeMs: num(raw?.time), // USGS epoch ms
place: text(raw?.place),
};
}
/** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const [index, feature] of geojson.features.entries()) {
const coordinates = feature?.geometry?.coordinates;
const properties = feature?.properties;
if (
!Array.isArray(coordinates) ||
coordinates.length < 2 ||
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties) ||
(feature.geometry.type != null && feature.geometry.type !== 'Point')
)
return null;
const [lon, lat, depthKm] = coordinates;
const mag = properties.mag;
if (
!Number.isFinite(lon) ||
Math.abs(lon) > 180 ||
!Number.isFinite(lat) ||
Math.abs(lat) > 90 ||
(depthKm != null && !Number.isFinite(depthKm)) ||
(mag != null && (!Number.isFinite(mag) || mag > 10))
)
return null;
// A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue;
const stableId =
feature.id == null || feature.id === ''
? `event-${index + 1}`
: String(feature.id);
if (ids.has(stableId)) return null;
ids.add(stableId);
rows.push({
stableId,
usgsId: feature.id ?? null,
lon,
lat,
depthKm: depthKm ?? null,
mag,
place: typeof properties.place === 'string' ? properties.place : null,
time: Number.isFinite(properties.time) ? properties.time : null,
});
}
return rows;
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEarthquakesLayer } from './index.js';
import { createUsgsEarthquakeSource } from './source.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createEarthquakesLayer({
source,
overlayHost: {
setEntries(...args) {
events.push(args);
},
setVisible() {},
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
}
const row = {
stableId: 'event-a',
usgsId: 'event-a',
lon: 30,
lat: 20,
depthKm: 3,
mag: 4,
place: 'Fixture',
time: 1000,
};
test('late refresh cannot publish after disable, re-enable, or destroy', async () => {
for (const action of ['disable', 'destroy']) {
let resolve, signal;
const h = harness({
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.events.length, 0);
h.layer.destroy(h.viewer);
}
});
test('two displays own separate data sources and destruction', async () => {
const a = harness({ getSnapshot: async () => [row] });
const b = harness({ getSnapshot: async () => [] });
await a.layer.update(a.viewer);
await b.layer.update(b.viewer);
assert.equal(a.layer.getStats().count, 1);
assert.equal(b.layer.getStats().count, 0);
a.layer.destroy();
assert.equal(a.sources.length, 0);
assert.equal(b.sources.length, 1);
b.layer.destroy();
});
test('USGS body completion honors cancellation even with an uncooperative transport', async () => {
const abort = new AbortController();
const source = createUsgsEarthquakeSource({
fetchImpl: async () => ({
ok: true,
json: async () => {
abort.abort();
return { features: [] };
},
}),
});
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
name: 'AbortError',
});
});
+20
View File
@@ -0,0 +1,20 @@
import { normalizeEarthquakeSnapshot } from './model.js';
const API_URL =
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
/** Request and validate a complete USGS snapshot before it can replace displayed events. */
export function createUsgsEarthquakeSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(API_URL, { signal });
if (!response.ok) throw new Error(`USGS HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
const rows = normalizeEarthquakeSnapshot(payload);
if (!rows) throw new Error('Malformed USGS response');
return rows;
},
};
}