refactor(earthquakes): extract native renderer with identical ground-disc output

Keep refresh, cancellation and label policy engine-neutral; inject the original native entities at composition. Prove static geometry, analyst records, owner disposal and last-good refresh behavior. Native dev and QA-preview pass 18 checks each; all eight baseline PNGs remain byte-identical.
This commit is contained in:
German Khokhlov committed 2026-10-27 10:56:41 +04:00
1 parent e8870a4766
commit c89185f352
13 files changed
+343 -92

No files matched your search

+1
View File
@@ -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/',
+3 -1
View File
@@ -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"
]
+23 -4
View File
@@ -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",
+12 -2
View File
@@ -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 });
}
+2
View File
@@ -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,
+2 -1
View File
@@ -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',
+1 -1
View File
@@ -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,
+41 -77
View File
@@ -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<Object>} 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() {
+8 -5
View File
@@ -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}
@@ -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);
+83
View File
@@ -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);
},
};
}
@@ -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);
});
+1 -1
View File
@@ -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';");