mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
refactor: separate earthquake source and display ownership
This commit is contained in:
1 parent
965a44860f
commit
d1d938babe
11 files changed
+540
-339
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user