From c390724bc0dcf6f0f15112c0dcf6e5bb038350a8 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:46:01 -0400 Subject: [PATCH] fix(earthquakes): validate snapshots before replacing entities --- CHANGELOG.md | 4 +++ docs/CURRENT-STATE.md | 4 +++ src/data/earthquakes.js | 51 +++++++++++++++++++++++++---------- src/data/earthquakes.test.mjs | 50 ++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..fc58e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + This changelog records public product changes. For the authoritative description of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861..bfd9683 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +## September 8, 2026 + +Earthquake refreshes validate the complete feed and construct replacement entities before clearing the previous snapshot. Malformed rows and duplicate rendered IDs retain the last good entities, overlays, count and timestamp and report a malformed response; unknown magnitude is excluded from M2.5+ rendering. + Updated: August 24, 2026 ## Installations and map-source guidance diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 18767ad..476b72c 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -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, diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 3e2e91f..cee572e 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -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); + } +});