diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 648ecfec2..ad57a5931 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // engine in their renderers: cesium only in render/cesium*.js, three and // Navara only in render/navara*.js. The list grows with each moved layer. const RENDERED_LAYER_DIRS = [ + 'src/layers/earthquakes/', 'src/layers/flights/', 'src/layers/military/', 'src/layers/localAdsb/', diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5b1a7dfc9..5d32dbea4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1182,5 +1182,7 @@ "src/tooling/w1RuntimeEvidence.test.mjs", "scripts/qa-w2-layers.mjs", "scripts/fixtures/w2/earthquakes.mjs", - "src/tooling/w2QaComparison.test.mjs" + "src/tooling/w2QaComparison.test.mjs", + "src/layers/earthquakes/render/cesium.js", + "src/layers/earthquakes/render/cesium.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f059dda89..430d7f9ab 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1074,9 +1074,26 @@ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", - "src/layers/earthquakes/records.js" + "src/layers/earthquakes/records.js", + "src/geo/boundingSphere.js", + "src/geo/cartesian2.js", + "src/geo/cartesian3.js", + "src/geo/cartographic.js", + "src/geo/check.js", + "src/geo/debug.js", + "src/geo/developerError.js", + "src/geo/ellipsoid.js", + "src/geo/ellipsoidGeodesic.js", + "src/geo/index.js", + "src/geo/math.js", + "src/geo/matrix3.js", + "src/geo/matrix4.js", + "src/geo/quaternion.js", + "src/geo/scaleToGeodeticSurface.js", + "src/geo/transforms.js", + "src/layers/shared/color.js" ], - "external": ["cesium"] + "external": [] }, "recent-imagery": { "exports": ["./layers/recent-imagery"], @@ -2677,7 +2694,8 @@ "src/layers/shared/render/engineView.js", "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", - "src/layers/satellites/render/cesium.js" + "src/layers/satellites/render/cesium.js", + "src/layers/earthquakes/render/cesium.js" ], "external": [ "hls.js", @@ -3269,7 +3287,8 @@ "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", - "src/layers/satellites/render/cesium.js" + "src/layers/satellites/render/cesium.js", + "src/layers/earthquakes/render/cesium.js" ], "external": [ "hls.js", diff --git a/src/app/layers/earthquakes.js b/src/app/layers/earthquakes.js index ae6bc1b63..41b35bb32 100644 --- a/src/app/layers/earthquakes.js +++ b/src/app/layers/earthquakes.js @@ -1,6 +1,16 @@ +import { rendererFor } from './renderers.js'; +import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createEarthquakesLayer } from '../../layers/earthquakes/index.js'; import { overlayHost } from './overlayHost.js'; /** Wire earthquake observations to the application overlay host. */ -export function createApplicationEarthquakes(options) { - return createEarthquakesLayer({ overlayHost, ...options }); +export function createApplicationEarthquakes({ + renderer = { + create: ({ engine = null, ...options }) => + engine + ? rendererFor('earthquakes', engine, options) + : createEarthquakesCesiumRenderer(null, options), + }, + ...options +} = {}) { + return createEarthquakesLayer({ overlayHost, renderer, ...options }); } diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 68b570fb1..3c026f034 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -1,3 +1,4 @@ +import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js'; // Which engine draws which layer: the layer renderer registry (plan, "layer // renderer interface") and the layer availability API built on it. @@ -89,6 +90,7 @@ export const layerAvailableOn = (layerId, engineKind) => /** Cesium renderers extracted from their layers (render/cesium.js). */ export const CESIUM_LAYER_RENDERERS = Object.freeze({ + earthquakes: createEarthquakesCesiumRenderer, 'rocket-launches': createLaunchesCesiumRenderer, satellites: createSatellitesCesiumRenderer, flights: createFlightsCesiumRenderer, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index ac10199df..72a1d2c71 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -56,8 +56,9 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => { } }); -test('the aircraft, vessels, satellites and launches have extracted Cesium renderers; the others still draw themselves', () => { +test('the aircraft, vessels, satellites, launches and earthquakes have extracted Cesium renderers', () => { assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ + 'earthquakes', 'rocket-launches', 'satellites', 'flights', diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 5a014b1f5..49641bed1 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -294,7 +294,7 @@ 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 = ['index', 'model', 'source'].map(name => + const source = ['index', 'model', 'source', 'render/cesium'].map(name => readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n'); assert.doesNotMatch( source, diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index f4fd12bd8..1e0022cee 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -1,4 +1,4 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { EARTHQUAKE_OVERLAY_SOURCE_ID, EARTHQUAKE_OVERLAY_COHORT_LIMIT, @@ -11,14 +11,22 @@ import { export * from './model.js'; export { createUsgsEarthquakeSource } from './source.js'; -/** Own one earthquake display and its refresh lifecycle. */ -export function createEarthquakesLayer({ source, overlayHost } = {}) { +/** + * Own one earthquake display and its refresh lifecycle. + * renderer.create({viewer, engine}) owns drawing and returns: + * setVisible(boolean), replace(discs), records(limit), destroy(). + * Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep + * source order and return plain raw analyst rows (model.mapAnalystRecord). + */ +export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Earthquakes require a snapshot source'); if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); + if (typeof renderer?.create !== 'function') + throw new TypeError('Earthquakes require a renderer factory'); let _viewer = null; let _request = null; - let _dataSource = null; + let _renderer = null; let _count = 0; let _lastUpdate = null; let _lastError = null; @@ -31,12 +39,12 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { source: 'USGS', updateInterval: 60000, - init(viewer) { - if (_viewer) throw new Error('Earthquake layer is already initialized'); + init(viewer, { engine = null } = {}) { + if (_renderer) throw new Error('Earthquake layer is already initialized'); _viewer = viewer; - _dataSource = new Cesium.CustomDataSource('earthquakes'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); + _renderer = renderer.create({ viewer, engine }); + if (!_renderer) throw new Error('Earthquake renderer unavailable'); + _renderer.setVisible(false); _count = 0; _lastUpdate = null; _lastError = null; @@ -49,7 +57,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _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; + _renderer?.setVisible(true); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); }, @@ -57,13 +65,13 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _request?.abort(); _request = null; _enabled = false; - if (_dataSource) _dataSource.show = false; + _renderer?.setVisible(false); overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); }, async update(viewer) { - if (!_enabled || !_dataSource) return false; + if (!_enabled || !_renderer) return false; _request?.abort(); const request = new AbortController(); _request = request; @@ -72,7 +80,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { if (request.signal.aborted || _request !== request || !_enabled) return false; - const nextEntities = []; + const nextDiscs = []; let count = 0; const overlayEntries = []; @@ -93,34 +101,21 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { 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, - }, - }), - ); + const position = geo.cartesian3.fromDegrees(lon, lat); + nextDiscs.push({ + stableId, + usgsId, + position, + baseRadius, + color, + isSignificant, + fillAlpha, + outlineAlpha, + mag, + place, + time, + depthKm, + }); overlayEntries.push( createEarthquakeOverlayEntry({ id: String(stableId), @@ -131,8 +126,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { ); } - _dataSource.entities.removeAll(); - for (const entity of nextEntities) _dataSource.entities.add(entity); + _renderer.replace(nextDiscs); if (_enabled) { overlayHost.setEntries( EARTHQUAKE_OVERLAY_SOURCE_ID, @@ -168,10 +162,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _enabled = false; overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); - if (_dataSource) { - viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - } + _renderer?.destroy(); + _renderer = null; _count = 0; _lastUpdate = null; _lastError = null; @@ -186,39 +178,11 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { * @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 []; + if (!_enabled || !_renderer) 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; + return _renderer.records(limit).map(mapAnalystRecord); }, getStats() { diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index c88063b7b..014a29493 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -1,4 +1,7 @@ -import * as Cesium from 'cesium'; +import { constantRgba } from '../shared/color.js'; +const RED = constantRgba(1, 0, 0); +const ORANGE = constantRgba(1, 165 / 255, 0); +const YELLOW = constantRgba(1, 1, 0); export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; @@ -10,9 +13,9 @@ export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; * - 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; + if (depthKm < 70) return RED; + if (depthKm < 300) return ORANGE; + return YELLOW; } /** @@ -21,7 +24,7 @@ export function depthColor(depthKm) { * 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 {object} 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} diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 9951935de..7d9469157 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -1,3 +1,4 @@ +import { createEarthquakesCesiumRenderer } from './render/cesium.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createEarthquakesLayer } from './index.js'; @@ -17,6 +18,9 @@ function harness(source) { }; const layer = createEarthquakesLayer({ source, + renderer: { + create: ({ viewer }) => createEarthquakesCesiumRenderer(null, { viewer }), + }, overlayHost: { setEntries(...args) { events.push(args); diff --git a/src/layers/earthquakes/render/cesium.js b/src/layers/earthquakes/render/cesium.js new file mode 100644 index 000000000..ff8a683cc --- /dev/null +++ b/src/layers/earthquakes/render/cesium.js @@ -0,0 +1,83 @@ +// Native earthquake entities, moved from the layer without changing its +// static CLAMP_TO_GROUND ellipses, properties or analyst snapshot semantics. +import * as Cesium from 'cesium'; + +export function createEarthquakesCesiumRenderer(_engine, { viewer }) { + if (!viewer?.dataSources) + throw new TypeError('Earthquakes require a native viewer'); + const dataSource = new Cesium.CustomDataSource('earthquakes'); + dataSource.show = false; + viewer.dataSources.add(dataSource); + let destroyed = false; + return { + kind: 'cesium', + setVisible(show) { + if (!destroyed) dataSource.show = show !== false; + }, + replace(discs) { + if (destroyed) return; + const entities = discs.map( + ({ + stableId, + usgsId, + position, + baseRadius, + color, + isSignificant, + fillAlpha, + outlineAlpha, + mag, + place, + time, + depthKm, + }) => + new Cesium.Entity({ + id: `earthquake:${stableId}`, + position, + ellipse: { + 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: { usgsId, mag, place, time, depth: depthKm }, + }), + ); + dataSource.entities.removeAll(); + for (const entity of entities) dataSource.entities.add(entity); + }, + records(limit) { + if (destroyed || !dataSource.show) return []; + const now = Cesium.JulianDate.now(); + const rows = []; + for (const entity of dataSource.entities.values) { + if (rows.length >= limit) break; + const position = entity.position?.getValue(now); + const carto = position + ? Cesium.Cartographic.fromCartesian(position) + : null; + const p = entity.properties; + rows.push({ + 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, + }); + } + return rows; + }, + destroy() { + if (destroyed) return; + destroyed = true; + viewer.dataSources.remove(dataSource, true); + }, + }; +} diff --git a/src/layers/earthquakes/render/cesium.test.mjs b/src/layers/earthquakes/render/cesium.test.mjs new file mode 100644 index 000000000..7bd6c8971 --- /dev/null +++ b/src/layers/earthquakes/render/cesium.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { createEarthquakesLayer } from '../index.js'; +import { createEarthquakesCesiumRenderer } from './cesium.js'; + +const rows = [ + { + stableId: 'a', + usgsId: 'us-a', + lon: -122.42, + lat: 37.77, + depthKm: 8, + mag: 2.5, + place: 'A', + time: 1000, + }, + { + stableId: 'b', + usgsId: 'us-b', + lon: 30, + lat: 20, + depthKm: 70, + mag: 5, + place: 'B', + time: 2000, + }, + { + stableId: 'c', + usgsId: null, + lon: 150, + lat: -30, + depthKm: 300, + mag: 6, + place: 'C', + time: 3000, + }, +]; +function harness() { + const sources = new Set(); + const viewer = { + dataSources: { + add(source) { + sources.add(source); + }, + remove(source, destroy) { + assert.equal(destroy, true); + return sources.delete(source); + }, + }, + }; + let snapshot = rows; + const layer = createEarthquakesLayer({ + source: { + async getSnapshot() { + if (snapshot instanceof Error) throw snapshot; + return snapshot; + }, + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderer: { + create: (options) => createEarthquakesCesiumRenderer(null, options), + }, + }); + layer.init(viewer); + layer.enable(); + return { + viewer, + sources, + layer, + setSnapshot(value) { + snapshot = value; + }, + }; +} + +test('extracted native ground discs preserve static axes, depth palette and analyst identity', async () => { + const h = harness(); + try { + assert.equal(await h.layer.update(), true); + const dataSource = [...h.sources][0]; + const now = Cesium.JulianDate.now(); + const colors = [Cesium.Color.RED, Cesium.Color.ORANGE, Cesium.Color.YELLOW]; + dataSource.entities.values.forEach((entity, index) => { + const row = rows[index]; + const ellipse = entity.ellipse; + assert.equal(entity.id, `earthquake:${row.stableId}`); + assert.equal(ellipse.semiMajorAxis.isConstant, true); + assert.equal(ellipse.semiMinorAxis.getValue(now), 2 ** row.mag * 1000); + assert.equal(ellipse.semiMajorAxis.getValue(now), 2 ** row.mag * 1000); + assert.equal( + ellipse.heightReference.getValue(now), + Cesium.HeightReference.CLAMP_TO_GROUND, + ); + assert.equal(ellipse.outlineWidth.getValue(now), row.mag >= 5 ? 3 : 2); + assert.deepEqual( + { ...ellipse.material.color.getValue(now) }, + { ...colors[index].withAlpha(row.mag >= 5 ? 0.4 : 0.3) }, + ); + const expected = Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + assert.deepEqual(entity.position.getValue(now), expected); + assert.deepEqual( + { ...geo.cartesian3.fromDegrees(row.lon, row.lat) }, + { + x: expected.x, + y: expected.y, + z: expected.z, + }, + ); + }); + const records = h.layer.getAnalystRecords(); + assert.deepEqual( + records.map((r) => r.id), + ['us-a', 'us-b', 'QUAKE-0002'], + ); + assert.equal(h.layer.getAnalystRecords(1).length, 1); + assert.equal(h.layer.getAnalystRecords(0).length, 1); + assert.equal(h.layer.getAnalystRecords(Infinity).length, 3); + h.layer.disable(); + assert.deepEqual(h.layer.getAnalystRecords(), []); + assert.equal(dataSource.show, false); + } finally { + h.layer.destroy(); + } + assert.equal(h.sources.size, 0); +}); + +test('failed native refresh retains the last complete geometry and analyst snapshot', async () => { + const h = harness(); + try { + await h.layer.update(); + const source = [...h.sources][0]; + const before = [...source.entities.values]; + const records = h.layer.getAnalystRecords(); + h.setSnapshot(new Error('fixture source unavailable')); + assert.equal(await h.layer.update(), false); + assert.deepEqual(source.entities.values, before); + assert.deepEqual(h.layer.getAnalystRecords(), records); + assert.equal(h.layer.getStats().error, 'fixture source unavailable'); + h.setSnapshot([]); + assert.equal(await h.layer.update(), true); + assert.equal(source.entities.values.length, 0); + assert.equal(h.layer.getStats().error, null); + } finally { + h.layer.destroy(); + } +}); + +test('renderer destroys its owner even when passed another viewer and repeated destroy is safe', () => { + const h = harness(); + const alien = { + dataSources: { + remove() { + assert.fail('must not remove from another viewer'); + }, + }, + }; + h.layer.destroy(alien); + h.layer.destroy(alien); + assert.equal(h.sources.size, 0); +}); diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 47d3c1dc9..a35908618 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -341,7 +341,7 @@ test('layers moved onto renderers keep each engine in its render file', (t) => { "export const load = () => import('../layers/flights/render/navara.js');", ); // A layer not moved yet still imports cesium anywhere (until its *a task). - write('src/layers/earthquakes/rendering.js', "import 'cesium';"); + write('src/layers/wind/rendering.js', "import 'cesium';"); assert.doesNotThrow(() => checkImportDirections(root)); write('src/layers/flights/tracking.js', "import 'cesium';");