From c33ee31efc784623556b8847b7394c4327034485 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 15:01:46 +0400 Subject: [PATCH] refactor(layers): W1 timestamps without JulianDate Flights and military keep their fix history in plain milliseconds (timeMs, whole ms as the Date the JulianDate came from held) and read "now" from the app clock: the render-delay time, bracketing, extrapolation, the warm-up check and the trail seed are number arithmetic (motion.js, tracking.js, snapshotRenderer.js), as is the turn rate and the forward synthetic fix in data/motionModel.js, which no longer imports Cesium. The JulianDate scratch fields in state.js and the unused JulianDate.now() in queries.js go. Launches (mission frame, panel, selection), the tracked satellite's SGP4 epoch and the celestial ring's sun and moon read the app clock; where a Cesium property still takes a JulianDate it is built from the app clock's now (as JulianDate.now did from Date.now). Wall-clock throttles and feed receipt stamps stay on Date.now; property reads at viewer.clock.currentTime (render, frozen at boot) move with the render code in the *a tasks. Tests that built JulianDate history fixtures now build ms ones with the same instants (motionModel.test, flights.test, militaryFlights.test); new tests show the military delayed position and the tracked satellite follow the app clock (both fail on the previous code). --- src/celestialRing.js | 5 ++- src/data/flights.test.mjs | 2 +- src/data/militaryFlights.test.mjs | 2 +- src/data/motionModel.js | 22 ++++++----- src/data/motionModel.test.mjs | 15 +++---- src/data/satellitesTrackedRefresh.test.mjs | 30 ++++++++++++++ src/layers/flights/motion.js | 39 +++++------------- src/layers/flights/queries.js | 2 - src/layers/flights/snapshotRenderer.js | 7 ++-- src/layers/flights/state.js | 6 +-- src/layers/flights/tracking.js | 11 ++---- src/layers/launches/panel.js | 6 ++- src/layers/launches/rendering.js | 7 +++- src/layers/launches/selection.js | 3 +- src/layers/military/motion.js | 39 +++++------------- src/layers/military/ownership.test.mjs | 46 ++++++++++++++++++---- src/layers/military/queries.js | 2 - src/layers/military/snapshotRenderer.js | 8 ++-- src/layers/military/state.js | 6 +-- src/layers/military/tracking.js | 11 ++---- src/layers/satellites/tracking.js | 16 +++++--- 21 files changed, 155 insertions(+), 130 deletions(-) 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