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 1/6] 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 21544fa46..fc58e8fd8 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 1f4d861f4..bfd9683ae 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 18767adb2..476b72c18 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 3e2e91fe1..cee572ee3 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); + } +}); From 9e92ab6f9bbf262ef7f2b48fc98cbbc0cc792c4a Mon Sep 17 00:00:00 2001 From: Kushagra Kumar Date: Wed, 9 Sep 2026 15:43:58 +0530 Subject: [PATCH 2/6] fix(a11y): add accessible names to hud sliders and inputs Add explicit aria-label attributes to unlabelled interactive controls in index.html and dynamic parameter sliders in src/ui.js, fulfilling WCAG 2.1 Success Criterion 4.1.2 (Name, Role, Value). - Add aria-label to #scope-feather-slider, #bloom-intensity-slider, #sharpen-intensity-slider, #location-search, #scene-import-file, and first-run suppression checkbox. - Set aria-label to uniform label on dynamic style parameter sliders in ui.js. - Add unit test suite (src/hudA11y.test.mjs) verifying accessible names across all inputs in index.html and dynamic slider construction. Signed-off-by: Kushagra Kumar --- index.html | 12 +++++----- src/hudA11y.test.mjs | 52 ++++++++++++++++++++++++++++++++++++++++++++ src/ui.js | 1 + 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src/hudA11y.test.mjs diff --git a/index.html b/index.html index e8b0b8ddd..449801b04 100644 --- a/index.html +++ b/index.html @@ -440,7 +440,7 @@ mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather softens the black mask edge; label fading is the separate Fade slider. --> - + 11% @@ -460,7 +460,7 @@ Bloom
- + 0%
@@ -470,7 +470,7 @@ Sharpen
- + 49%
@@ -565,7 +565,7 @@ - + @@ -671,7 +671,7 @@ - +
0%
@@ -872,7 +872,7 @@