mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
The first formal release. - One-click install via Pinokio; keyless boot lands on a live Esri World Imagery satellite globe with keyless terrain, with automatic OSM fallback and graceful degradation when terrain is unavailable. - Provider Settings (the POWER UP panel): add, replace, or remove API keys inside the app; credential files made owner-only before any secret is written; external keys shown read-only; the panel refuses shared or proxied servers. - Keyless capability responses for the optional HUD summary and place search. - Aircraft-identity voice answers cover operator, type, and route, and say so plainly when enrichment is unavailable. - README rewritten keyless-first; concise 0.1.0 changelog section; CI workflow included. Gates: 2,672 unit tests pass / 0 fail, build clean, tracking regression 108/108, map-source tray QA pass, setup doctor ready.
428 lines
19 KiB
JavaScript
428 lines
19 KiB
JavaScript
// src/data/motionModel.js
|
||
/**
|
||
* Pure display-motion math for the aircraft layers. No layer state, no DOM —
|
||
* importable under `node --test`.
|
||
*
|
||
* 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';
|
||
|
||
const DEG = Math.PI / 180;
|
||
const _mmCarto = new Cesium.Cartographic();
|
||
const _mmCandidateCarto = new Cesium.Cartographic();
|
||
|
||
export function norm360(deg) {
|
||
return ((deg % 360) + 360) % 360;
|
||
}
|
||
|
||
/**
|
||
* Lift a repeated grounded fix to a newly resolved render floor without
|
||
* manufacturing a new source timestamp or changing its reported lat/lon.
|
||
* Airborne fixes and downward candidates are deliberately left untouched.
|
||
*
|
||
* @param {{position: Cesium.Cartesian3}|null} newest
|
||
* @param {Cesium.Cartesian3|null} candidatePosition
|
||
* @param {boolean} onGround
|
||
* @returns {boolean} True when the stored position was lifted.
|
||
*/
|
||
export function liftRepeatedGroundFix(newest, candidatePosition, onGround) {
|
||
if (!onGround || !newest?.position || !candidatePosition) return false;
|
||
const stored = Cesium.Cartographic.fromCartesian(
|
||
newest.position, Cesium.Ellipsoid.WGS84, _mmCarto,
|
||
);
|
||
const candidate = Cesium.Cartographic.fromCartesian(
|
||
candidatePosition, Cesium.Ellipsoid.WGS84, _mmCandidateCarto,
|
||
);
|
||
if (!stored || !candidate || candidate.height <= stored.height) return false;
|
||
Cesium.Cartesian3.fromRadians(
|
||
stored.longitude,
|
||
stored.latitude,
|
||
candidate.height,
|
||
Cesium.Ellipsoid.WGS84,
|
||
newest.position,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/** Normalize to (−180, 180]. */
|
||
export function norm180(deg) {
|
||
const n = norm360(deg);
|
||
return n > 180 ? n - 360 : n;
|
||
}
|
||
|
||
/**
|
||
* Course over ground (deg from north, clockwise) of the chord from → to,
|
||
* measured in `from`'s local ENU frame. Returns null when the chord is shorter
|
||
* than `minChordM` or the motion is vertical-only (direction unreliable —
|
||
* callers fall back to the reported track).
|
||
*/
|
||
export function courseBetweenCartesians(from, to, minChordM = 25) {
|
||
const dx = to.x - from.x;
|
||
const dy = to.y - from.y;
|
||
const dz = to.z - from.z;
|
||
if (dx * dx + dy * dy + dz * dz < minChordM * minChordM) return null;
|
||
const carto = Cesium.Cartographic.fromCartesian(from, Cesium.Ellipsoid.WGS84, _mmCarto);
|
||
if (!carto) return null;
|
||
const sLat = Math.sin(carto.latitude);
|
||
const cLat = Math.cos(carto.latitude);
|
||
const sLon = Math.sin(carto.longitude);
|
||
const cLon = Math.cos(carto.longitude);
|
||
const e = -sLon * dx + cLon * dy;
|
||
const n = -sLat * cLon * dx - sLat * sLon * dy + cLat * dz;
|
||
if (e * e + n * n < 1) return null; // < 1 m horizontal — vertical-only motion
|
||
return norm360(Math.atan2(e, n) / DEG);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Low-speed course tuning (2026-07-03 field test: slow planes + helicopters).
|
||
// The interpolation chord is only trustworthy when the segment actually covers
|
||
// ground: at hover the fix-to-fix chord is a few metres of GPS jitter (its
|
||
// direction a pure random walk), and on a slow tight turn the chord STEPS by
|
||
// the whole per-segment turn at each poll boundary (a 60°/s whip, then frozen).
|
||
// Below the gate the reported per-fix track takes over; below the hold floor
|
||
// nothing is trustworthy and callers keep their previous display course.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** At/below this displayed ground speed the course source is the reported
|
||
* track only (chord weight 0). ≈30 kt. */
|
||
export const COURSE_TRACK_ONLY_MPS = 15.4;
|
||
/** At/above this displayed ground speed the course source is the chord only
|
||
* (weight 1) — the regime the owner field-approved; behavior unchanged there.
|
||
* ≈50 kt. */
|
||
export const COURSE_CHORD_ONLY_MPS = 25.7;
|
||
/** Below this displayed speed neither chord nor reported track means anything
|
||
* (hover / GPS drift — a hovering heli's track is velocity-vector noise):
|
||
* callers HOLD their previous display course. ≈3 kt. */
|
||
export const COURSE_HOLD_SPEED_MPS = 1.5;
|
||
/** Course slew cap at/below the track-only gate. Slow aircraft turn a few °/s;
|
||
* whipping their nose at the fleet's 60°/s cap reads as a snap. */
|
||
export const COURSE_MIN_DPS = 12;
|
||
/** Below this fix ground speed, fix-track deltas are GPS-vector noise, not a
|
||
* turn — the turn-rate estimator ignores those intervals (a hovering heli's
|
||
* jitter used to manufacture a fake ±°/s rate that the extrapolation arc then
|
||
* integrated into a slow pirouette). ≈10 kt. */
|
||
export const TURN_MIN_SPEED_MPS = 5;
|
||
|
||
/** 0 at/below COURSE_TRACK_ONLY_MPS, 1 at/above COURSE_CHORD_ONLY_MPS, linear
|
||
* between. Non-finite speed (unknown) → 1, the legacy chord behavior. */
|
||
export function speedRamp(speedMps) {
|
||
if (!Number.isFinite(speedMps)) return 1;
|
||
if (speedMps <= COURSE_TRACK_ONLY_MPS) return 0;
|
||
if (speedMps >= COURSE_CHORD_ONLY_MPS) return 1;
|
||
return (speedMps - COURSE_TRACK_ONLY_MPS) / (COURSE_CHORD_ONLY_MPS - COURSE_TRACK_ONLY_MPS);
|
||
}
|
||
|
||
/** Shortest-arc angle lerp: from → to by t (t=0 → from, t=1 → to). Also the
|
||
* course blender (blend chord over track by the speed ramp). */
|
||
export function lerpAngleDeg(fromDeg, toDeg, t) {
|
||
return norm360(fromDeg + norm180(toDeg - fromDeg) * t);
|
||
}
|
||
|
||
/** Speed-scaled slew cap for limitCourseStep: COURSE_MIN_DPS at low speed
|
||
* easing to `maxDps` at cruise, so slow aircraft glide through segment
|
||
* boundaries instead of whipping at the fleet cap. */
|
||
export function courseSlewCapDps(speedMps, maxDps, minDps = COURSE_MIN_DPS) {
|
||
return minDps + (maxDps - minDps) * speedRamp(speedMps);
|
||
}
|
||
|
||
/**
|
||
* Resolve the kinematics that belong to the position currently being drawn.
|
||
*
|
||
* A feed may transiently report zero or missing ground speed while consecutive
|
||
* fixes still describe clear movement. The delayed renderer already derives a
|
||
* matching segment speed and course from those fixes, so tracking and cockpit
|
||
* consumers should prefer that pair over stale poll fields.
|
||
*/
|
||
export function displayedKinematics({
|
||
derivedSpeedMps,
|
||
derivedTrackDeg,
|
||
reportedSpeedMps,
|
||
reportedTrackDeg,
|
||
} = {}) {
|
||
return {
|
||
speedMps: Number.isFinite(derivedSpeedMps) ? Math.max(0, derivedSpeedMps)
|
||
: (Number.isFinite(reportedSpeedMps) ? Math.max(0, reportedSpeedMps) : null),
|
||
trackDeg: Number.isFinite(derivedTrackDeg) ? derivedTrackDeg
|
||
: (Number.isFinite(reportedTrackDeg) ? reportedTrackDeg : null),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Bound forward coasting by the freshest source contact, not only by the last
|
||
* position-fix epoch.
|
||
*
|
||
* ADS-B state vectors often keep receiving velocity/track messages after the
|
||
* last position timestamp. A fixed "60 s after position" ceiling therefore
|
||
* alternates between a frozen icon and a catch-up jump even while the source
|
||
* still hears the aircraft. This horizon permits one grace window after the
|
||
* latest actual contact, while `maximumSec` prevents a cached/stale feed from
|
||
* drifting an aircraft indefinitely.
|
||
*/
|
||
export function staleCoastLimitSeconds({
|
||
fixEpochMs,
|
||
lastContactEpochMs,
|
||
minimumSec = 60,
|
||
contactGraceSec = 60,
|
||
maximumSec = 300,
|
||
} = {}) {
|
||
const floor = Math.max(0, Number.isFinite(minimumSec) ? minimumSec : 60);
|
||
const ceiling = Math.max(floor, Number.isFinite(maximumSec) ? maximumSec : 300);
|
||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(lastContactEpochMs)) return floor;
|
||
const contactLeadSec = Math.max(0, (lastContactEpochMs - fixEpochMs) / 1000);
|
||
const grace = Math.max(0, Number.isFinite(contactGraceSec) ? contactGraceSec : 60);
|
||
return Math.min(ceiling, Math.max(floor, contactLeadSec + grace));
|
||
}
|
||
|
||
/**
|
||
* Shortest-arc rate limiter: step `prevDeg` toward `targetDeg` by at most
|
||
* `maxDegPerSec * dtSec`. Seeds directly on the target when prev is null.
|
||
* Smooths the once-per-fix course step at interpolation-segment boundaries
|
||
* without lagging real turns (real aircraft turn ≤ ~4°/s; limiter default 60°/s).
|
||
*/
|
||
export function limitCourseStep(prevDeg, targetDeg, maxDegPerSec, dtSec) {
|
||
if (prevDeg == null || !Number.isFinite(prevDeg)) return norm360(targetDeg);
|
||
const d = norm180(targetDeg - prevDeg);
|
||
const maxStep = Math.max(0, maxDegPerSec * Math.max(0, dtSec));
|
||
const step = Math.abs(d) <= maxStep ? d : Math.sign(d) * maxStep;
|
||
return norm360(prevDeg + step);
|
||
}
|
||
|
||
/**
|
||
* Turn-rate estimate (deg/s) from track samples, with angle unwrapping.
|
||
* Below `noiseFloorDps` returns 0 (treat as straight); clamped to ±maxDps
|
||
* (standard-rate turn is 3°/s). dt sanity window matches our 15–30 s poll
|
||
* cadence (2..120 s). When `minSpeedMps` > 0, intervals whose endpoints carry
|
||
* a FINITE speed below it are skipped — at hover/taxi speed the reported track
|
||
* is GPS-vector noise, not a turn (samples without a speed keep the legacy
|
||
* behavior: no way to tell, so they still count).
|
||
*/
|
||
export function estimateTurnRateDps(samples, noiseFloorDps = 0.4, maxDps = 4, minSpeedMps = 0) {
|
||
if (!samples || samples.length < 2) return 0;
|
||
let sum = 0;
|
||
let n = 0;
|
||
for (let i = 1; i < samples.length; i++) {
|
||
const dt = samples[i].tSec - samples[i - 1].tSec;
|
||
if (dt < 2 || dt > 120) continue;
|
||
if (!Number.isFinite(samples[i].trackDeg) || !Number.isFinite(samples[i - 1].trackDeg)) continue;
|
||
if (minSpeedMps > 0) {
|
||
const v0 = samples[i - 1].speedMps;
|
||
const v1 = samples[i].speedMps;
|
||
if ((Number.isFinite(v0) && v0 < minSpeedMps) || (Number.isFinite(v1) && v1 < minSpeedMps)) continue;
|
||
}
|
||
sum += norm180(samples[i].trackDeg - samples[i - 1].trackDeg) / dt;
|
||
n += 1;
|
||
}
|
||
if (!n) return 0;
|
||
const rate = sum / n;
|
||
if (Math.abs(rate) < noiseFloorDps) return 0;
|
||
return Math.max(-maxDps, Math.min(maxDps, rate));
|
||
}
|
||
|
||
/** Adapter: fix history [{time: JulianDate, 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 samples = [];
|
||
for (const s of history) {
|
||
if (!Number.isFinite(s.track)) continue;
|
||
samples.push({
|
||
tSec: Cesium.JulianDate.secondsDifference(s.time, t0),
|
||
trackDeg: s.track,
|
||
speedMps: s.velocity,
|
||
});
|
||
}
|
||
return estimateTurnRateDps(samples, undefined, undefined, TURN_MIN_SPEED_MPS);
|
||
}
|
||
|
||
/** @constant {number} Metres per degree of latitude (spherical approximation). */
|
||
const METRES_PER_DEG = 111320;
|
||
/** @constant {number} Ground distance between samples along a projected arc
|
||
* (m). Half a coarse floor cell: the corridor's cell walk joins consecutive
|
||
* samples with a straight chord, and at this spacing the chord's departure
|
||
* from the arc is centimetres even on a tight taxi turn. A FIXED sample count
|
||
* cannot promise that — its spacing grows with speed, so a fast contact's
|
||
* chords cut corners off its own path. */
|
||
export const CORRIDOR_SAMPLE_SPACING_M = 55;
|
||
/** @constant {number} Longest arc a single corridor projects (m). The cell
|
||
* budget truncates at ~12 cells ≈ 1.3 km anyway, so projecting further just
|
||
* builds samples the walk will discard. Ground past this is picked up on a
|
||
* later poll as the display advances into it — the same honest truncation the
|
||
* straight walk makes. */
|
||
export const CORRIDOR_MAX_LENGTH_M = 1300;
|
||
/** Scratch for the shared arc integrator (see arcOffsetEnu). */
|
||
const _scratchProjectArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||
|
||
/**
|
||
* Where a ground contact will be after `dtSec`, using the SAME kinematics the
|
||
* dead-reckon uses: `arcOffsetEnu`, which integrates a constant-rate turn
|
||
* rather than running a straight tangent. A taxiing contact in a sustained
|
||
* turn leaves the tangent within a few hundred metres, so a straight
|
||
* projection would aim the display-floor corridor at ground it never crosses
|
||
* while its actual arc stays cold.
|
||
*
|
||
* Flat-earth ENU→lat/lon on purpose: the only consumer quantises the result to
|
||
* ~111 m cells over ≤1 km legs, where the spherical error is centimetres. The
|
||
* TURN, not the earth model, is what mattered.
|
||
*
|
||
* @param {number} lat @param {number} lon - Start, degrees.
|
||
* @param {number} courseDeg - Instantaneous course (0 = north, 90 = east).
|
||
* @param {number} speedMps - Ground speed.
|
||
* @param {number} turnRateDps - Signed turn rate; 0 gives a straight leg.
|
||
* @param {number} dtSec - Seconds ahead; 0 or negative returns the start.
|
||
* @returns {{lat: number, lon: number}} Projected coordinate, degrees.
|
||
*/
|
||
export function projectGroundArcLatLon(lat, lon, courseDeg, speedMps, turnRateDps, dtSec) {
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { lat, lon };
|
||
if (!Number.isFinite(courseDeg) || !Number.isFinite(speedMps) || !Number.isFinite(dtSec)
|
||
|| speedMps <= 0 || dtSec <= 0) {
|
||
return { lat, lon };
|
||
}
|
||
const off = arcOffsetEnu(speedMps, courseDeg, turnRateDps || 0, dtSec, _scratchProjectArc);
|
||
// Guard the pole: cos(lat) → 0 would send the longitude to infinity.
|
||
const cosLat = Math.max(Math.cos(lat * DEG), 1e-6);
|
||
return {
|
||
lat: lat + off.north / METRES_PER_DEG,
|
||
lon: lon + off.east / (METRES_PER_DEG * cosLat),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The PATH a grounded contact's display is about to walk — the ground its floor
|
||
* needs to be warm for, as a polyline rather than a single endpoint.
|
||
*
|
||
* Two regimes, matching what the dead-reckon actually does:
|
||
* - INTERPOLATING: the display LERPS a straight chord between two fixes, so a
|
||
* two-point corridor to the newest fix is exact.
|
||
* - EXTRAPOLATING (coasting past the newest fix, or the pre-history warm-up):
|
||
* the display integrates a constant-rate turn away from that fix. A single
|
||
* projected endpoint would still be joined to the start by a straight chord,
|
||
* so a sustained turn's arc — where the contact actually goes — would be
|
||
* left cold. Sampling the arc keeps corridor and display on one path.
|
||
*
|
||
* @param {object} p
|
||
* @param {boolean} p.extrapolating - Whether the display position was extrapolated.
|
||
* @param {number} p.displayLat @param {number} p.displayLon - Where it renders now.
|
||
* @param {number} p.courseDeg - Course of the displayed motion.
|
||
* @param {number} p.speedMps - Displayed ground speed.
|
||
* @param {number} [p.turnRateDps] - The contact's estimated turn rate.
|
||
* @param {number} p.fixLat @param {number} p.fixLon - The newest fix.
|
||
* @param {number} p.lookaheadSec - How far ahead to project when extrapolating.
|
||
* The sample COUNT derives from the arc's length so spacing stays at
|
||
* CORRIDOR_SAMPLE_SPACING_M whatever the speed, and the arc is truncated at
|
||
* CORRIDOR_MAX_LENGTH_M rather than thinned — the same discipline the straight
|
||
* walk uses. A fixed count silently widened its spacing as speed rose, and the
|
||
* chords between widely spaced samples cut corners off the contact's own path,
|
||
* leaving cells it genuinely occupies cold.
|
||
*
|
||
* @returns {Array<{lat: number, lon: number}>} Polyline, display position first.
|
||
*/
|
||
export function corridorPathLatLon({
|
||
extrapolating, displayLat, displayLon, courseDeg, speedMps, turnRateDps = 0,
|
||
fixLat, fixLon, lookaheadSec,
|
||
}) {
|
||
if (!Number.isFinite(displayLat) || !Number.isFinite(displayLon)) return [];
|
||
const start = { lat: displayLat, lon: displayLon };
|
||
if (!extrapolating && Number.isFinite(fixLat) && Number.isFinite(fixLon)) {
|
||
// The dead-reckon LERPS a straight chord between two fixes, so two points
|
||
// ARE the display path here; the corridor's own walk fills the cells.
|
||
return [start, { lat: fixLat, lon: fixLon }];
|
||
}
|
||
const speed = Number.isFinite(speedMps) ? speedMps : 0;
|
||
const seconds = Number.isFinite(lookaheadSec) ? lookaheadSec : 0;
|
||
if (speed <= 0 || seconds <= 0) return [start];
|
||
const cappedSec = Math.min(seconds, CORRIDOR_MAX_LENGTH_M / speed);
|
||
const arcLengthM = speed * cappedSec;
|
||
const n = Math.max(1, Math.ceil(arcLengthM / CORRIDOR_SAMPLE_SPACING_M));
|
||
const out = [start];
|
||
for (let i = 1; i <= n; i++) {
|
||
out.push(projectGroundArcLatLon(
|
||
displayLat, displayLon, courseDeg, speed, turnRateDps, cappedSec * (i / n),
|
||
));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Dead-reckon offset in the local ENU frame: along-track at ground speed,
|
||
* integrating a constant-rate turn when turnRateDps is significant. Writes
|
||
* {east, north, endCourseDeg} into `result` (no allocation) and returns it.
|
||
* Works for negative dt (backward extrapolation — the warm-up path).
|
||
*/
|
||
export function arcOffsetEnu(speedMps, trackDeg, turnRateDps, dtSec, result) {
|
||
const tr = trackDeg * DEG;
|
||
const w = (turnRateDps || 0) * DEG; // rad/s
|
||
if (Math.abs(w) < 1e-4) {
|
||
result.east = speedMps * Math.sin(tr) * dtSec;
|
||
result.north = speedMps * Math.cos(tr) * dtSec;
|
||
result.endCourseDeg = norm360(trackDeg);
|
||
return result;
|
||
}
|
||
result.east = (speedMps / w) * (Math.cos(tr) - Math.cos(tr + w * dtSec));
|
||
result.north = (speedMps / w) * (Math.sin(tr + w * dtSec) - Math.sin(tr));
|
||
result.endCourseDeg = norm360(trackDeg + turnRateDps * dtSec);
|
||
return result;
|
||
}
|
||
|
||
const _forwardFixArc = { east: 0, north: 0, endCourseDeg: 0 };
|
||
const _forwardFixEnu = new Cesium.Matrix4();
|
||
const _forwardFixOffset = new Cesium.Cartesian3();
|
||
|
||
/**
|
||
* Create a forward-only synthetic fix when a repeated position epoch carries
|
||
* newer velocity or course data. The prior fix remains immutable: its own
|
||
* kinematics project it to `epochMs`, and only the returned fix adopts the new
|
||
* 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,
|
||
* 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,
|
||
* velocity: number, track: number}|null} Forward synthetic fix, or null.
|
||
*/
|
||
export function synthesizeForwardKinematicsFix(newest, next = {}) {
|
||
if (!newest?.position || !newest?.time || !Number.isFinite(next.epochMs)) return null;
|
||
const startEpochMs = Number.isFinite(newest.epochMs)
|
||
? newest.epochMs
|
||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||
if (!Number.isFinite(startEpochMs) || next.epochMs <= startEpochMs) return null;
|
||
|
||
const previousVelocity = Number.isFinite(newest.velocity) ? Math.max(0, newest.velocity) : 0;
|
||
const previousTrack = Number.isFinite(newest.track) ? newest.track : 0;
|
||
const dtSec = (next.epochMs - startEpochMs) / 1000;
|
||
arcOffsetEnu(
|
||
previousVelocity,
|
||
previousTrack,
|
||
Number.isFinite(next.turnRateDps) ? next.turnRateDps : 0,
|
||
dtSec,
|
||
_forwardFixArc,
|
||
);
|
||
Cesium.Cartesian3.fromElements(
|
||
_forwardFixArc.east,
|
||
_forwardFixArc.north,
|
||
0,
|
||
_forwardFixOffset,
|
||
);
|
||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(
|
||
newest.position,
|
||
Cesium.Ellipsoid.WGS84,
|
||
_forwardFixEnu,
|
||
);
|
||
const position = Cesium.Matrix4.multiplyByPoint(
|
||
enu,
|
||
_forwardFixOffset,
|
||
new Cesium.Cartesian3(),
|
||
);
|
||
return {
|
||
time: Cesium.JulianDate.fromDate(new Date(next.epochMs)),
|
||
epochMs: next.epochMs,
|
||
position,
|
||
velocity: Number.isFinite(next.velocity) ? Math.max(0, next.velocity) : previousVelocity,
|
||
track: Number.isFinite(next.track) ? next.track : previousTrack,
|
||
};
|
||
}
|