import * as Cesium from 'cesium'; 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; } /** * 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} 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;