mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34: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
+16
-336
@@ -1,342 +1,22 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createEarthquakesLayer as createLayer,
|
||||
createUsgsEarthquakeSource,
|
||||
} from '../layers/earthquakes/index.js';
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* USGS earthquake discs — last 24 hours, M2.5+.
|
||||
*
|
||||
* Ellipse axes are STATIC (plain numbers), redefined only when a poll brings
|
||||
* new data. They must never become a `CallbackProperty` again: every entity
|
||||
* here is a `CLAMP_TO_GROUND` ellipse, and a per-frame axis re-tessellates its
|
||||
* ground primitive on EVERY frame. Measured on the shipped 58-event feed
|
||||
* (2026-08-20 QA hunt, parked camera over SF at 40 km):
|
||||
*
|
||||
* 58 discs, callback axes → 32.4 ms/frame, 30 fps
|
||||
* 58 discs, static axes → 1.4 ms/frame, 60 fps
|
||||
*
|
||||
* The former ±15 % radius "pulse" was DROPPED to buy that back: an
|
||||
* imperceptible breathing wobble on a translucent ground disc is not worth
|
||||
* rebuilding 58 ground primitives 60 times a second. With no per-frame
|
||||
* animator left, the layer also no longer holds the render governor
|
||||
* continuous — the manager's `layer-tick` / `layer-visibility` requests
|
||||
* already cover every discrete mutation this layer makes.
|
||||
*/
|
||||
|
||||
const API_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson';
|
||||
|
||||
export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
|
||||
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
|
||||
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
|
||||
|
||||
const DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
/**
|
||||
* Color by depth:
|
||||
* - Shallow (<70km): Red
|
||||
* - Intermediate (70-300km): Orange
|
||||
* - Deep (>300km): Yellow
|
||||
*/
|
||||
function depthColor(depthKm) {
|
||||
if (depthKm < 70) return Cesium.Color.RED;
|
||||
if (depthKm < 300) return Cesium.Color.ORANGE;
|
||||
return Cesium.Color.YELLOW;
|
||||
export * from '../layers/earthquakes/index.js';
|
||||
/** Wire the standalone source and application overlay owner. */
|
||||
export function createEarthquakesLayer({
|
||||
source = createUsgsEarthquakeSource(),
|
||||
overlayHost = {
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
},
|
||||
} = {}) {
|
||||
return createLayer({ source, overlayHost });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function createEarthquakesLayer({ overlayHost = DEFAULT_OVERLAY_HOST } = {}) {
|
||||
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) {
|
||||
_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) {
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
async update(viewer) {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
_lastError = `USGS HTTP ${response.status}`;
|
||||
console.warn(`[Data:Earthquakes] API returned ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const geojson = await response.json();
|
||||
const rows = normalizeEarthquakeSnapshot(geojson);
|
||||
if (!rows) {
|
||||
_lastError = 'Malformed USGS response';
|
||||
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) {
|
||||
console.warn('[Data:Earthquakes] Fetch error:', e);
|
||||
_lastError = 'USGS network error';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
_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;
|
||||
}
|
||||
|
||||
const earthquakesLayer = createEarthquakesLayer();
|
||||
|
||||
export default earthquakesLayer;
|
||||
export default createEarthquakesLayer();
|
||||
@@ -294,7 +294,8 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
|
||||
});
|
||||
|
||||
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
|
||||
const source = readFileSync(new URL('./earthquakes.js', import.meta.url), 'utf8');
|
||||
const source = ['index', 'model', 'source'].map(name =>
|
||||
readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/new Cesium\.CallbackProperty/,
|
||||
|
||||
Reference in new issue
Block a user