fix(earthquakes): validate snapshots before replacing entities

This commit is contained in:
onionviolet committed 2026-09-08 17:46:01 -04:00
1 parent 759652207f
commit c390724bc0
4 files changed
+95 -14

No files matched your search

+37 -14
View File
@@ -121,6 +121,34 @@ export function mapAnalystRecord(raw, index = 0) {
};
}
/** 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
|| (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;
}
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
let _dataSource = null;
let _count = 0;
@@ -172,23 +200,17 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
}
const geojson = await response.json();
if (!geojson || !Array.isArray(geojson.features)) {
const rows = normalizeEarthquakeSnapshot(geojson);
if (!rows) {
_lastError = 'Malformed USGS response';
return false;
}
_dataSource.entities.removeAll();
const nextEntities = [];
let count = 0;
const overlayEntries = [];
for (const feature of geojson.features) {
const [lon, lat, depthKm] = feature.geometry.coordinates;
const mag = feature.properties.mag;
const place = feature.properties.place;
const time = feature.properties.time;
if (mag < 2.5) continue; // Skip micro-quakes
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);
@@ -197,8 +219,7 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const position = Cesium.Cartesian3.fromDegrees(lon, lat);
const stableId = feature.id || `event-${count}`;
_dataSource.entities.add({
nextEntities.push(new Cesium.Entity({
id: `earthquake:${stableId}`,
position,
ellipse: {
@@ -216,13 +237,13 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
},
properties: {
// Analyst seam (additive): the USGS event id (e.g. "us7000abcd").
usgsId: feature.id ?? null,
usgsId,
mag,
place,
time,
depth: depthKm,
},
});
}));
overlayEntries.push(createEarthquakeOverlayEntry({
id: String(stableId),
position,
@@ -231,6 +252,8 @@ export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } =
}));
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
if (_enabled) {
overlayHost.setEntries(
EARTHQUAKE_OVERLAY_SOURCE_ID,
+50
View File
@@ -338,3 +338,53 @@ test('earthquake refresh reports failure and clears it only after a successful r
layer.destroy(viewer);
}
});
test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => {
const originalFetch = globalThis.fetch;
const dataSources = [];
const publications = [];
const viewer = { dataSources: {
add(source) { dataSources.push(source); }, remove() { return true; },
} };
const layer = createEarthquakesLayer({ overlayHost: {
setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {},
} });
const good = { id: 'good', geometry: { type: 'Point', coordinates: [10, 20, 5] },
properties: { mag: 4, time: 100, place: 'Fixture' } };
const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
try {
layer.init(viewer);
layer.enable(viewer);
respond([good]);
assert.equal(await layer.update(viewer), true);
const entity = dataSources[0].entities.values[0];
const stats = layer.getStats();
for (const bad of [null, { ...good, geometry: null },
{ ...good, geometry: { type: 'LineString', coordinates: [1, 2] } },
{ ...good, geometry: { coordinates: [181, 20] } },
{ ...good, geometry: { coordinates: [10, -91] } },
{ ...good, geometry: { coordinates: [null, 20] } },
{ ...good, properties: { mag: '4' } },
{ ...good, properties: { mag: Infinity } }, good]) {
respond([good, bad]);
assert.equal(await layer.update(viewer), false);
assert.equal(dataSources[0].entities.values.length, 1);
assert.equal(dataSources[0].entities.values[0], entity);
assert.equal(layer.getStats().count, stats.count);
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
assert.equal(layer.getStats().error, 'Malformed USGS response');
assert.equal(publications.length, 1);
}
respond([{ ...good, properties: { mag: null } }]);
assert.equal(await layer.update(viewer), true);
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal(layer.getStats().error, null);
assert.equal(publications.at(-1)[1].length, 0);
respond([good]);
assert.equal(await layer.update(viewer), true);
assert.equal(dataSources[0].entities.values.length, 1);
} finally {
globalThis.fetch = originalFetch;
layer.destroy(viewer);
}
});