Merge branch 'main' into motion/vessels-frame-cadence

This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 23:37:10 -08:00
commit c2fa15b205
12 files changed
+471 -60

No files matched your search

+44 -32
View File
@@ -1,11 +1,14 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { gstime, twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import { reckonEarthFixed, sampleOrbit } from './motion.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
DENSE_SAMPLES_PER_FRAME_MAX,
POINT_STYLES,
DENSE_CREATE_CHUNK,
ORBIT_SAMPLE_HORIZON_MS,
} from './policy.js';
export function createCatalog({ state: layerState, services, parts, source }) {
@@ -18,37 +21,42 @@ export function createCatalog({ state: layerState, services, parts, source }) {
}
/**
* Re-propagate a small per-frame slice of the dense extras (round-robin).
* Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES
* frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays
* ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the
* work per frame avoids the once-per-second spike a tick-sized chunk
* (~2K props ≈ 4ms) would cause.
* Sample a small per-frame slice of the dense extras (round-robin); every
* point is then reckoned from its sample each frame (rendering.js
* _reckonPoints). At normal speed the full dense set is sampled once every
* ~DENSE_REFRESH_FRAMES frames (~5s at 60fps), so per-frame cost stays ~35
* propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the work
* per frame avoids the once-per-second spike a tick-sized chunk (~2K props
* ≈ 4ms) would cause. While time plays fast, or after a seek, the slice
* grows so each sample stays within ORBIT_SAMPLE_HORIZON_MS of the world
* time, up to DENSE_SAMPLES_PER_FRAME_MAX a frame.
* @param {number} worldStepMs How far the world time moved since the last
* frame (NaN on the first).
*/
function _propagateDenseChunk() {
if (layerState._denseIds.length === 0) return;
const perFrame = Math.max(
1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
function _propagateDenseChunk(worldStepMs) {
const count = layerState._denseIds.length;
if (count === 0) return;
const share = Math.max(
1 / DENSE_REFRESH_FRAMES,
Number.isFinite(worldStepMs)
? Math.abs(worldStepMs) / ORBIT_SAMPLE_HORIZON_MS
: 1,
);
const now = new Date(layerState.now());
const perFrame = Math.min(
count,
DENSE_SAMPLES_PER_FRAME_MAX,
Math.max(1, Math.ceil(count * share)),
);
const nowMs = layerState.now();
for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0;
if (layerState._denseCursor >= count) layerState._denseCursor = 0;
const noradId = layerState._denseIds[layerState._denseCursor++];
if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it
const sat = layerState._catalog.get(noradId);
const point = layerState._points.get(noradId);
if (!sat || !point) continue;
const pos = parts.orbits.propagatePosition(sat.satrec, now);
if (pos) {
point.position = Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
}
if (!sat) continue;
const sample = sampleOrbit(sat.satrec, nowMs, sat.orbitSample);
if (sample) sat.orbitSample = sample;
}
}
@@ -97,7 +105,8 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense;
const now = new Date(layerState.now());
const nowMs = layerState.now();
const gmst = gstime(new Date(nowMs));
let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
@@ -116,8 +125,9 @@ export function createCatalog({ state: layerState, services, parts, source }) {
if (!satrec || satrec.error !== 0) continue;
const noradId = noradIdFromSatnum(satrec.satnum);
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
// Its first sample places it now and moves it from then on.
const orbitSample = sampleOrbit(satrec, nowMs);
if (!orbitSample) continue;
layerState._catalog.set(noradId, {
name: entry.name,
@@ -131,12 +141,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
ucs: null,
satcat: null,
sensor: null,
orbitSample,
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
position: reckonEarthFixed(
orbitSample,
nowMs,
gmst,
new Cesium.Cartesian3(),
),
pixelSize: style.pixelSize,
color: style.color,
+80
View File
@@ -0,0 +1,80 @@
import { propagate } from 'satellite.js';
/** The Earth's gravitational parameter, km³/s², as SGP4's WGS72 constants. */
const MU_KM3_S2 = 398600.8;
/** Floats in one orbit sample: its time, then position, velocity,
* acceleration and jerk. */
export const ORBIT_SAMPLE_LENGTH = 13;
/**
* One SGP4 state of a satellite for dead reckoning: its inertial (TEME)
* position and velocity at `atMs`, with the two-body acceleration and its
* rate of change (jerk) there.
* Layout: [atMs, rx, ry, rz (km), vx, vy, vz (km/s), ax, ay, az (km/s²),
* jx, jy, jz (km/s³)].
* @param {object} satrec
* @param {number} atMs Epoch milliseconds.
* @param {Float64Array} [out]
* @returns {Float64Array|null} Null when SGP4 has no state for that time.
*/
export function sampleOrbit(
satrec,
atMs,
out = new Float64Array(ORBIT_SAMPLE_LENGTH),
) {
let state;
try {
state = propagate(satrec, new Date(atMs));
} catch {
return null;
}
const p = state?.position;
const v = state?.velocity;
if (!p || typeof p === 'boolean' || !v || typeof v === 'boolean') return null;
const r = Math.hypot(p.x, p.y, p.z);
if (!(r > 0) || !Number.isFinite(v.x + v.y + v.z)) return null;
const k = -MU_KM3_S2 / (r * r * r);
// d/dt of -μ r/|r|³: -μ (v/|r|³ - 3 (r·v) r/|r|⁵).
const radial = (3 * (p.x * v.x + p.y * v.y + p.z * v.z)) / (r * r);
out[0] = atMs;
out[1] = p.x;
out[2] = p.y;
out[3] = p.z;
out[4] = v.x;
out[5] = v.y;
out[6] = v.z;
out[7] = k * p.x;
out[8] = k * p.y;
out[9] = k * p.z;
out[10] = k * (v.x - radial * p.x);
out[11] = k * (v.y - radial * p.y);
out[12] = k * (v.z - radial * p.z);
return out;
}
/**
* Where a satellite is at `atMs`, reckoned from its sample along the inertial
* path (position, velocity, gravity and its change) and turned into the
* Earth-fixed frame by the Greenwich sidereal angle at that time. Over half a
* minute of world time the reckoned point stays within metres of SGP4.
* @param {Float64Array} sample From sampleOrbit.
* @param {number} atMs Epoch milliseconds.
* @param {number} gmst Greenwich mean sidereal angle at `atMs`, radians.
* @param {{x: number, y: number, z: number}} out Receives Earth-fixed metres.
* @returns {{x: number, y: number, z: number}} `out`.
*/
export function reckonEarthFixed(sample, atMs, gmst, out) {
const dt = (atMs - sample[0]) / 1000;
const half = 0.5 * dt * dt;
const sixth = (half * dt) / 3;
const x = sample[1] + sample[4] * dt + sample[7] * half + sample[10] * sixth;
const y = sample[2] + sample[5] * dt + sample[8] * half + sample[11] * sixth;
const z = sample[3] + sample[6] * dt + sample[9] * half + sample[12] * sixth;
const cos = Math.cos(gmst);
const sin = Math.sin(gmst);
out.x = (x * cos + y * sin) * 1000;
out.y = (-x * sin + y * cos) * 1000;
out.z = z * 1000;
return out;
}
+86
View File
@@ -0,0 +1,86 @@
// src/layers/satellites/motion.test.mjs — a satellite's point reckoned from
// one SGP4 sample stays on SGP4's track between samples.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime, twoline2satrec } from 'satellite.js';
import { createOrbits } from './orbits.js';
import { reckonEarthFixed, sampleOrbit } from './motion.js';
const ISS = twoline2satrec(
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
);
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
/** Where the layer has always drawn a satellite: SGP4, through geodetic. */
function drawnBySgp4(atMs) {
const pos = orbits.propagatePosition(ISS, new Date(atMs));
return Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
}
function reckoned(sample, atMs) {
return reckonEarthFixed(
sample,
atMs,
gstime(new Date(atMs)),
new Cesium.Cartesian3(),
);
}
test('at its own time the reckoned point is where SGP4 draws it', () => {
const sample = sampleOrbit(ISS, AT_MS);
const gap = Cesium.Cartesian3.distance(
reckoned(sample, AT_MS),
drawnBySgp4(AT_MS),
);
assert.ok(gap < 1, `${gap} m`);
});
test('between samples the point keeps to the SGP4 track', () => {
const sample = sampleOrbit(ISS, AT_MS);
for (const [seconds, withinM] of [
[0.016, 1],
[1, 2],
[10, 2],
[30, 10],
[60, 50],
]) {
const atMs = AT_MS + seconds * 1000;
const gap = Cesium.Cartesian3.distance(
reckoned(sample, atMs),
drawnBySgp4(atMs),
);
assert.ok(gap < withinM, `${seconds} s: ${gap} m`);
}
});
test('the point moves every frame, at orbital speed', () => {
const sample = sampleOrbit(ISS, AT_MS);
const a = reckoned(sample, AT_MS + 500);
const b = reckoned(sample, AT_MS + 516);
const speed = Cesium.Cartesian3.distance(a, b) / 0.016;
// ISS ground-relative speed: about 7.2–7.7 km/s.
assert.ok(speed > 7000 && speed < 8000, `${speed} m/s`);
});
test('reckoning backwards in time works as forwards', () => {
const sample = sampleOrbit(ISS, AT_MS);
const atMs = AT_MS - 30_000;
const gap = Cesium.Cartesian3.distance(
reckoned(sample, atMs),
drawnBySgp4(atMs),
);
assert.ok(gap < 10, `${gap} m`);
});
test('an element set SGP4 cannot propagate has no sample', () => {
const decayed = { ...ISS, error: 0 };
decayed.ecco = 2; // not an orbit
assert.equal(sampleOrbit(decayed, AT_MS), null);
});
+17 -1
View File
@@ -31,7 +31,16 @@ export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({
export const ORBIT_PATH_STEPS = 180;
// points per orbital path
/** How often every core satellite takes a fresh SGP4 sample (wall ms). */
export const POSITION_UPDATE_MS = 1000;
/**
* Between SGP4 samples every point is reckoned each frame from its last one
* (motion.js). A sample this far from the world time is taken again, so a
* point is never more than about 10 m off SGP4 however fast time plays.
*/
export const ORBIT_SAMPLE_HORIZON_MS = 30_000;
/** Beyond this from its sample a point is left where it is until sampled again. */
export const ORBIT_RECKON_LIMIT_MS = 120_000;
/** How far either side of now SGP4 on current element sets stays meaningful. */
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
@@ -41,7 +50,6 @@ export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
* drifts a few degrees a day.
*/
export const ORBIT_REBAKE_MS = 30 * 60_000;
// re-propagate every 1s (SGP4 is smooth at this rate)
export const RING_ROTATION_MS = 1000;
// re-align baked orbit rings to current GMST every 1s
@@ -80,6 +88,14 @@ export const DENSE_GROUP_PATH = 'starlink';
export const DENSE_REFRESH_FRAMES = 300;
// full dense pass spread over ~300 frames (~5s @ 60fps)
/**
* The most dense extras sampled in one frame while time plays fast (~4 ms).
* Past about 10 minutes of world time a second the full dense set no longer
* fits within ORBIT_RECKON_LIMIT_MS, and its points move only as they are
* sampled.
*/
export const DENSE_SAMPLES_PER_FRAME_MAX = 2000;
export const DENSE_CREATE_CHUNK = 1500;
// satrec builds per macro-task while loading
+157
View File
@@ -0,0 +1,157 @@
// src/layers/satellites/reckon.test.mjs — every frame each shown point moves
// to where its satellite is at the world time; samples are taken again when
// time has moved far from them, and dense extras are sampled faster the
// faster time plays.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { createCatalog } from './catalog.js';
import { createOrbits } from './orbits.js';
import { createRendering } from './rendering.js';
import { sampleOrbit } from './motion.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_SAMPLES_PER_FRAME_MAX,
ORBIT_RECKON_LIMIT_MS,
ORBIT_SAMPLE_HORIZON_MS,
} from './policy.js';
const ISS = twoline2satrec(
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
);
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
/** A point primitive that counts position writes. */
function point(show = true) {
let position = new Cesium.Cartesian3();
return {
show,
writes: 0,
get position() {
return position;
},
set position(value) {
this.writes++;
position = Cesium.Cartesian3.clone(value);
},
};
}
function scene(entries, { tracked = null } = {}) {
const state = {
_points: new Map(),
_catalog: new Map(),
_trackedNorad: tracked,
_denseIds: [],
_denseCursor: 0,
};
for (const [id, entry] of Object.entries(entries)) {
state._points.set(id, entry.point);
state._catalog.set(id, { satrec: ISS, group: 'core', ...entry.sat });
}
const rendering = createRendering({
state,
services: { focus: {} },
parts: {},
});
return { state, rendering };
}
function sgp4At(atMs) {
const pos = orbits.propagatePosition(ISS, new Date(atMs));
return Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
}
test('a shown point moves to its satellite at the world time; hidden and tracked ones do not', () => {
const shown = point();
const hidden = point(false);
const tracked = point();
const { rendering } = scene(
{
shown: { point: shown },
hidden: { point: hidden },
tracked: { point: tracked },
},
{ tracked: 'tracked' },
);
rendering._reckonPoints(AT_MS);
assert.ok(Cesium.Cartesian3.distance(shown.position, sgp4At(AT_MS)) < 1);
assert.equal(hidden.writes, 0);
assert.equal(tracked.writes, 0);
// The same time again (the clock paused): nothing is written.
rendering._reckonPoints(AT_MS);
assert.equal(shown.writes, 1);
// Shown while paused, a hidden point moves at once.
hidden.show = true;
rendering._reckonPoints(AT_MS);
assert.equal(hidden.writes, 1);
});
test('between samples the point keeps to the track; far from its sample, a core satellite is sampled again', () => {
const p = point();
const { state, rendering } = scene({ iss: { point: p } });
rendering._reckonPoints(AT_MS);
const sample = state._catalog.get('iss').orbitSample;
assert.equal(sample[0], AT_MS);
// Within the horizon the same sample moves it on.
const soon = AT_MS + ORBIT_SAMPLE_HORIZON_MS / 2;
rendering._reckonPoints(soon);
assert.equal(state._catalog.get('iss').orbitSample[0], AT_MS);
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(soon)) < 5);
// A seek an hour back samples it again there.
const back = AT_MS - 3_600_000;
rendering._reckonPoints(back);
assert.equal(state._catalog.get('iss').orbitSample[0], back);
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(back)) < 1);
});
test('a dense extra is left for its round-robin, and stays put once too far from its sample', () => {
const p = point();
const { rendering } = scene({
starlink: {
point: p,
sat: { group: 'dense', orbitSample: sampleOrbit(ISS, AT_MS) },
},
});
rendering._reckonPoints(AT_MS + ORBIT_SAMPLE_HORIZON_MS * 2);
assert.equal(p.writes, 1, 'reckoned from its own sample');
rendering._reckonPoints(AT_MS + ORBIT_RECKON_LIMIT_MS + 1);
assert.equal(p.writes, 1, 'too far: left where it is');
});
test('the dense slice grows with how fast the world time moves, up to its cap', () => {
const sampled = (count, worldStepMs) => {
const state = {
_denseIds: [],
_denseCursor: 0,
_catalog: new Map(),
_trackedNorad: null,
now: () => AT_MS,
};
for (let i = 0; i < count; i++) {
state._denseIds.push(String(i));
state._catalog.set(String(i), { satrec: ISS, group: 'dense' });
}
createCatalog({
state,
services: {},
parts: {},
source: {},
})._propagateDenseChunk(worldStepMs);
return [...state._catalog.values()].filter((sat) => sat.orbitSample).length;
};
// Real time: the set over DENSE_REFRESH_FRAMES frames.
assert.equal(sampled(3000, 16), Math.ceil(3000 / DENSE_REFRESH_FRAMES));
// A tenth of the horizon a frame: a tenth of the set.
assert.equal(sampled(3000, ORBIT_SAMPLE_HORIZON_MS / 10), 300);
// A seek, or the first frame: as many as the cap allows.
assert.equal(sampled(3000, Number.NaN), DENSE_SAMPLES_PER_FRAME_MAX);
assert.equal(sampled(1000, -ORBIT_SAMPLE_HORIZON_MS * 5), 1000);
});
+67 -22
View File
@@ -1,12 +1,18 @@
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { reckonEarthFixed, sampleOrbit } from './motion.js';
import {
ISS_NORAD,
ORBIT_REBAKE_MS,
ORBIT_RECKON_LIMIT_MS,
ORBIT_SAMPLE_HORIZON_MS,
POSITION_UPDATE_MS,
RING_ROTATION_MS,
} from './policy.js';
/** Scratch for a point's reckoned position. */
const _reckoned = new Cesium.Cartesian3();
export function createRendering({
state: layerState,
services,
@@ -158,40 +164,73 @@ export function createRendering({
}
/**
* Propagate all CORE satellite positions and update point primitives.
* (About 1,300 core satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms,
* fine at the 1s/200ms cadence.) Dense extras are
* excluded: they refresh on the round-robin budget in _propagateDenseChunk.
* Take a fresh SGP4 sample of every CORE satellite (motion.js); the points
* move from these each frame in _reckonPoints. (About 1,300 core
* satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms, fine at the
* 1s/200ms cadence.) Dense extras are excluded: they are sampled on the
* round-robin budget in _propagateDenseChunk.
* @returns {number} How many satellites were sampled.
*/
function _propagateAll() {
const now = new Date(layerState.now());
const nowMs = layerState.now();
let updated = 0;
for (const [noradId, sat] of layerState._catalog) {
for (const sat of layerState._catalog.values()) {
if (sat.group === 'dense') continue;
const pos = parts.orbits.propagatePosition(sat.satrec, now);
if (!pos) continue;
const cartesian = Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
const point = layerState._points.get(noradId);
if (point) {
point.position = cartesian;
updated++;
}
if (_sample(sat, nowMs)) updated++;
}
return updated;
}
/**
* Sample one satellite's orbit at `atMs` into its reused sample.
* @returns {boolean} Whether SGP4 had a state for that time.
*/
function _sample(sat, atMs) {
const sample = sampleOrbit(sat.satrec, atMs, sat.orbitSample);
if (!sample) return false;
sat.orbitSample = sample;
return true;
}
/**
* Move every shown point to where its satellite is at the world time,
* reckoned from its last sample, so points glide every frame between
* samples instead of stepping once a second. A core sample more than
* ORBIT_SAMPLE_HORIZON_MS from the world time (time playing fast, or a
* seek) is taken again first; dense extras are resampled by their own
* round-robin. A hidden point is moved when it is shown again, and the
* tracked satellite is placed by its per-frame path.
* @param {number} nowMs World time, epoch ms.
*/
function _reckonPoints(nowMs) {
const gmst = gstime(new Date(nowMs));
for (const [noradId, point] of layerState._points) {
if (!point.show || noradId === layerState._trackedNorad) continue;
const sat = layerState._catalog.get(noradId);
if (!sat || sat.reckonedAtMs === nowMs) continue;
let sample = sat.orbitSample;
if (
sat.group !== 'dense' &&
(!sample || Math.abs(nowMs - sample[0]) > ORBIT_SAMPLE_HORIZON_MS)
)
sample = _sample(sat, nowMs) ? sat.orbitSample : null;
if (!sample || Math.abs(nowMs - sample[0]) > ORBIT_RECKON_LIMIT_MS)
continue;
point.position = reckonEarthFixed(sample, nowMs, gmst, _reckoned); // setter clones
sat.reckonedAtMs = nowMs;
}
}
/**
* Shared scene.preRender tick (single definition for init + enable):
* - core fleet propagation at 200ms-tracked / 1s-idle cadence,
* - dense extras on a per-frame round-robin budget,
* - core fleet SGP4 samples at 200ms-tracked / 1s-idle cadence,
* - dense extras sampled on a per-frame round-robin budget,
* - every point reckoned from its sample each frame (motion.js),
* - tracked satellite's point primitive per frame (WS-D2),
* - orbit ring GMST re-alignment every ~1s (WS-D1).
*/
@@ -213,7 +252,12 @@ export function createRendering({
layerState._lastPropagation = now;
}
if (layerState._params.showPoints) parts.catalog._propagateDenseChunk();
if (layerState._params.showPoints) {
const worldMs = layerState.now();
parts.catalog._propagateDenseChunk(worldMs - layerState._lastReckonMs);
layerState._lastReckonMs = worldMs;
_reckonPoints(worldMs);
}
// Keep the tracked dot on the per-frame epoch shared with label + camera —
// runs after _propagateAll so the per-frame sample wins over the 200ms one.
@@ -339,6 +383,7 @@ export function createRendering({
_updateOrbitPathRotations,
_hideOrbitPath,
_propagateAll,
_reckonPoints,
_preRenderTick,
_updatePointFocus,
applySatellitePointFocusDeemphasis,
+4
View File
@@ -64,6 +64,10 @@ export function createState({ services }) {
state._lastPropagation = 0;
// The world time of the last frame that reckoned the points (motion.js),
// NaN before the first.
state._lastReckonMs = NaN;
state._lastRingRotation = 0;
state._lastFocusUpdate = 0;
+1 -1
View File
@@ -429,7 +429,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
// 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.
// Falls back to the point primitive's reckoned position if SGP4 fails.
const positionProperty = new Cesium.CallbackProperty(() => {
const pos = _getTrackedFramePosition();
return pos ? layerState._trackedFrameCartesian : point.position;