Drop earthquakes older than the USGS feed window and pin duplicate and horizon edge cases

This commit is contained in:
German Khokhlov committed 2026-11-03 19:43:07 +04:00
1 parent 03cd379c0e
commit 736ee8d694
2 files changed
+153

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { isOverlayPointVisible } from '../../overlays/worldOverlay.js';
import {
createEarthquakesLayer,
createEarthquakeOverlayEntry,
normalizeEarthquakeSnapshot,
} from './index.js';
const DAY_MS = 24 * 60 * 60 * 1000;
const GENERATED_MS = Date.UTC(2026, 0, 2, 12);
const feature = (id, lon, lat, mag, time) => ({
id,
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat, 10] },
properties: { mag, place: `Place ${id}`, time },
});
// The USGS all_day feed: one fresh event, one exactly on the window edge
// and one older than the feed's own 24 h window.
const FIXED_SAMPLE = {
type: 'FeatureCollection',
metadata: { generated: GENERATED_MS },
features: [
feature('us-fresh', 10, 20, 4.1, GENERATED_MS - 60_000),
feature('us-edge', 11, 21, 3.2, GENERATED_MS - DAY_MS),
feature('us-expired', 12, 22, 5.5, GENERATED_MS - DAY_MS - 1),
],
};
const WITH_DUPLICATE = {
...FIXED_SAMPLE,
features: [
...FIXED_SAMPLE.features,
feature('us-fresh', 10.5, 20.5, 4.4, GENERATED_MS - 30_000),
],
};
function layerWith(payloads) {
const replaced = [];
const labelSets = [];
const queue = [...payloads];
const layer = createEarthquakesLayer({
source: {
async getSnapshot() {
const rows = normalizeEarthquakeSnapshot(queue.shift());
if (!rows) throw new Error('Malformed USGS response');
return rows;
},
},
overlayHost: {
setEntries(_source, entries) {
labelSets.push(entries.map((entry) => entry.id));
},
setVisible() {},
clearSource() {},
},
renderer: {
create: () => ({
setVisible() {},
replace(discs) {
replaced.push(discs.map((disc) => disc.stableId));
},
destroy() {},
}),
},
});
return { layer, replaced, labelSets };
}
test('edge:earthquakes: an expired record is dropped and a duplicate never forks the ID set', async () => {
assert.deepEqual(
normalizeEarthquakeSnapshot(FIXED_SAMPLE).map((row) => row.stableId),
['us-fresh', 'us-edge'],
);
// A duplicate ID is a malformed feed: it is rejected whole so the last good
// snapshot (one disc per ID) stays on screen with the source error shown.
assert.equal(normalizeEarthquakeSnapshot(WITH_DUPLICATE), null);
const { layer, replaced, labelSets } = layerWith([
FIXED_SAMPLE,
WITH_DUPLICATE,
]);
layer.init(null, { engine: {} });
layer.enable();
assert.equal(await layer.update(), true);
assert.equal(await layer.update(), false);
const ids = ['us-fresh', 'us-edge'];
assert.deepEqual(replaced, [ids]);
assert.deepEqual(labelSets.at(-1).toSorted(), ids.toSorted());
assert.deepEqual(
layer.getAnalystRecords().map((row) => row.id),
ids,
);
assert.equal(layer.getStats().count, 2);
assert.match(layer.getStats().error, /Malformed/);
layer.destroy();
});
test('edge:earthquakes: near the horizon the label of the same ID is kept in front and culled behind', () => {
const engine = createFakeEngine();
// 1000 km over (0, 0) looking east at the limb, which lies ~30.2° away.
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 1_000_000),
orientation: { heading: Math.PI / 2, pitch: -0.52, roll: 0 },
});
const occluder = {
isPointVisible: (position) => engine.projector.horizonVisible(position),
};
const viewport = engine.host.size();
const rows = normalizeEarthquakeSnapshot({
features: [
feature('us-front', 29.5, 0, 4.5, null),
feature('us-behind', 31.5, 0, 4.5, null),
],
});
const visible = rows
.map((row) =>
createEarthquakeOverlayEntry({
id: row.stableId,
position: geo.cartesian3.fromDegrees(row.lon, row.lat),
magnitude: row.mag,
accent: '#f00',
}),
)
.filter((entry) => {
const screen = engine.projector.worldToWindow(entry.position, {});
// Both anchors project inside the viewport; only the horizon separates them.
assert.ok(screen && screen.x > 0 && screen.x < viewport.width);
return isOverlayPointVisible(
entry,
entry.position,
screen,
viewport,
occluder,
);
})
.map((entry) => entry.id);
assert.deepEqual(visible, ['us-front']);
});
+10
View File
@@ -1,6 +1,15 @@
// The layer shows the USGS all_day feed: events within 24 h of its generation.
const FEED_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Validate a complete feed before replacing the last good earthquake snapshot. */ /** Validate a complete feed before replacing the last good earthquake snapshot. */
export function normalizeEarthquakeSnapshot(geojson) { export function normalizeEarthquakeSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null; if (!Array.isArray(geojson?.features)) return null;
// The feed's own clock bounds the window, so a wrong local clock cannot
// empty the layer; a feed without one is taken as published.
const generated = geojson.metadata?.generated;
const oldest = Number.isFinite(generated)
? generated - FEED_WINDOW_MS
: -Infinity;
const rows = []; const rows = [];
const ids = new Set(); const ids = new Set();
for (const [index, feature] of geojson.features.entries()) { for (const [index, feature] of geojson.features.entries()) {
@@ -28,6 +37,7 @@ export function normalizeEarthquakeSnapshot(geojson) {
return null; return null;
// A missing magnitude cannot establish that this event meets M2.5+. // A missing magnitude cannot establish that this event meets M2.5+.
if (mag == null || mag < 2.5) continue; if (mag == null || mag < 2.5) continue;
if (Number.isFinite(properties.time) && properties.time < oldest) continue;
const stableId = const stableId =
feature.id == null || feature.id === '' feature.id == null || feature.id === ''
? `event-${index + 1}` ? `event-${index + 1}`