diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index b7d5f622f..57d1a385d 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -38,10 +38,13 @@ const RENDERED_LAYER_DIRS = [ 'src/layers/military/', 'src/layers/localAdsb/', 'src/layers/vessels/', + 'src/layers/satellites/', 'src/layers/aircraft/', 'src/layers/shared/', ]; const renderedLayer = (file) => + file === 'src/celestialRing.js' || + file.startsWith('src/celestialRing/') || RENDERED_LAYER_DIRS.some((dir) => file.startsWith(dir)); const cesiumLayerRenderer = (file) => /\/render\/cesium[^/]*\.js$/.test(file); const navaraLayerRenderer = (file) => /\/render\/navara[^/]*\.js$/.test(file); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0a2a652db..6f7e48557 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1138,5 +1138,10 @@ "scripts/qa-flights-engines.mjs", "src/bench/w1Feeds.js", "scripts/qa-map-switch.mjs", - "scripts/qa-engine-notice.mjs" + "scripts/qa-engine-notice.mjs", + "src/bench/satelliteFixture.js", + "src/bench/satelliteFixture.test.mjs", + "src/layers/satellites/render/cesium.js", + "src/celestialRing/render/cesium.js", + "scripts/qa-satellites.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4679199b2..7ac715c1d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1146,7 +1146,8 @@ "src/geo/check.js", "src/geo/debug.js", "src/geo/developerError.js", - "src/time/clock.js" + "src/time/clock.js", + "src/layers/shared/color.js" ], "external": ["cesium", "satellite.js"] }, @@ -2608,7 +2609,9 @@ "src/layers/vessels/render/cesium.js", "src/layers/localAdsb/render/navara.js", "src/layers/vessels/render/navara.js", - "src/layers/shared/render/navaraView.js" + "src/layers/shared/render/navaraView.js", + "src/celestialRing/render/cesium.js", + "src/layers/satellites/render/cesium.js" ], "external": [ "hls.js", @@ -3188,7 +3191,9 @@ "src/layers/military/render/cesium.js", "src/layers/aircraft/qaDrawn.js", "src/layers/localAdsb/render/cesium.js", - "src/layers/vessels/render/cesium.js" + "src/layers/vessels/render/cesium.js", + "src/celestialRing/render/cesium.js", + "src/layers/satellites/render/cesium.js" ], "external": [ "hls.js", @@ -3753,7 +3758,8 @@ "src/geo/debug.js", "src/geo/developerError.js", "src/time/clock.js", - "src/globe/surfaceDetail.js" + "src/globe/surfaceDetail.js", + "src/celestialRing/render/cesium.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index 25f5bd6df..925d6da19 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -8,6 +8,7 @@ // military:synthetic[:count] readsb rows, `count` aircraft (1 000); the // first 25 are also synthetic civil flights // vessels:fixture 12 000 AIS rows, or those within an area +// satellites:fixture 839 core TLEs + 6000 dense points // local-adsb:fixture the local receivers' answer (server format, // /api/local-receivers/aircraft): 40 aircraft // near JFK, 1090 MHz and 978 MHz UAT @@ -23,12 +24,15 @@ import { syntheticMilitary, syntheticVessels, } from '../../bench/w1Feeds.js'; +import { satelliteFixture } from '../../bench/satelliteFixture.js'; +import { createSatelliteSource } from '../../layers/satellites/source.js'; import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; const KINDS = Object.freeze({ flights: new Set(['synthetic']), military: new Set(['synthetic']), vessels: new Set(['fixture']), + satellites: new Set(['fixture']), 'local-adsb': new Set(['fixture']), }); @@ -44,7 +48,7 @@ export function parseQaFeed(search = '') { (count !== null && !(Number.isInteger(count) && count > 0)) ) throw new TypeError( - `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, local-adsb:fixture)`, + `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, satellites:fixture, local-adsb:fixture)`, ); return { layer, kind, count }; }); @@ -60,6 +64,11 @@ const json = (payload) => export function createQaSources(entries, { now = () => Date.now() } = {}) { const sources = {}; for (const { layer, count } of entries) { + if (layer === 'satellites') + sources.satellites = createSatelliteSource({ + fetchImpl: async (url) => + new Response(satelliteFixture(String(url).split('/').at(-1))), + }); if (layer === 'flights') sources.flights = createFlightSource({ now, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 9d9c8a5c3..247ffd293 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -27,6 +27,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js'; import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js'; import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js'; +import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js'; import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -87,6 +88,7 @@ export const layerAvailableOn = (layerId, engineKind) => /** Cesium renderers extracted from their layers (render/cesium.js). */ export const CESIUM_LAYER_RENDERERS = Object.freeze({ + satellites: createSatellitesCesiumRenderer, flights: createFlightsCesiumRenderer, military: createMilitaryCesiumRenderer, 'local-adsb': createLocalAdsbCesiumRenderer, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index e8529c734..9d1f3d3e7 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 layers and vessels have extracted Cesium renderers; the others still draw themselves', () => { +test('the aircraft, vessels and satellites have extracted Cesium renderers; the others still draw themselves', () => { assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [ + 'satellites', 'flights', 'military', 'local-adsb', @@ -78,7 +79,7 @@ test('the aircraft layers and vessels have extracted Cesium renderers; the other const military = rendererFor('military', { kind: 'cesium' }, { viewer }); assert.equal(military.kind, 'cesium'); assert.equal(military.viewer, viewer); - assert.equal(rendererFor('satellites', { kind: 'cesium' }), null); + assert.equal(rendererFor('satellites', { kind: 'cesium' }).kind, 'cesium'); }); test('the layer availability API: engine, isAvailable, available, unavailable', () => { diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js index 10eccb39f..109029d0a 100644 --- a/src/app/layers/satellites.js +++ b/src/app/layers/satellites.js @@ -1,3 +1,5 @@ +import { rendererFor } from './renderers.js'; +import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js'; import { createSatellitesLayer } from '../../layers/satellites/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as focus from '../../data/focusDeemphasis.js'; @@ -8,10 +10,19 @@ import * as render from '../../renderGovernor.js'; import * as layerState from '../../data/layerState.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationSatellites({ source }) { +export function createApplicationSatellites({ + source, + renderer = { + create: ({ engine = null, ...options }) => + engine + ? rendererFor('satellites', engine, options) + : createSatellitesCesiumRenderer(null, options), + }, +}) { return createSatellitesLayer({ source, services: { + renderer, picking, focus, readout, diff --git a/src/bench/satelliteFixture.js b/src/bench/satelliteFixture.js new file mode 100644 index 000000000..7e46f3572 --- /dev/null +++ b/src/bench/satelliteFixture.js @@ -0,0 +1,50 @@ +// Deterministic TLE catalogs for phase 3 browser QA; no live feed requests. +import { W1_FROZEN_MS } from './w1Feeds.js'; + +const checksum = (line) => { + const body = line.padEnd(68).slice(0, 68); + let sum = 0; + for (const c of body) sum += /[0-9]/.test(c) ? Number(c) : c === '-' ? 1 : 0; + return body + (sum % 10); +}; +const tle = (id, name, index, inclination, motion) => { + const number = String(id).padStart(5, '0'); + const angle = (value) => (value % 360).toFixed(4).padStart(8); + return [ + name, + checksum( + `1 ${number}U 26001A 26289.50000000 .00000000 00000-0 00000-0 0 999`, + ), + checksum( + `2 ${number} ${angle(inclination)} ${angle(index * 137.508)} 0003000 ${angle(0)} ${angle(index * 47.3)} ${motion.toFixed(8).padStart(11)}00001`, + ), + ].join('\n'); +}; +const GROUPS = { + stations: [25544, 1, 51.64, 15.5, 'ISS (ZARYA)'], + visual: [30000, 720, 53, 15.2, 'QA VISUAL'], + 'gps-ops': [40000, 32, 55, 2.0056, 'QA GPS'], + 'glo-ops': [41000, 24, 64.8, 2.131, 'QA GLONASS'], + galileo: [42000, 30, 56, 1.7047, 'QA GALILEO'], + geo: [45000, 32, 0.1, 1.0027, 'QA GEO'], + starlink: [50000, 6000, 53, 15.2, 'QA STARLINK'], +}; +export const SATELLITE_FIXTURE_EPOCH = W1_FROZEN_MS; +export const SATELLITE_FIXTURE_CORE_COUNT = 839; +export const SATELLITE_FIXTURE_DENSE_COUNT = 6839; +export function satelliteFixture(group) { + const spec = GROUPS[group]; + if (!spec) throw new TypeError('Unknown fixture group'); + const [first, count, inclination, motion, prefix] = spec; + return ( + Array.from({ length: count }, (_, i) => + tle( + first + i, + count === 1 ? prefix : `${prefix} ${i + 1}`, + i, + inclination, + motion, + ), + ).join('\n') + '\n' + ); +} diff --git a/src/bench/satelliteFixture.test.mjs b/src/bench/satelliteFixture.test.mjs new file mode 100644 index 000000000..d70c3b965 --- /dev/null +++ b/src/bench/satelliteFixture.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { twoline2satrec, propagate } from 'satellite.js'; +import { + satelliteFixture, + SATELLITE_FIXTURE_EPOCH, +} from './satelliteFixture.js'; + +test('satellite fixture has distinct, valid SGP4 catalogs including LEO, MEO and GEO', () => { + const ids = new Set(); + const counts = { + stations: 1, + visual: 720, + 'gps-ops': 32, + 'glo-ops': 24, + galileo: 30, + geo: 32, + starlink: 6000, + }; + for (const [group, count] of Object.entries(counts)) { + const lines = satelliteFixture(group).trim().split('\n'); + assert.equal(lines.length, count * 3); + for (let i = 0; i < lines.length; i += 3) { + for (const line of [lines[i + 1], lines[i + 2]]) { + assert.equal(line.length, 69); + const sum = [...line.slice(0, 68)].reduce( + (n, c) => n + (/\d/.test(c) ? Number(c) : c === '-' ? 1 : 0), + 0, + ); + assert.equal(Number(line[68]), sum % 10); + } + const rec = twoline2satrec(lines[i + 1], lines[i + 2]); + assert.equal(rec.error, 0); + assert.ok(!ids.has(rec.satnum)); + ids.add(rec.satnum); + const position = propagate( + rec, + new Date(SATELLITE_FIXTURE_EPOCH), + ).position; + assert.ok(position && Number.isFinite(position.x)); + const radius = Math.hypot(position.x, position.y, position.z); + if (group === 'geo') assert.ok(radius > 41000 && radius < 43000); + if (group === 'gps-ops') assert.ok(radius > 26000 && radius < 27000); + } + } +}); diff --git a/src/celestialRing.js b/src/celestialRing.js index 0d368605d..93c8e5e91 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,4 +1,10 @@ -import * as Cesium from 'cesium'; +import * as geo from './geo/index.js'; +import { engineFromViewer } from './globe/cesium/engineFromViewer.js'; +import { + createCelestialEphemeris, + projectCelestialPoint, + flyCelestialViewer, +} from './celestialRing/render/cesium.js'; import { appClock } from './time/clock.js'; import { governorRequestRender } from './renderGovernor.js'; @@ -32,7 +38,7 @@ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; -const MARKER_COLLISION_ANGLE = Cesium.Math.toRadians(6); +const MARKER_COLLISION_ANGLE = geo.math.toRadians(6); const FULL_GLOBE_RADIUS_RATIO = 0.61; const TAU = Math.PI * 2; // This overlay is a secondary HUD treatment. Keep it smooth while reserving @@ -212,7 +218,7 @@ export function isFullGlobeInsideKeyhole(geometry, wasVisible = false) { /** Return the Earth-disc radius in CSS pixels for a perspective camera. */ export function earthDiscScreenRadius(cameraDistance, viewportHeight, fovy) { - const earthRadiusM = Cesium.Ellipsoid.WGS84.maximumRadius; + const earthRadiusM = geo.ellipsoid.WGS84.maximumRadius; if ( !Number.isFinite(cameraDistance) || cameraDistance <= earthRadiusM || @@ -250,24 +256,23 @@ export function projectEarthDiscToViewport( const scene = viewer?.scene; if (!camera || !scene || !(width > 0) || !(height > 0)) return null; - const distance = Cesium.Cartesian3.magnitude(camera.positionWC); + const distance = geo.cartesian3.magnitude(camera.positionWC); if ( !Number.isFinite(distance) || - distance <= Cesium.Ellipsoid.WGS84.maximumRadius + distance <= geo.ellipsoid.WGS84.maximumRadius ) return null; - const toCenter = Cesium.Cartesian3.negate( + const toCenter = geo.cartesian3.negate( camera.positionWC, - scratchToCenter || new Cesium.Cartesian3(), + scratchToCenter || geo.cartesian3.create(), ); - if (Cesium.Cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; + if (geo.cartesian3.dot(camera.directionWC, toCenter) <= 0) return null; - const center = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - Cesium.Cartesian3.ZERO, - scratchCenter, - ); + const projector = engineFromViewer(viewer)?.projector; + const center = projector + ? projector.worldToWindow(geo.cartesian3.ZERO, scratchCenter) + : projectCelestialPoint(scene, geo.cartesian3.ZERO, scratchCenter); if (!center || !Number.isFinite(center.x) || !Number.isFinite(center.y)) return null; @@ -408,13 +413,11 @@ export class CelestialRing { this._moonRenderKey = ''; this._outlineRenderKey = ''; - this._sunInertial = new Cesium.Cartesian3(); - this._moonInertial = new Cesium.Cartesian3(); - this._sunFixed = new Cesium.Cartesian3(); - this._moonFixed = new Cesium.Cartesian3(); - this._toCenter = new Cesium.Cartesian3(); - this._screenCenter = new Cesium.Cartesian2(); - this._fixedMatrix = new Cesium.Matrix3(); + this._sunFixed = geo.cartesian3.create(); + this._moonFixed = geo.cartesian3.create(); + this._toCenter = geo.cartesian3.create(); + this._screenCenter = geo.cartesian2.create(); + this._ephemeris = createCelestialEphemeris(); this._buildDOM(); this._removePostRender = viewer.scene.postRender.addEventListener(() => @@ -531,7 +534,7 @@ export class CelestialRing { return false; } - const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; + const earthRadius = geo.ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry( canvas.clientWidth || canvas.width, height, @@ -548,19 +551,18 @@ export class CelestialRing { this._focusInProgress = false; this.viewer.scene.requestRender?.(); }; - this.viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians( + flyCelestialViewer(this.viewer, { + destination: geo.cartesian3.fromRadians( cartographic.longitude, cartographic.latitude, altitude, ), orientation: { heading: this.viewer.camera.heading, - pitch: -Cesium.Math.PI_OVER_TWO, + pitch: -geo.math.PI_OVER_TWO, roll: 0, }, duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, complete: finishFocus, cancel: () => { finishFocus(); @@ -622,27 +624,7 @@ export class CelestialRing { _updateEphemeris(time) { if (!this._ephemerisDirty) return true; - Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( - time, - this._sunInertial, - ); - Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( - time, - this._moonInertial, - ); - const matrix = - Cesium.Transforms.computeIcrfToFixedMatrix(time, this._fixedMatrix) || - Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, this._fixedMatrix); - if (!matrix) return false; - - Cesium.Matrix3.multiplyByVector(matrix, this._sunInertial, this._sunFixed); - Cesium.Matrix3.multiplyByVector( - matrix, - this._moonInertial, - this._moonFixed, - ); - Cesium.Cartesian3.normalize(this._sunFixed, this._sunFixed); - Cesium.Cartesian3.normalize(this._moonFixed, this._moonFixed); + if (!this._ephemeris(time, this._sunFixed, this._moonFixed)) return false; this._ephemerisDirty = false; this._ephemerisUpdateCount += 1; return true; @@ -755,18 +737,18 @@ export class CelestialRing { // The sun and moon at the app clock's now (the wall clock unless QA // moves it), as Navara's sun. - const time = Cesium.JulianDate.fromDate(new Date(appClock.now())); + const time = appClock.now(); if (!this._updateEphemeris(time)) return; this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount); const camera = this.viewer.camera; const sunProjection = celestialScreenAngle( - Cesium.Cartesian3.dot(this._sunFixed, camera.rightWC), - Cesium.Cartesian3.dot(this._sunFixed, camera.upWC), + geo.cartesian3.dot(this._sunFixed, camera.rightWC), + geo.cartesian3.dot(this._sunFixed, camera.upWC), this._sunAngle, ); const moonProjection = celestialScreenAngle( - Cesium.Cartesian3.dot(this._moonFixed, camera.rightWC), - Cesium.Cartesian3.dot(this._moonFixed, camera.upWC), + geo.cartesian3.dot(this._moonFixed, camera.rightWC), + geo.cartesian3.dot(this._moonFixed, camera.upWC), this._moonAngle, ); if (sunProjection.stable) this._sunAngle = sunProjection.angle; diff --git a/src/celestialRing/render/cesium.js b/src/celestialRing/render/cesium.js new file mode 100644 index 000000000..d4ff8b432 --- /dev/null +++ b/src/celestialRing/render/cesium.js @@ -0,0 +1,34 @@ +// Exact legacy planetary model and bare-viewer fallback for CelestialRing. +import * as Cesium from 'cesium'; +export function createCelestialEphemeris() { + const sunInertial = new Cesium.Cartesian3(); + const moonInertial = new Cesium.Cartesian3(); + const fixedMatrix = new Cesium.Matrix3(); + return (epochMs, sunFixed, moonFixed) => { + const time = Cesium.JulianDate.fromDate(new Date(epochMs)); + Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame( + time, + sunInertial, + ); + Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame( + time, + moonInertial, + ); + const matrix = + Cesium.Transforms.computeIcrfToFixedMatrix(time, fixedMatrix) || + Cesium.Transforms.computeTemeToPseudoFixedMatrix(time, fixedMatrix); + if (!matrix) return false; + Cesium.Matrix3.multiplyByVector(matrix, sunInertial, sunFixed); + Cesium.Matrix3.multiplyByVector(matrix, moonInertial, moonFixed); + Cesium.Cartesian3.normalize(sunFixed, sunFixed); + Cesium.Cartesian3.normalize(moonFixed, moonFixed); + return true; + }; +} +export const projectCelestialPoint = (scene, position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, result); +export const flyCelestialViewer = (viewer, options) => + viewer.camera.flyTo({ + ...options, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index e3faf3b05..2efca8806 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -127,7 +127,7 @@ test('every scene click handler consults ownership before it picks', () => { ['src/layers/launches/lifecycle.js', 'drillPick(movement.position'], ['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'], [ - 'src/layers/satellites/interaction.js', + 'src/layers/satellites/render/cesium.js', 'viewer.scene.pick(click.position)', ], [ diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..061e09919 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,4 +1,6 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { nearFarScale } from '../shared/color.js'; import { twoline2satrec } from 'satellite.js'; import { DENSE_REFRESH_FRAMES, @@ -31,7 +33,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(appClock.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -42,7 +44,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { if (!sat || !point) continue; const pos = parts.orbits.propagatePosition(sat.satrec, now); if (pos) { - point.position = Cesium.Cartesian3.fromDegrees( + point.position = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -96,7 +98,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(appClock.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { @@ -124,7 +126,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { group: 'dense', }); const point = layerState._pointCollection.add({ - position: Cesium.Cartesian3.fromDegrees( + position: geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -133,7 +135,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { color: style.color, outlineColor: style.outlineColor, outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6), id: noradId, }); layerState._points.set(noradId, point); diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 9101668a3..4479e6ced 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import { tallySatelliteClasses, satelliteClassLabel, @@ -189,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(appClock.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js index 16e1de512..b3c508381 100644 --- a/src/layers/satellites/index.js +++ b/src/layers/satellites/index.js @@ -17,7 +17,15 @@ export function createSatellitesLayer({ services, source }) { throw new TypeError('A satellites source is required'); const state = createState({ services }); const parts = {}; - const context = { state, services, parts, source }; + const getRenderer = () => { + if (!state._renderer || state._renderer.view !== state._viewer) + state._renderer = services.renderer.create({ + viewer: state._viewer, + engine: state._engine, + }); + return state._renderer; + }; + const context = { state, services, parts, source, getRenderer }; parts.controls = createControls(context); parts.catalog = createCatalog(context); parts.labels = createLabels(context); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..5f5c4f1db 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,5 +1,7 @@ import { twoline2satrec } from 'satellite.js'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { nearFarScale } from '../shared/color.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -7,6 +9,7 @@ export function createIngestion({ services, parts, source, + getRenderer, }) { const methods = { async update(viewer, { signal = null } = {}) { @@ -78,7 +81,7 @@ export function createIngestion({ layerState._pointCollection.removeAll(); layerState._points.clear(); for (const path of layerState._orbitPaths.values()) - viewer.scene.primitives.remove(path.primitive); + getRenderer().removeOrbit(path.primitive); layerState._orbitPaths.clear(); layerState._catalog.clear(); // The detection overlay caches one record per satellite and stamps its @@ -93,7 +96,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(appClock.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; @@ -123,7 +126,7 @@ export function createIngestion({ const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; - const cartesian = Cesium.Cartesian3.fromDegrees( + const cartesian = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -137,7 +140,7 @@ export function createIngestion({ color: style.color, outlineColor: style.outlineColor, outlineWidth: style.outlineWidth, - scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6), + scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6), id: noradId, }); diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js index 7ef8816c6..e293da20b 100644 --- a/src/layers/satellites/interaction.js +++ b/src/layers/satellites/interaction.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; export function createInteraction({ @@ -6,6 +5,7 @@ export function createInteraction({ services, parts, source, + getRenderer, }) { const { resolvePickId, isOwnedByOtherLayer } = services.picking; @@ -25,63 +25,64 @@ export function createInteraction({ // new owner controls it). Guarded so our OWN switch (viewer.trackedEntity // briefly undefined mid-_trackSatellite) doesn't self-clear. if (!layerState._trackedEntityChangedRemove) { - layerState._trackedEntityChangedRemove = - viewer.trackedEntityChanged.addEventListener(() => { + layerState._trackedEntityChangedRemove = getRenderer().follow.onChanged( + () => { if (!layerState._enabled) return; if ( layerState._trackedNorad && layerState._viewer && - layerState._viewer.trackedEntity && - layerState._viewer.trackedEntity !== layerState._trackedEntity + getRenderer().follow.current() && + getRenderer().follow.current() !== layerState._trackedEntity ) { parts.tracking._clearTracking(true, { origin: - layerState._viewer.trackedEntity?.gevSelectionOrigin || + getRenderer().follow.current()?.gevSelectionOrigin || 'programmatic', }); } - }); + }, + ); } - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { - // A tool owns the pointer (src/data/inputOwnership.js): yield the click. - if (!isPointerFree()) return; - if (!layerState._enabled) return; - const picked = viewer.scene.pick(click.position); + layerState._clickHandler = getRenderer().listenClick( + ({ picked }) => { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; + if (!layerState._enabled) return; - if (picked) { - // Clicking tracked entity itself — ignore - if (picked.id === layerState._trackedEntity) return; - - // Check if it's a satellite point (id is NORAD catalog number) - const prim = picked.primitive; - if (prim && prim.id != null) { - const noradId = Number(prim.id); - if (!isNaN(noradId) && layerState._catalog.has(noradId)) { - parts.tracking._cancelPendingTrackingRestore(); - parts.tracking._trackSatellite(noradId, { origin: 'user' }); + if (picked) { + // Clicking tracked entity itself — ignore + if (getRenderer().isTrackedPick(picked, layerState._trackedEntity)) return; + + // Check if it's a satellite point (id is NORAD catalog number) + const prim = picked.primitive; + if (prim && prim.id != null) { + const noradId = getRenderer().pickedNorad(picked); + if (!isNaN(noradId) && layerState._catalog.has(noradId)) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._trackSatellite(noradId, { origin: 'user' }); + return; + } } } - } - // A pick that belongs to a sibling layer (plane, vessel, station, CCTV - // camera…) is not "empty space" — leave OUR tracking (and crucially - // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). - if (picked) { - const pickedId = resolvePickId(picked); - if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; - } + // A pick that belongs to a sibling layer (plane, vessel, station, CCTV + // camera…) is not "empty space" — leave OUR tracking (and crucially + // viewer.trackedEntity, which that sibling may have JUST set) alone (H2). + if (picked) { + const pickedId = resolvePickId(picked); + if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return; + } - // Clicked empty space — deselect - if (layerState._trackedNorad) { - parts.tracking._cancelPendingTrackingRestore(); - parts.tracking._clearTracking(false, { origin: 'user' }); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + // Clicked empty space — deselect + if (layerState._trackedNorad) { + parts.tracking._cancelPendingTrackingRestore(); + parts.tracking._clearTracking(false, { origin: 'user' }); + } + }, + { enabled: () => layerState._enabled }, + ); document.addEventListener('keydown', _onKeyDown); } diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 7c0d36670..16ed95e51 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; export function createLifecycle({ @@ -6,6 +5,7 @@ export function createLifecycle({ services, parts, source, + getRenderer, }) { const { clearFocusTarget } = services.focus; const { holdContinuousRender, releaseContinuousRender } = services.render; @@ -14,10 +14,13 @@ export function createLifecycle({ const methods = { // Catalog data refresh; propagation remains preRender-owned. - async init(viewer) { + async init(viewer, { engine = null } = {}) { parts.catalog._abortActiveUpdates(); clearFocusTarget('satellites'); - layerState._viewer = viewer; + layerState._engine = engine; + layerState._renderer?.destroy(); + layerState._renderer = services.renderer.create({ viewer, engine }); + layerState._viewer = layerState._renderer.view; layerState._catalog = new Map(); layerState._points = new Map(); layerState._detectionObjects = new Map(); @@ -46,14 +49,13 @@ export function createLifecycle({ layerState._catalogRevision++; // Point primitives for satellite dots - layerState._pointCollection = new Cesium.PointPrimitiveCollection(); - viewer.scene.primitives.add(layerState._pointCollection); + layerState._pointCollection = getRenderer().createPoints(); parts.interaction._installClickHandler(viewer); // Pre-render listener for real-time position updates // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) - layerState._preRenderListener = viewer.scene.preRender.addEventListener( + layerState._preRenderListener = getRenderer().onFrame( parts.rendering._preRenderTick, ); @@ -84,8 +86,8 @@ export function createLifecycle({ const norad = Number(pickedId); return Number.isFinite(norad) && layerState._points.has(norad); }); - if (!layerState._preRenderListener && viewer) { - layerState._preRenderListener = viewer.scene.preRender.addEventListener( + if (!layerState._preRenderListener && layerState._viewer) { + layerState._preRenderListener = getRenderer().onFrame( parts.rendering._preRenderTick, ); } @@ -142,12 +144,12 @@ export function createLifecycle({ layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); if (layerState._pointCollection) { - viewer.scene.primitives.remove(layerState._pointCollection); + getRenderer().removePoints(layerState._pointCollection); layerState._pointCollection = null; } // Orbit ring primitives are removed (and destroyed) here for (const path of layerState._orbitPaths.values()) { - viewer.scene.primitives.remove(path.primitive); + getRenderer().removeOrbit(path.primitive); } layerState._points.clear(); layerState._detectionObjects.clear(); @@ -171,6 +173,9 @@ export function createLifecycle({ status: 'destroyed', failedGroups: [], }; + layerState._renderer?.destroy(); + layerState._renderer = null; + layerState._engine = null; layerState._viewer = null; }, }; diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 4d97dbed8..6464ea036 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { gstime, @@ -133,7 +134,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = appClock.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -163,7 +164,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = appClock.now(), }, ) { const id = Number(noradId); @@ -280,7 +281,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(appClock.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..301edb998 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { WHITE, rgbaFromCss } from '../shared/color.js'; import { satelliteClassColor } from '../../data/satelliteClass.js'; /** @@ -71,7 +72,7 @@ export const DENSE_CREATE_CHUNK = 1500; * High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame. */ -export const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3( +export const TRACK_VIEW_FROM_LEO = geo.cartesian3.create( -450000, -450000, 350000, @@ -94,10 +95,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +export const POINT_OUTLINE = WHITE.withAlpha(0.3); -export const _classColor = (group) => - Cesium.Color.fromCssColorString(satelliteClassColor(group)); +export const _classColor = (group) => rgbaFromCss(satelliteClassColor(group)); export const POINT_STYLES = { // The ISS keeps its own long-standing red hero styling rather than the @@ -106,7 +106,7 @@ export const POINT_STYLES = { // Its card still reads "STATION · ISS", so the class stays legible. iss: { pixelSize: 12, - color: Cesium.Color.fromCssColorString('#ff4444'), + color: rgbaFromCss('#ff4444'), outlineColor: POINT_OUTLINE, outlineWidth: 2, }, diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index c3fd9b470..44a58a09f 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -1,3 +1,4 @@ +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { ISS_NORAD } from './policy.js'; @@ -77,7 +78,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(appClock.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/render/cesium.js b/src/layers/satellites/render/cesium.js new file mode 100644 index 000000000..217069620 --- /dev/null +++ b/src/layers/satellites/render/cesium.js @@ -0,0 +1,95 @@ +// Satellite drawing and native follow, extracted unchanged from the layer. +import * as Cesium from 'cesium'; +import { isPointerFree } from '../../../data/inputOwnership.js'; + +export function createSatellitesCesiumRenderer(engine, { viewer } = {}) { + return { + kind: 'cesium', + engine, + view: viewer, + createPoints() { + const points = new Cesium.PointPrimitiveCollection(); + viewer.scene.primitives.add(points); + return points; + }, + removePoints(points) { + viewer?.scene.primitives.remove(points); + }, + onFrame: (listener) => viewer?.scene.preRender.addEventListener(listener), + project: (position, result) => + Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + result, + ), + createOrbit({ positions, width, color }) { + const primitive = new Cesium.Primitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry: new Cesium.PolylineGeometry({ + positions, + width, + vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, + }), + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + color.withAlpha(0.6), + ), + depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( + color.withAlpha(0.35), + ), + }, + }), + appearance: new Cesium.PolylineColorAppearance({ translucent: true }), + depthFailAppearance: new Cesium.PolylineColorAppearance({ + translucent: true, + }), + asynchronous: false, + allowPicking: false, + }); + viewer.scene.primitives.add(primitive); + return primitive; + }, + commitOrbit() {}, + removeOrbit: (primitive) => viewer?.scene.primitives.remove(primitive), + createTracked({ position, viewFrom }) { + return viewer.entities.add({ + position: new Cesium.CallbackProperty(position, false), + viewFrom, + point: { + pixelSize: 14, + color: Cesium.Color.YELLOW, + outlineColor: Cesium.Color.WHITE, + outlineWidth: 2, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + }, + removeTracked: (entity) => viewer?.entities.remove(entity), + follow: { + start: (entity) => { + viewer.trackedEntity = entity; + }, + release: () => { + if (viewer) viewer.trackedEntity = undefined; + }, + current: () => viewer?.trackedEntity, + onChanged: (listener) => + viewer.trackedEntityChanged.addEventListener(listener), + }, + listenClick(listener, { enabled = () => true } = {}) { + const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); + handler.setInputAction((click) => { + if (!isPointerFree()) return; + if (!enabled()) return; + listener({ + picked: viewer.scene.pick(click.position), + position: click.position, + }); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + return { destroy: () => handler.destroy() }; + }, + pickedNorad: (picked) => Number(picked?.primitive?.id), + isTrackedPick: (picked, entity) => Boolean(entity && picked?.id === entity), + destroy() {}, + }; +} diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 08e4396bc..ed435d0ee 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -1,4 +1,6 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; +import { CYAN } from '../shared/color.js'; import { gstime } from 'satellite.js'; import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; @@ -7,6 +9,7 @@ export function createRendering({ services, parts, source, + getRenderer, }) { const { focusNowMs, @@ -42,36 +45,17 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(appClock.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; - const pathColor = color || Cesium.Color.CYAN; + const pathColor = color || CYAN; - const primitive = new Cesium.Primitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry: new Cesium.PolylineGeometry({ - positions: basePositions, - width: noradId === ISS_NORAD ? 2.5 : 2.0, - vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: Cesium.ColorGeometryInstanceAttribute.fromColor( - pathColor.withAlpha(0.6), - ), - depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor( - pathColor.withAlpha(0.35), - ), - }, - }), - appearance: new Cesium.PolylineColorAppearance({ translucent: true }), - depthFailAppearance: new Cesium.PolylineColorAppearance({ - translucent: true, - }), - asynchronous: false, // build this frame — no async-rebuild blink window - allowPicking: false, // ring clicks fall through to satellites/deselect + const primitive = getRenderer().createOrbit({ + positions: basePositions, + width: noradId === ISS_NORAD ? 2.5 : 2.0, + color: pathColor, }); - layerState._viewer.scene.primitives.add(primitive); layerState._orbitPaths.set(noradId, { primitive, @@ -120,6 +104,7 @@ export function createRendering({ nowDate, path.primitive.modelMatrix, ); + getRenderer().commitOrbit(path.primitive); } } @@ -130,8 +115,7 @@ export function createRendering({ function _hideOrbitPath(noradId) { const path = layerState._orbitPaths.get(noradId); if (path) { - if (layerState._viewer) - layerState._viewer.scene.primitives.remove(path.primitive); // remove() destroys + if (layerState._viewer) getRenderer().removeOrbit(path.primitive); // remove() destroys layerState._orbitPaths.delete(noradId); } } @@ -143,7 +127,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(appClock.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -151,7 +135,7 @@ export function createRendering({ const pos = parts.orbits.propagatePosition(sat.satrec, now); if (!pos) continue; - const cartesian = Cesium.Cartesian3.fromDegrees( + const cartesian = geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -211,7 +195,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(appClock.now())); layerState._lastRingRotation = now; } } @@ -236,13 +220,9 @@ export function createRendering({ previousActiveCount: layerState._activeFocusCount, nowMs, screenPositionFor: (position) => - Cesium.SceneTransforms.worldToWindowCoordinates( - scene, - position, - layerState._scratchFocusScreen, - ), + getRenderer().project(position, layerState._scratchFocusScreen), cameraDistanceFor: (position) => - Cesium.Cartesian3.distance(camera.positionWC, position), + geo.cartesian3.distance(camera.positionWC, position), baseColorFor: (noradId) => parts.controls._pointStyleFor( noradId, diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index f2ce08d9e..30f484969 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -45,6 +45,8 @@ export function createState({ services }) { state._denseLoadController = null; state._viewer = null; + state._renderer = null; + state._engine = null; state._preRenderListener = null; diff --git a/src/layers/satellites/testing.js b/src/layers/satellites/testing.js index e0f394a50..a58c48643 100644 --- a/src/layers/satellites/testing.js +++ b/src/layers/satellites/testing.js @@ -1,7 +1,13 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js'; -export function createTesting({ state: layerState, services, parts, source }) { +export function createTesting({ + state: layerState, + services, + parts, + source, + getRenderer, +}) { /** * Seed a single tracked satellite so tests can invoke the exact production * pre-render callback without constructing a WebGL viewer. @@ -27,6 +33,8 @@ export function createTesting({ state: layerState, services, parts, source }) { // control satellite that must NOT be treated as part of it). neighbours = [], }) { + layerState._renderer = null; + layerState._engine = null; layerState._viewer = viewer; layerState._catalog = new Map([ [noradId, { name, satrec, group: 'stations' }], @@ -168,6 +176,8 @@ export function createTesting({ state: layerState, services, parts, source }) { overlayHost, preservePending = false, }) { + layerState._renderer = null; + layerState._engine = null; layerState._viewer = viewer; layerState._catalog = new Map([ [ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }], @@ -177,7 +187,7 @@ export function createTesting({ state: layerState, services, parts, source }) { [ ISS_NORAD, { - primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, + primitive: { show: true, modelMatrix: geo.matrix4.create() }, gmstAtBake: 0, }, ], diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 485466aa2..306329dc3 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,4 +1,5 @@ -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { constantRgba } from '../shared/color.js'; import { appClock } from '../../time/clock.js'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { @@ -9,7 +10,15 @@ import { TRACK_VIEW_FROM_LEO, } from './policy.js'; -export function createTracking({ state: layerState, services, parts, source }) { +const YELLOW = constantRgba(1, 1, 0); + +export function createTracking({ + state: layerState, + services, + parts, + source, + getRenderer, +}) { const { clearFocusTarget, publishFocusTargetFromCachedPosition } = services.focus; const { @@ -111,9 +120,9 @@ export function createTracking({ state: layerState, services, parts, source }) { parts.rendering._hideOrbitPath(layerState._trackedNorad); } if (layerState._viewer && !skipViewerUntrack) - layerState._viewer.trackedEntity = undefined; + getRenderer().follow.release(); if (layerState._trackedEntity) { - layerState._viewer.entities.remove(layerState._trackedEntity); + getRenderer().removeTracked(layerState._trackedEntity); layerState._trackedEntity = null; } layerState._trackedNorad = null; @@ -159,7 +168,7 @@ export function createTracking({ state: layerState, services, parts, source }) { ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; - Cesium.Cartesian3.fromDegrees( + geo.cartesian3.fromDegrees( pos.longitude, pos.latitude, pos.altitude, @@ -410,15 +419,15 @@ export function createTracking({ state: layerState, services, parts, source }) { point.show = false; // Show orbital path - parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW); + parts.rendering._showOrbitPath(noradId, YELLOW); // Tracked entity position propagates per evaluation through the per-frame // cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame. // Falls back to the point primitive's 1s-throttled position if SGP4 fails. - const positionProperty = new Cesium.CallbackProperty(() => { + const position = () => { const pos = _getTrackedFramePosition(); return pos ? layerState._trackedFrameCartesian : point.position; - }, false); + }; const name = sat.name.trim(); @@ -433,22 +442,15 @@ export function createTracking({ state: layerState, services, parts, source }) { initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE : 1; - const viewFrom = Cesium.Cartesian3.multiplyByScalar( + const viewFrom = geo.cartesian3.multiplyByScalar( TRACK_VIEW_FROM_LEO, viewScale, - new Cesium.Cartesian3(), + geo.cartesian3.create(), ); - layerState._trackedEntity = layerState._viewer.entities.add({ - position: positionProperty, + layerState._trackedEntity = getRenderer().createTracked({ + position, viewFrom, - point: { - pixelSize: 14, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, }); layerState._trackedEntity.gevSelectionOrigin = origin; layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`; @@ -459,13 +461,13 @@ export function createTracking({ state: layerState, services, parts, source }) { layerId: 'satellites', id: noradId, label: name, - position: Cesium.Cartesian3.clone(point.position), + position: geo.cartesian3.clone(point.position), origin, }); selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); layerState._contextRefreshedAtMs = Date.now(); - layerState._viewer.trackedEntity = layerState._trackedEntity; + getRenderer().follow.start(layerState._trackedEntity); console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); } return { diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 9a45fc8c8..c36b4f56c 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -311,7 +311,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/satellites/rendering.js', "import 'cesium';"); + write('src/layers/earthquakes/rendering.js', "import 'cesium';"); assert.doesNotThrow(() => checkImportDirections(root)); write('src/layers/flights/tracking.js', "import 'cesium';");