diff --git a/src/celestialRing.js b/src/celestialRing.js index 3e30e62ec..0d368605d 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from './time/clock.js'; import { governorRequestRender } from './renderGovernor.js'; /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ @@ -752,7 +753,9 @@ export class CelestialRing { if (!wasVisible) this._ephemerisDirty = true; - const time = Cesium.JulianDate.now(); + // 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())); if (!this._updateEphemeris(time)) return; this._root.dataset.ephemerisUpdates = String(this._ephemerisUpdateCount); const camera = this.viewer.camera; diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index b09dc7014..8bbb6e062 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -479,7 +479,7 @@ test('real civil track path creates no native label and publishes every cached h tracked: false, history: [ { - time: now, + timeMs: Cesium.JulianDate.toDate(now).getTime(), epochMs: Date.now(), position, velocity: 250, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 597211bb7..18f49f79a 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -407,7 +407,7 @@ test('real military track path creates no native label and publishes every cache tracked: false, history: [ { - time: now, + timeMs: Cesium.JulianDate.toDate(now).getTime(), epochMs: Date.now(), position, velocity: 231.5, diff --git a/src/data/motionModel.js b/src/data/motionModel.js index 5b985240d..1b8babbee 100644 --- a/src/data/motionModel.js +++ b/src/data/motionModel.js @@ -6,7 +6,6 @@ * Turn-rate estimation + constant-rate-turn arc integration are adapted from * skylight (https://github.com/cpaczek/skylight, MIT) shared/src/aim.ts. */ -import * as Cesium from 'cesium'; import * as geo from '../geo/index.js'; const DEG = Math.PI / 180; @@ -257,17 +256,17 @@ export function estimateTurnRateDps( return Math.max(-maxDps, Math.min(maxDps, rate)); } -/** Adapter: fix history [{time: JulianDate, track, velocity}] → +/** Adapter: fix history [{timeMs, track, velocity}] → * estimateTurnRateDps, guarded by TURN_MIN_SPEED_MPS (low-speed fix-track * jitter must not manufacture a turn rate). */ export function turnRateFromFixHistory(history) { if (!history || history.length < 2) return 0; - const t0 = history[0].time; + const t0 = history[0].timeMs; const samples = []; for (const s of history) { if (!Number.isFinite(s.track)) continue; samples.push({ - tSec: Cesium.JulianDate.secondsDifference(s.time, t0), + tSec: (s.timeMs - t0) / 1000, trackDeg: s.track, speedMps: s.velocity, }); @@ -447,19 +446,23 @@ const _forwardFixOffset = geo.cartesian3.create(); * kinematics. This prevents a late course change from reprojecting the entire * stale interval and snapping the rendered contact by kilometres. * - * @param {{time: Cesium.JulianDate, epochMs?: number, position: Cesium.Cartesian3, + * @param {{timeMs: number, epochMs?: number, position: Cesium.Cartesian3, * velocity?: number, track?: number}} newest Existing immutable history fix. * @param {{epochMs: number, velocity: number, track: number, turnRateDps?: number}} next * New kinematics and the wall/source epoch at which they become authoritative. - * @returns {{time: Cesium.JulianDate, epochMs: number, position: Cesium.Cartesian3, + * @returns {{timeMs: number, epochMs: number, position: Cesium.Cartesian3, * velocity: number, track: number}|null} Forward synthetic fix, or null. */ export function synthesizeForwardKinematicsFix(newest, next = {}) { - if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) + if ( + !newest?.position || + newest?.timeMs == null || + !Number.isFinite(next.epochMs) + ) return null; const startEpochMs = Number.isFinite(newest.epochMs) ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(); + : newest.timeMs; if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null; @@ -492,7 +495,8 @@ export function synthesizeForwardKinematicsFix(newest, next = {}) { geo.cartesian3.create(), ); return { - time: Cesium.JulianDate.fromDate(new Date(next.epochMs)), + // Whole milliseconds, as a Date holds the fix time. + timeMs: Math.trunc(next.epochMs), epochMs: next.epochMs, position, velocity: Number.isFinite(next.velocity) diff --git a/src/data/motionModel.test.mjs b/src/data/motionModel.test.mjs index fbdcc2cbd..ed255960d 100644 --- a/src/data/motionModel.test.mjs +++ b/src/data/motionModel.test.mjs @@ -93,10 +93,10 @@ test('estimateTurnRateDps: 2°/s ramp detected; noise floored; clamped', () => { assert.equal(estimateTurnRateDps(wild), -4); // clamped }); -test('turnRateFromFixHistory adapts JulianDate history', () => { - const t0 = Cesium.JulianDate.now(); +test('turnRateFromFixHistory adapts the fix history (ms timestamps)', () => { + const t0 = Date.now(); const h = [0, 30, 60].map((s, i) => ({ - time: Cesium.JulianDate.addSeconds(t0, s, new Cesium.JulianDate()), + timeMs: t0 + s * 1000, track: i * 60, })); assert.ok(Math.abs(turnRateFromFixHistory(h) - 2) < 1e-9); @@ -142,9 +142,9 @@ test('courseSlewCapDps: COURSE_MIN_DPS at low speed easing to the fleet cap at c }); test('turn-rate guard: hover fix-track jitter manufactures NO turn rate; a real slow turn still does', () => { - const t0 = Cesium.JulianDate.now(); + const t0 = Date.now(); const mk = (specs) => specs.map(([s, track, velocity]) => ({ - time: Cesium.JulianDate.addSeconds(t0, s, new Cesium.JulianDate()), + timeMs: t0 + s * 1000, track, velocity, })); // Hovering heli: 1 m/s drift, reported track flipping ±45° — used to yield ±°/s @@ -180,7 +180,7 @@ test('repeated kinematics apply only from a forward synthetic fix', () => { const epochMs = 1_800_000_000_000; const originalPosition = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 9000); const newest = { - time: Cesium.JulianDate.fromDate(new Date(epochMs)), + timeMs: epochMs, epochMs, position: Cesium.Cartesian3.clone(originalPosition), velocity: 100, @@ -195,6 +195,7 @@ test('repeated kinematics apply only from a forward synthetic fix', () => { assert.equal(newest.velocity, 100); assert.equal(newest.track, 90); assert.ok(Cesium.Cartesian3.equals(newest.position, originalPosition)); + assert.equal(synthetic.timeMs, epochMs + 10_000); assert.equal(synthetic.velocity, 220); assert.equal(synthetic.track, 15); assert.ok(Math.abs(Cesium.Cartesian3.distance(newest.position, synthetic.position) - 1000) < 0.5); @@ -202,7 +203,7 @@ test('repeated kinematics apply only from a forward synthetic fix', () => { test('liftRepeatedGroundFix raises only grounded history and preserves the stored coordinates', () => { const newest = { - time: Cesium.JulianDate.now(), + timeMs: Date.now(), epochMs: 123, position: Cesium.Cartesian3.fromDegrees(-98.04, 29.71, -30), velocity: 0, diff --git a/src/data/satellitesTrackedRefresh.test.mjs b/src/data/satellitesTrackedRefresh.test.mjs index 09c1bc515..54a76293b 100644 --- a/src/data/satellitesTrackedRefresh.test.mjs +++ b/src/data/satellitesTrackedRefresh.test.mjs @@ -338,3 +338,33 @@ test('ISS overlay entry retains the native distance scale and source text', () = assert.equal(entry.edgeFade, 'keyhole'); assert.equal(entry.horizonCull, true); }); + +test('the tracked satellite is propagated at the app clock time', async () => { + const { appClock } = await import('../time/clock.js'); + const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } }; + const point = { position: new Cesium.Cartesian3() }; + const viewer = { + camera: null, + scene: { frameState: { frameNumber: 1 } }, + }; + _setTrackedSatelliteRefreshStateForTest({ + noradId: 25544, + name: 'ISS (ZARYA)', + satrec: twoline2satrec(L1, L2), + entity, + point, + viewer, + now: undefined, + }); + try { + appClock.freeze(Date.UTC(2008, 8, 20, 12, 30)); + _runSatellitePreRenderForTest(); + assert.equal(entity.gevLabelModel.details[1], '353 km · NORAD 25544'); + appClock.freeze(Date.UTC(2008, 8, 20, 13, 0)); + viewer.scene.frameState.frameNumber = 2; + _runSatellitePreRenderForTest(); + assert.equal(entity.gevLabelModel.details[1], '366 km · NORAD 25544'); + } finally { + appClock.reset(); + } +}); diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 14c828b54..e41d5e7e7 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -1,5 +1,5 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; import { COURSE_HOLD_SPEED_MPS, lerpAngleDeg, @@ -150,25 +150,15 @@ export function createMotion({ const out = result || geo.cartesian3.create(); // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchRenderTime, - ); + const renderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; // Bracketing pair: interpolate — no extrapolation error, no snap-back. for (let i = history.length - 1; i >= 1; i--) { const a = history[i - 1]; const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = - span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; + if (a.timeMs <= renderTimeMs && renderTimeMs <= b.timeMs) { + const span = (b.timeMs - a.timeMs) / 1000; + const t = span > 0 ? (renderTimeMs - a.timeMs) / 1000 / span : 1.0; // Course of the DISPLAYED motion. The chord is only trustworthy when the // segment covers real ground (at hover its direction is GPS jitter; on a // slow tight turn it STEPS the whole per-segment turn at each boundary), @@ -199,10 +189,7 @@ export function createMotion({ } const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference( - renderTime, - newest.time, - ); + const elapsedSec = (renderTimeMs - newest.timeMs) / 1000; if (elapsedSec <= 0) { // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing @@ -213,10 +200,7 @@ export function createMotion({ // the oldest fix froze the icon; extrapolating the NEWEST fix to wall-clock now made // the icon jump back ~one poll interval the instant interpolation took over.) const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference( - oldest.time, - renderTime, - ); // ≥ 0 + const lookbackSec = (oldest.timeMs - renderTimeMs) / 1000; // ≥ 0 return _extrapolateFix( oldest, info, @@ -236,7 +220,7 @@ export function createMotion({ // a Date per aircraft on every 12 Hz fleet tick. fixEpochMs: Number.isFinite(newest.epochMs) ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), + : newest.timeMs, lastContactEpochMs: info?.lastContactEpochMs, // Permit one minute of contact grace but cap any cached-feed drift at // five minutes. Source backoff is exposed separately as a STALE cue. @@ -307,12 +291,7 @@ export function createMotion({ if (!flightState._trackedIcao) return false; const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchWarmupTime, - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); + return appClock.now() - RENDER_DELAY_SEC * 1000 < history[0].timeMs; } /** Resolve the selected aircraft's actual rendered square extent this frame. */ diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index bd47e82cd..cfcafd374 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -1,6 +1,5 @@ import { approxDistanceKm as _approxDistanceKm } from './recordPolicy.js'; import { displayedKinematics } from '../../data/motionModel.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { modelVisualAnchor } from '../../data/modelVisualAnchor.js'; @@ -550,7 +549,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index c66e9ec3b..9265e7b92 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -97,18 +97,19 @@ export function createFlightSnapshotRenderer({ // 5-15s stale and receipt-time stamping is what caused the // back/forward oscillation. Only append when the fix actually // advances, so stale repeats don't create zero-dt segments. - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + // Milliseconds since the epoch, whole as a Date holds them. + const fixTimeMs = Math.trunc(fixEpochMs); if (!flightState._positionHistory.has(icao24)) { flightState._positionHistory.set(icao24, []); } const history = flightState._positionHistory.get(icao24); const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + if (!newest || fixTimeMs > newest.timeMs) { // Per-fix kinematics: the fix's own velocity/track ride along so the // extrapolation paths use the values that BELONG to the fix they // extend, not whatever the latest poll reported. history.push({ - time: fixTime, + timeMs: fixTimeMs, epochMs: fixEpochMs, position: geo.cartesian3.clone(position), velocity: meta.velocity, diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index f4777d002..62ccba13d 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -117,7 +117,7 @@ export function createFlightState({ source, services }) { flightState._focusEvidenceIds = new Set(); - /** @type {Map>} */ + /** @type {Map>} timeMs: the fix time in whole ms since the epoch (the app clock's unit) */ flightState._positionHistory = new Map(); @@ -247,16 +247,12 @@ export function createFlightState({ source, services }) { flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; - flightState._scratchRenderTime = new Cesium.JulianDate(); - flightState._scratchFleetPos = geo.cartesian3.create(); flightState._scratchSonarScreen = geo.cartesian2.create(); flightState._scratchDrRaw = geo.cartesian3.create(); - flightState._scratchWarmupTime = new Cesium.JulianDate(); - flightState._trackedPosHolder = geo.cartesian3.create(); // --------------------------------------------------------------------------- diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index 7a2bbcfeb..83b19b25f 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; import { @@ -378,13 +379,9 @@ export function createTracking({ // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join the trail via _appendTrailFix // as they age past the delay. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchWarmupTime, - ); + const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + if (fix.timeMs <= seedRenderTimeMs) { flightState._trailPositions.push(Cesium.Cartesian3.clone(fix.position)); } } @@ -458,7 +455,7 @@ export function createTracking({ _refreshTrailDisplay(); const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + ? history[0].timeMs / 1000 : Infinity; _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); } diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 20b275d35..eb75c46c8 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { MISSION_CLOSE_VIEW_RANGE_M, @@ -358,7 +359,10 @@ export function createPanel({ state: layerState, services, parts, source }) { `rocket-satellite:${layerState._selectedLaunchId}`, ); const position = satellite?.position?.getValue( - Cesium.JulianDate.now(layerState._declutterTime), + Cesium.JulianDate.fromDate( + new Date(appClock.now()), + layerState._declutterTime, + ), ); const altitudeM = position ? geo.cartographic.fromCartesian(position)?.height diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 60e6e0070..a76aae224 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import { TRAJECTORY_STAGE_COLORS, REPLAY_ORBIT_DURATION_SEC, @@ -32,7 +33,11 @@ export function createRendering({ return; } parts.panel.updateMissionTelemetry(); - const time = Cesium.JulianDate.now(layerState._declutterTime); + // The app clock's now, as the JulianDate Cesium's entity properties take. + const time = Cesium.JulianDate.fromDate( + new Date(appClock.now()), + layerState._declutterTime, + ); layerState._declutterOccluder.cameraPosition = layerState._viewer.scene.camera.positionWC; parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder); diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 5bbd30145..761b31aa6 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import * as geo from '../../geo/index.js'; import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js'; @@ -126,7 +127,7 @@ export function createSelection({ `rocket-orbit:${launch.id}`, ); const orbitPositions = orbitEntity?.polyline?.positions?.getValue( - Cesium.JulianDate.now(), + Cesium.JulianDate.fromDate(new Date(appClock.now())), ); const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat); let range = 18000000; diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index c5f5f3507..74ff953ed 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,5 +1,5 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; +import { appClock } from '../../time/clock.js'; import { lerpAngleDeg, speedRamp, @@ -67,25 +67,15 @@ export function createMotion({ const out = result || geo.cartesian3.create(); // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchRenderTime, - ); + const renderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; // Bracketing pair: interpolate — no extrapolation error, no snap-back. for (let i = history.length - 1; i >= 1; i--) { const a = history[i - 1]; const b = history[i]; - if ( - Cesium.JulianDate.lessThanOrEquals(a.time, renderTime) && - Cesium.JulianDate.lessThanOrEquals(renderTime, b.time) - ) { - const span = Cesium.JulianDate.secondsDifference(b.time, a.time); - const t = - span > 0 - ? Cesium.JulianDate.secondsDifference(renderTime, a.time) / span - : 1.0; + if (a.timeMs <= renderTimeMs && renderTimeMs <= b.timeMs) { + const span = (b.timeMs - a.timeMs) / 1000; + const t = span > 0 ? (renderTimeMs - a.timeMs) / 1000 / span : 1.0; // Course of the DISPLAYED motion. The chord is only trustworthy when the // segment covers real ground (at hover its direction is GPS jitter; on a // slow tight turn it STEPS the whole per-segment turn at each boundary), @@ -115,10 +105,7 @@ export function createMotion({ } const newest = history[history.length - 1]; - const elapsedSec = Cesium.JulianDate.secondsDifference( - renderTime, - newest.time, - ); + const elapsedSec = (renderTimeMs - newest.timeMs) / 1000; if (elapsedSec <= 0) { // Warm-up: renderTime predates ALL history (freshly seen / just-started-tracking // aircraft, before RENDER_DELAY_SEC of history has accumulated, so no bracketing @@ -128,10 +115,7 @@ export function createMotion({ // the bracketing interpolation above with NO freeze and NO backward snap. (Holding // the oldest fix froze the icon until enough history accrued, then jumped.) const oldest = history[0]; - const lookbackSec = Cesium.JulianDate.secondsDifference( - oldest.time, - renderTime, - ); // ≥ 0 + const lookbackSec = (oldest.timeMs - renderTimeMs) / 1000; // ≥ 0 return _extrapolateFix( oldest, info, @@ -148,7 +132,7 @@ export function createMotion({ // Date for every aircraft twelve times per second. fixEpochMs: Number.isFinite(newest.epochMs) ? newest.epochMs - : Cesium.JulianDate.toDate(newest.time).getTime(), + : newest.timeMs, lastContactEpochMs: info?.lastContactEpochMs, }); return _extrapolateFix( @@ -213,12 +197,7 @@ export function createMotion({ if (!flightState._trackedIcao) return false; const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; - const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchWarmupTime, - ); - return Cesium.JulianDate.lessThan(renderTime, history[0].time); + return appClock.now() - RENDER_DELAY_SEC * 1000 < history[0].timeMs; } /** Resolve the selected aircraft's actual rendered square extent this frame. */ diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 41081c137..e180f3ced 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -114,11 +114,9 @@ test('a normalized source can retain its stale reason without changing standalon assert.equal(layer.getStats().stale, true); }); -test('a newly seen military contact appears at its delayed position, not ahead of it', async () => { - // The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh - // contact's first displayed position is its fix projected back to that - // delayed time. Creating the billboard at the raw fix drew it ahead until - // the next fleet tick jumped it back. +// A fresh contact's first billboard, with the app clock `appClockLagMs` +// behind the wall clock (0: the app's default, the wall clock itself). +async function firstPlacement(appClockLagMs) { const Cesium = await import('cesium'); const { RENDER_DELAY_SEC } = await import('./policy.js'); // The application's own scene services, as src/app/layers/militaryFlights.js @@ -146,6 +144,8 @@ test('a newly seen military contact appears at its delayed position, not ahead o const speedMps = 200; const fixAgeMs = 2_000; const nowMs = Date.now(); + const { appClock } = await import('../../time/clock.js'); + if (appClockLagMs) appClock.freeze(nowMs - appClockLagMs); const layer = createMilitaryFlightLayer({ services: supplied, source: { @@ -193,7 +193,11 @@ test('a newly seen military contact appears at its delayed position, not ahead o viewer: { camera: { positionCartographic: null }, scene: {} }, tracked: false, }); - await layer.update({ camera: { positionCartographic: null }, scene: {} }); + try { + await layer.update({ camera: { positionCartographic: null }, scene: {} }); + } finally { + appClock.reset(); + } const billboard = added.find((entry) => entry.id === 'ae0001'); assert.ok(billboard, 'the new contact gets a billboard'); const shown = Cesium.Cartographic.fromCartesian(billboard.position); @@ -201,11 +205,37 @@ test('a newly seen military contact appears at its delayed position, not ahead o Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0), Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0), ); + return { + behindM, + westOfFix: Cesium.Math.toDegrees(shown.longitude) < fixLon, + speedMps, + fixAgeMs, + RENDER_DELAY_SEC, + }; +} + +test('a newly seen military contact appears at its delayed position, not ahead of it', async () => { + // The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh + // contact's first displayed position is its fix projected back to that + // delayed time. Creating the billboard at the raw fix drew it ahead until + // the next fleet tick jumped it back. + const { behindM, westOfFix, speedMps, fixAgeMs, RENDER_DELAY_SEC } = + await firstPlacement(0); const expectedM = speedMps * (RENDER_DELAY_SEC - fixAgeMs / 1000); + assert.ok(westOfFix, 'drawn behind the fix'); assert.ok( - Cesium.Math.toDegrees(shown.longitude) < fixLon, - 'drawn behind the fix', + Math.abs(behindM - expectedM) < speedMps * 3, + `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, ); +}); + +test('the delayed position is read from the app clock', async () => { + // With the app clock 10 s behind the wall clock, the render time moves 10 s + // further back from the fix. + const { behindM, westOfFix, speedMps, fixAgeMs, RENDER_DELAY_SEC } = + await firstPlacement(10_000); + const expectedM = speedMps * (RENDER_DELAY_SEC + 10 - fixAgeMs / 1000); + assert.ok(westOfFix, 'drawn behind the fix'); assert.ok( Math.abs(behindM - expectedM) < speedMps * 3, `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index f96b1fed7..bd202435f 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -1,5 +1,4 @@ import { displayedKinematics } from '../../data/motionModel.js'; -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { isExplicitLayerStateOrigin } from '../../data/layerState.js'; import { aircraftIncludedInNearby } from '../../data/aircraftNearbyPolicy.js'; @@ -338,7 +337,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index c6f55c93e..819286a03 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { @@ -72,18 +71,19 @@ export function createMilitarySnapshotRenderer({ // report, so the fix epoch is receipt time minus that age. Only // append when the fix actually advances, so stale repeats don't // create zero-dt segments. - const fixTime = Cesium.JulianDate.fromDate(new Date(fixEpochMs)); + // Milliseconds since the epoch, whole as a Date holds them. + const fixTimeMs = Math.trunc(fixEpochMs); if (!flightState._positionHistory.has(icao24)) { flightState._positionHistory.set(icao24, []); } const history = flightState._positionHistory.get(icao24); const newest = history[history.length - 1]; - if (!newest || Cesium.JulianDate.greaterThan(fixTime, newest.time)) { + if (!newest || fixTimeMs > newest.timeMs) { // Per-fix kinematics: the fix's own velocity/track ride along so the // extrapolation paths use the values that BELONG to the fix they // extend, not whatever the latest poll reported. history.push({ - time: fixTime, + timeMs: fixTimeMs, epochMs: fixEpochMs, position: geo.cartesian3.clone(position), velocity: meta.speedMps, diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 74a5d0cdc..273e5a283 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -109,7 +109,7 @@ export function createFlightState({ source, services }) { flightState._detectionObjects = new Map(); - /** @type {Map>} ICAO hex -> recent position samples for dead reckoning */ + /** @type {Map>} ICAO hex -> recent position samples for dead reckoning (timeMs: whole ms since the epoch, the app clock's unit) */ flightState._positionHistory = new Map(); @@ -224,16 +224,12 @@ export function createFlightState({ source, services }) { flightState._scratchArc = { east: 0, north: 0, endCourseDeg: 0 }; - flightState._scratchRenderTime = new Cesium.JulianDate(); - flightState._scratchFleetPos = geo.cartesian3.create(); flightState._scratchSonarScreen = geo.cartesian2.create(); flightState._scratchDrRaw = geo.cartesian3.create(); - flightState._scratchWarmupTime = new Cesium.JulianDate(); - flightState._trackedPosHolder = geo.cartesian3.create(); // -- Per-frame dead-reckoning cache for the tracked entity -- diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index b0cfb0368..391342e3e 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; import { trackedModelZoomActive } from '../../data/trackedModelRegime.js'; @@ -497,13 +498,9 @@ export function createTracking({ // Seed only fixes at/behind the DELAYED display time (now − RENDER_DELAY_SEC). The // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join via _appendTrailFix as they age. - const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), - -RENDER_DELAY_SEC, - flightState._scratchWarmupTime, - ); + const seedRenderTimeMs = appClock.now() - RENDER_DELAY_SEC * 1000; for (const fix of history) { - if (Cesium.JulianDate.lessThanOrEquals(fix.time, seedRenderTime)) { + if (fix.timeMs <= seedRenderTimeMs) { // Floor grounded-history seeds (round 2): a grounded contact's stored // fixes carry the pre-datum default height — the trail copy sits on // the surface instead (pure lift; airborne fixes pass through). @@ -586,7 +583,7 @@ export function createTracking({ _refreshTrailDisplay(); const oldestFixEpochSec = history.length - ? Cesium.JulianDate.toDate(history[0].time).getTime() / 1000 + ? history[0].timeMs / 1000 : Infinity; _backfillTrail(icao24, flightState._trailBackfillToken, oldestFixEpochSec); } diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 7bdb1d29f..485466aa2 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { appClock } from '../../time/clock.js'; import { satelliteClassLabel } from '../../data/satelliteClass.js'; import { ISS_NORAD, @@ -130,7 +131,7 @@ export function createTracking({ state: layerState, services, parts, source }) { * Get the tracked satellite's geodetic position, propagated at most once per * rendered frame (WS-D2). All tracked-satellite consumers (entity position * callback → camera, host model, point primitive, getTrackedInfo) share this - * single `new Date()` epoch per frame, so they can never diverge by the old + * single app-clock epoch per frame, so they can never diverge by the old * 200ms throttle. The matching ECEF position is left in * `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap. * @returns {{ longitude: number, latitude: number, altitude: number }|null} @@ -150,9 +151,11 @@ export function createTracking({ state: layerState, services, parts, source }) { ) { const pos = parts.orbits.propagatePosition( sat.satrec, - layerState._trackedFrameNowForTest - ? new Date(layerState._trackedFrameNowForTest()) - : new Date(), + new Date( + layerState._trackedFrameNowForTest + ? layerState._trackedFrameNowForTest() + : appClock.now(), + ), ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; @@ -422,7 +425,10 @@ export function createTracking({ state: layerState, services, parts, source }) { // Comfortable tracking landing: ~726 km back for LEO (user-validated // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const initialPos = parts.orbits.propagatePosition( + sat.satrec, + new Date(appClock.now()), + ); const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE