mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
692 lines
21 KiB
JavaScript
692 lines
21 KiB
JavaScript
/** Portable retained report-time playback. No scene or scheduling ownership. */
|
|
export const FIX_BYTES = 48;
|
|
export const FIX_FLAGS = Object.freeze({
|
|
VEHICLE_TIME: 1,
|
|
FEED_TIME: 2,
|
|
RECEIPT_TIME: 4,
|
|
FLOOR: 8,
|
|
BREAK: 16,
|
|
LIVE: 32,
|
|
});
|
|
const clampDelay = (n) => Math.max(25_000, Math.min(120_000, n));
|
|
|
|
export function createLagReservoir(capacity = 256) {
|
|
return { values: new Float64Array(capacity), count: 0, cursor: 0 };
|
|
}
|
|
function addLag(reservoir, value) {
|
|
if (!reservoir || !Number.isFinite(value) || value < 0) return;
|
|
reservoir.values[reservoir.cursor] = value;
|
|
reservoir.cursor = (reservoir.cursor + 1) % reservoir.values.length;
|
|
reservoir.count = Math.min(reservoir.count + 1, reservoir.values.length);
|
|
}
|
|
function percentile(reservoir, rank) {
|
|
if (!reservoir?.count) return 0;
|
|
const sorted = reservoir.values.slice(0, reservoir.count).sort();
|
|
return sorted[Math.ceil(sorted.length * rank) - 1];
|
|
}
|
|
|
|
/** Optional shared payload budget. Call trimTrack on lower-priority tracks first. */
|
|
export function createHistoryBudget(maxBytes = 32 * 1024 * 1024) {
|
|
return { maxBytes, allocatedBytes: 0 };
|
|
}
|
|
/** Numeric fields are reserved up front, including unknown mesh heights. */
|
|
export function createFixSample() {
|
|
return {
|
|
t: NaN,
|
|
lat: NaN,
|
|
lon: NaN,
|
|
heightM: NaN,
|
|
h: NaN,
|
|
receivedAt: NaN,
|
|
bearingDeg: NaN,
|
|
flags: 0,
|
|
epoch: 0,
|
|
seq: -1,
|
|
};
|
|
}
|
|
export function createTrack({
|
|
capacity = 128,
|
|
retentionMs = 900_000,
|
|
policy = {},
|
|
budget = null,
|
|
} = {}) {
|
|
capacity = Math.max(2, Math.min(128, Math.floor(capacity)));
|
|
return {
|
|
capacity,
|
|
retentionMs,
|
|
policy,
|
|
budget,
|
|
metricsScratchA: createFixSample(),
|
|
metricsScratchB: createFixSample(),
|
|
storage: new DataView(new ArrayBuffer(0)),
|
|
slots: 0,
|
|
head: 0,
|
|
count: 0,
|
|
baseSeq: 0,
|
|
revision: 0,
|
|
truncated: false,
|
|
epochs: new Map(),
|
|
lag: createLagReservoir(32),
|
|
targetDelayMs: 25_000,
|
|
displayT: NaN,
|
|
wallAnchor: NaN,
|
|
monoAnchor: NaN,
|
|
lastMono: NaN,
|
|
offset: 0,
|
|
rate: 1,
|
|
phase: 'buffering',
|
|
resets: 0,
|
|
resetReason: null,
|
|
acceptedAt: NaN,
|
|
acceptedMono: NaN,
|
|
resumePending: false,
|
|
bracket: 0,
|
|
metricsRevision: -1,
|
|
metricsIndex: -1,
|
|
from: createFixSample(),
|
|
to: createFixSample(),
|
|
metrics: { speed: NaN, course: NaN, nextMotionT: Infinity },
|
|
latest: createFixSample(),
|
|
pending: null,
|
|
pendingCount: 0,
|
|
};
|
|
}
|
|
function address(track, index) {
|
|
return ((track.head + index) % track.slots) * FIX_BYTES;
|
|
}
|
|
export function readFix(track, index, out = {}) {
|
|
if (index < 0 || index >= track.count) return null;
|
|
const v = track.storage,
|
|
o = address(track, index);
|
|
out.t = v.getFloat64(o);
|
|
out.lat = v.getFloat64(o + 8);
|
|
out.lon = v.getFloat64(o + 16);
|
|
out.heightM = v.getFloat32(o + 24);
|
|
out.h = out.heightM;
|
|
out.receivedAt = v.getFloat64(o + 28);
|
|
out.bearingDeg = v.getFloat32(o + 36);
|
|
out.flags = v.getUint32(o + 40);
|
|
out.epoch = v.getUint32(o + 44);
|
|
out.seq = track.baseSeq + index;
|
|
return out;
|
|
}
|
|
function writeFix(track, index, fix) {
|
|
const v = track.storage,
|
|
o = address(track, index);
|
|
v.setFloat64(o, fix.t);
|
|
v.setFloat64(o + 8, fix.lat);
|
|
v.setFloat64(o + 16, fix.lon);
|
|
v.setFloat32(o + 24, fix.heightM ?? fix.h ?? NaN);
|
|
v.setFloat64(o + 28, fix.receivedAt ?? NaN);
|
|
v.setFloat32(o + 36, fix.bearingDeg ?? NaN);
|
|
v.setUint32(o + 40, fix.flags || 0);
|
|
v.setUint32(o + 44, fix.epoch || 0);
|
|
}
|
|
function resize(track, slots) {
|
|
const bytes = slots * FIX_BYTES,
|
|
delta = bytes - track.storage.byteLength;
|
|
if (
|
|
delta > 0 &&
|
|
track.budget &&
|
|
track.budget.allocatedBytes + delta > track.budget.maxBytes
|
|
)
|
|
return false;
|
|
const old = track.storage,
|
|
head = track.head,
|
|
oldSlots = track.slots;
|
|
const next = new Uint8Array(bytes),
|
|
previous = new Uint8Array(old.buffer);
|
|
for (let i = 0; i < track.count; i++) {
|
|
const o = ((head + i) % oldSlots) * FIX_BYTES;
|
|
next.set(previous.subarray(o, o + FIX_BYTES), i * FIX_BYTES);
|
|
}
|
|
track.storage = new DataView(next.buffer);
|
|
track.slots = slots;
|
|
track.head = 0;
|
|
if (track.budget) track.budget.allocatedBytes += delta;
|
|
return true;
|
|
}
|
|
export function destroyTrack(track) {
|
|
if (track.budget) track.budget.allocatedBytes -= track.storage.byteLength;
|
|
track.storage = new DataView(new ArrayBuffer(0));
|
|
track.count = 0;
|
|
track.slots = 0;
|
|
track.epochs.clear();
|
|
}
|
|
function timeAt(track, i) {
|
|
return track.storage.getFloat64(address(track, i));
|
|
}
|
|
function dropOldest(track) {
|
|
track.head = (track.head + 1) % track.slots;
|
|
track.count--;
|
|
track.baseSeq++;
|
|
track.revision++;
|
|
track.truncated = true;
|
|
}
|
|
/** Retention never evicts the bracket currently on screen. */
|
|
export function trimTrack(track, nowMs, keep = track.capacity) {
|
|
while (
|
|
track.count > 2 &&
|
|
(track.count > keep || timeAt(track, 1) < nowMs - track.retentionMs)
|
|
) {
|
|
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT)
|
|
break;
|
|
dropOldest(track);
|
|
}
|
|
if (keep < track.slots && track.count <= keep)
|
|
resize(track, Math.max(2, track.count));
|
|
const used = new Set();
|
|
for (let i = 0; i < track.count; i++)
|
|
used.add(track.storage.getUint32(address(track, i) + 44));
|
|
for (const id of track.epochs.keys())
|
|
if (!used.has(id)) track.epochs.delete(id);
|
|
}
|
|
export function validFix(fix) {
|
|
return (
|
|
Number.isFinite(fix?.t) &&
|
|
fix.t >= 0 &&
|
|
Number.isFinite(fix.lat) &&
|
|
Math.abs(fix.lat) <= 90 &&
|
|
Number.isFinite(fix.lon) &&
|
|
Math.abs(fix.lon) <= 180
|
|
);
|
|
}
|
|
function distance(a, b) {
|
|
const lon = ((b.lon - a.lon + 540) % 360) - 180;
|
|
return Math.hypot(
|
|
(b.lat - a.lat) * 111320,
|
|
lon * 111320 * Math.cos((a.lat * Math.PI) / 180),
|
|
);
|
|
}
|
|
function append(track, fix) {
|
|
if (track.count === track.slots && track.slots < track.capacity)
|
|
resize(track, Math.min(track.capacity, Math.max(8, track.slots * 2)));
|
|
if (track.count === track.slots) {
|
|
if (track.count < 2) return false;
|
|
if (Number.isFinite(track.displayT) && timeAt(track, 1) > track.displayT) {
|
|
// Pin both active ends; drop the next future observation and mark the gap.
|
|
if (track.count < 4) return false;
|
|
const scratch = {};
|
|
for (let i = 2; i < track.count - 1; i++) {
|
|
readFix(track, i + 1, scratch);
|
|
if (i === 2) scratch.flags |= FIX_FLAGS.BREAK;
|
|
writeFix(track, i, scratch);
|
|
}
|
|
track.count--;
|
|
track.truncated = true;
|
|
} else dropOldest(track);
|
|
}
|
|
writeFix(track, track.count++, fix);
|
|
track.revision++;
|
|
return true;
|
|
}
|
|
|
|
export function insertFix(track, fix, observation = {}) {
|
|
if (
|
|
!validFix(fix) ||
|
|
(Number.isFinite(observation.wallNowMs) &&
|
|
fix.t > observation.wallNowMs + 10_000)
|
|
)
|
|
return { accepted: false, reason: 'malformed' };
|
|
const last = readFix(track, track.count - 1, track.latest);
|
|
if (last && fix.t === last.t)
|
|
return {
|
|
accepted: false,
|
|
reason:
|
|
fix.lat === last.lat && fix.lon === last.lon ? 'repeat' : 'conflict',
|
|
};
|
|
let reason = last ? 'moved' : 'first';
|
|
if (
|
|
last &&
|
|
(fix.t < last.t || (track.policy.accept && !track.policy.accept(last, fix)))
|
|
) {
|
|
reason = fix.t < last.t ? 'out-of-order' : 'implausible';
|
|
const prior = track.pending;
|
|
if (
|
|
!prior ||
|
|
prior.reason !== reason ||
|
|
fix.t <= prior.t ||
|
|
(track.policy.accept && !track.policy.accept(prior, fix))
|
|
) {
|
|
if (!prior || fix.t !== prior.t) {
|
|
track.pending = { ...fix, reason };
|
|
track.pendingCount = 1;
|
|
}
|
|
return { accepted: false, reason };
|
|
}
|
|
track.pendingCount++;
|
|
const required = reason === 'out-of-order' ? 3 : 2;
|
|
if (track.pendingCount < required) {
|
|
track.pending = { ...fix, reason };
|
|
return { accepted: false, reason };
|
|
}
|
|
// Clock correction starts a new time domain. A spatial replacement retains a visible gap.
|
|
if (reason === 'out-of-order') {
|
|
track.count = 0;
|
|
track.head = 0;
|
|
track.baseSeq += 128;
|
|
}
|
|
const epoch = (last.epoch + 1) >>> 0;
|
|
append(track, {
|
|
...prior,
|
|
epoch,
|
|
flags: (prior.flags || 0) | FIX_FLAGS.BREAK,
|
|
});
|
|
fix = { ...fix, epoch };
|
|
if (reason === 'out-of-order') track.displayT = NaN;
|
|
track.resetReason = reason;
|
|
track.resets++;
|
|
}
|
|
const receivedAt =
|
|
observation.receivedAt ?? fix.receivedAt ?? observation.wallNowMs ?? fix.t;
|
|
trimTrack(track, receivedAt);
|
|
const accepted = append(track, {
|
|
...fix,
|
|
receivedAt,
|
|
flags: (fix.flags || 0) | FIX_FLAGS.LIVE,
|
|
});
|
|
if (accepted) {
|
|
if (!observation.replayed) {
|
|
if (last && fix.t > last.t && reason === 'moved') {
|
|
addLag(track.lag, receivedAt - last.t);
|
|
addLag(track.policy.feedLag, receivedAt - last.t);
|
|
track.targetDelayMs = clampDelay(
|
|
Math.max(
|
|
percentile(track.lag, 0.95),
|
|
percentile(track.policy.feedLag, 0.9),
|
|
) + 5000,
|
|
);
|
|
} else if (!last)
|
|
track.targetDelayMs = clampDelay(
|
|
Math.max(0, receivedAt - fix.t) + 20_000,
|
|
);
|
|
}
|
|
track.acceptedAt = receivedAt;
|
|
track.acceptedMono = observation.monoNowMs ?? NaN;
|
|
track.resumePending = true;
|
|
track.pending = null;
|
|
track.pendingCount = 0;
|
|
}
|
|
return { accepted, reason: accepted ? reason : 'capacity' };
|
|
}
|
|
|
|
/** Bounded backfill; live positions win conflicts and the clock/health are untouched. */
|
|
export function mergeHistory(track, fixes) {
|
|
const merged = new Map();
|
|
for (const fix of fixes.slice(-128))
|
|
if (
|
|
validFix(fix) &&
|
|
(!Number.isFinite(track.acceptedAt) ||
|
|
fix.t >= track.acceptedAt - track.retentionMs)
|
|
)
|
|
merged.set(fix.t, { ...fix, flags: (fix.flags || 0) & ~FIX_FLAGS.LIVE });
|
|
for (let i = 0; i < track.count; i++) {
|
|
const fix = readFix(track, i);
|
|
merged.set(fix.t, fix);
|
|
}
|
|
const retained = Array.from({ length: track.count }, (_, i) =>
|
|
readFix(track, i),
|
|
);
|
|
const active = Number.isFinite(track.displayT)
|
|
? retained.findLastIndex((f) => f.t <= track.displayT)
|
|
: -1;
|
|
const pinnedFrom = retained[active],
|
|
pinnedTo = retained[active + 1];
|
|
const trusted = new Set(retained.map((f) => f.t));
|
|
const candidates = [...merged.values()]
|
|
.sort((a, b) => a.t - b.t)
|
|
.filter(
|
|
(f) =>
|
|
trusted.has(f.t) ||
|
|
!pinnedFrom ||
|
|
!pinnedTo ||
|
|
f.t < pinnedFrom.t ||
|
|
f.t > pinnedTo.t,
|
|
);
|
|
const ordered = [];
|
|
// Start from an admitted live position, including when walking backwards
|
|
// into retained history. A lone outlier is quarantined in either direction.
|
|
// Never borrow the live pending observation as a second vote for itself.
|
|
function admit(sequence, anchor, backwards = false) {
|
|
let last = anchor,
|
|
pending = null;
|
|
const plausible = (a, b) =>
|
|
!track.policy.accept ||
|
|
(backwards ? track.policy.accept(b, a) : track.policy.accept(a, b));
|
|
for (const fix of sequence) {
|
|
if (trusted.has(fix.t) || !last || plausible(last, fix)) {
|
|
ordered.push(fix);
|
|
last = fix;
|
|
pending = null;
|
|
} else if (pending && plausible(pending, fix)) {
|
|
// Two distinct coherent observations establish a replacement segment.
|
|
// Keep a visible gap between it and the authoritative live path.
|
|
if (backwards) last.flags |= FIX_FLAGS.BREAK;
|
|
else pending.flags |= FIX_FLAGS.BREAK;
|
|
ordered.push(pending, fix);
|
|
last = fix;
|
|
pending = null;
|
|
} else pending = fix;
|
|
}
|
|
}
|
|
const first = retained[0];
|
|
if (first) {
|
|
admit(
|
|
candidates.filter((f) => f.t < first.t).reverse(),
|
|
merged.get(first.t),
|
|
true,
|
|
);
|
|
admit(
|
|
candidates.filter((f) => f.t >= first.t),
|
|
null,
|
|
);
|
|
} else admit(candidates, null);
|
|
ordered.sort((a, b) => a.t - b.t);
|
|
while (ordered.length > track.capacity) {
|
|
const i = ordered.findIndex(
|
|
(f) => f.t !== pinnedFrom?.t && f.t !== pinnedTo?.t,
|
|
);
|
|
if (i > 0 && i + 1 < ordered.length)
|
|
ordered[i + 1].flags |= FIX_FLAGS.BREAK;
|
|
ordered.splice(Math.max(0, i), 1);
|
|
track.truncated = true;
|
|
}
|
|
for (let i = 1; i < ordered.length; i++) {
|
|
if (track.policy.accept && !track.policy.accept(ordered[i - 1], ordered[i]))
|
|
ordered[i].flags |= FIX_FLAGS.BREAK;
|
|
}
|
|
track.count = 0;
|
|
track.head = 0;
|
|
track.baseSeq += 128;
|
|
for (const fix of ordered) append(track, fix);
|
|
track.revision++;
|
|
track.bracket = 0;
|
|
}
|
|
export function correctHeight(track, seq, heightM) {
|
|
const i = seq - track.baseSeq;
|
|
if (i < 0 || i >= track.count || !Number.isFinite(heightM)) return false;
|
|
const o = address(track, i),
|
|
value = Math.fround(heightM);
|
|
if (track.storage.getFloat32(o + 24) === value) return false;
|
|
track.storage.setFloat32(o + 24, value);
|
|
track.storage.setUint32(
|
|
o + 40,
|
|
track.storage.getUint32(o + 40) | FIX_FLAGS.FLOOR,
|
|
);
|
|
track.revision++;
|
|
return true;
|
|
}
|
|
function anchoredWall(track, clocks) {
|
|
if (!Number.isFinite(track.wallAnchor)) {
|
|
track.wallAnchor = clocks.wallNowMs;
|
|
track.monoAnchor = clocks.monoNowMs;
|
|
}
|
|
return track.wallAnchor + Math.max(0, clocks.monoNowMs - track.monoAnchor);
|
|
}
|
|
/** Stable numeric fields avoid dictionary properties and boxed doubles in hot samples. */
|
|
export function createSample() {
|
|
return {
|
|
displayT: NaN,
|
|
revision: 0,
|
|
nextWakeMonoMs: Infinity,
|
|
phase: 'buffering',
|
|
motion: 'unknown',
|
|
fromSeq: -1,
|
|
toSeq: -1,
|
|
lat: NaN,
|
|
lon: NaN,
|
|
heightM: NaN,
|
|
segmentCourseDeg: NaN,
|
|
segmentSpeedMps: NaN,
|
|
fraction: NaN,
|
|
fromT: NaN,
|
|
toT: NaN,
|
|
nextMotionT: Infinity,
|
|
latestReportAgeMs: NaN,
|
|
actualDelayMs: NaN,
|
|
};
|
|
}
|
|
/** Sample an explicit report time into caller-owned storage. */
|
|
export function sampleAt(track, displayTimeMs, out) {
|
|
out.displayT = displayTimeMs;
|
|
return sampleCurrent(track, out);
|
|
}
|
|
/** The frame path keeps its changing double in storage across this call. */
|
|
function sampleCurrent(track, out) {
|
|
const sampleTime = out.displayT;
|
|
out.revision = track.revision;
|
|
out.nextWakeMonoMs = Infinity;
|
|
out.phase = track.phase;
|
|
out.motion = 'unknown';
|
|
out.fromSeq = -1;
|
|
out.toSeq = -1;
|
|
out.lat = NaN;
|
|
out.lon = NaN;
|
|
out.heightM = NaN;
|
|
out.segmentCourseDeg = NaN;
|
|
out.segmentSpeedMps = 0;
|
|
if (!track.count) return out;
|
|
let i = Math.min(track.bracket, track.count - 1);
|
|
if (timeAt(track, i) > sampleTime) i = 0;
|
|
while (i + 1 < track.count && timeAt(track, i + 1) <= sampleTime) i++;
|
|
track.bracket = i;
|
|
const a = readFix(track, i, track.from);
|
|
const j = sampleTime < a.t ? i : Math.min(i + 1, track.count - 1);
|
|
const b = readFix(track, j, track.to);
|
|
const continuous =
|
|
i !== j && a.epoch === b.epoch && !(b.flags & FIX_FLAGS.BREAK);
|
|
out.fromSeq = a.seq;
|
|
out.toSeq = continuous ? b.seq : a.seq;
|
|
out.fraction = continuous
|
|
? Math.max(0, Math.min(1, (sampleTime - a.t) / (b.t - a.t)))
|
|
: 1;
|
|
out.fromT = a.t;
|
|
out.toT = continuous ? b.t : a.t;
|
|
if (
|
|
track.metricsRevision !== track.revision ||
|
|
track.metricsIndex !== i ||
|
|
track.metricsTo !== j
|
|
) {
|
|
const metres = continuous ? distance(a, b) : 0;
|
|
const metrics = track.metrics;
|
|
metrics.speed = continuous ? (metres * 1000) / (b.t - a.t) : 0;
|
|
metrics.course = NaN;
|
|
if (metres >= (track.policy.courseMinTravelM ?? 12)) {
|
|
metrics.course =
|
|
((Math.atan2(
|
|
(((b.lon - a.lon + 540) % 360) - 180) *
|
|
Math.cos((a.lat * Math.PI) / 180),
|
|
b.lat - a.lat,
|
|
) *
|
|
180) /
|
|
Math.PI +
|
|
360) %
|
|
360;
|
|
} else {
|
|
// Tiny displacement holds the last reliable course in this epoch.
|
|
// With no reliable displacement yet, use the earliest known bearing.
|
|
const p = track.metricsScratchA,
|
|
q = track.metricsScratchB;
|
|
for (let k = i; k >= 0; k--) {
|
|
readFix(track, k, q);
|
|
if (q.epoch !== a.epoch) break;
|
|
if (Number.isFinite(q.bearingDeg)) metrics.course = q.bearingDeg;
|
|
if (k === 0 || q.flags & FIX_FLAGS.BREAK) continue;
|
|
readFix(track, k - 1, p);
|
|
if (
|
|
p.epoch === q.epoch &&
|
|
distance(p, q) >= (track.policy.courseMinTravelM ?? 12)
|
|
) {
|
|
metrics.course =
|
|
((Math.atan2(
|
|
(((q.lon - p.lon + 540) % 360) - 180) *
|
|
Math.cos((p.lat * Math.PI) / 180),
|
|
q.lat - p.lat,
|
|
) *
|
|
180) /
|
|
Math.PI +
|
|
360) %
|
|
360;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
metrics.nextMotionT = Infinity;
|
|
const p = track.metricsScratchA,
|
|
q = track.metricsScratchB;
|
|
for (let k = i; k + 1 < track.count; k++) {
|
|
readFix(track, k, p);
|
|
readFix(track, k + 1, q);
|
|
if (
|
|
p.lat !== q.lat ||
|
|
p.lon !== q.lon ||
|
|
p.epoch !== q.epoch ||
|
|
q.flags & FIX_FLAGS.BREAK
|
|
) {
|
|
metrics.nextMotionT =
|
|
p.epoch !== q.epoch || q.flags & FIX_FLAGS.BREAK ? q.t : p.t;
|
|
break;
|
|
}
|
|
}
|
|
track.metricsRevision = track.revision;
|
|
track.metricsIndex = i;
|
|
track.metricsTo = j;
|
|
}
|
|
const f = out.fraction;
|
|
out.lat = continuous ? a.lat + (b.lat - a.lat) * f : a.lat;
|
|
out.lon = continuous
|
|
? a.lon + (((b.lon - a.lon + 540) % 360) - 180) * f
|
|
: a.lon;
|
|
if (out.lon > 180) out.lon -= 360;
|
|
if (out.lon < -180) out.lon += 360;
|
|
out.heightM = continuous
|
|
? a.heightM + (b.heightM - a.heightM) * f
|
|
: a.heightM;
|
|
out.segmentCourseDeg = track.metrics.course;
|
|
out.segmentSpeedMps = track.metrics.speed;
|
|
out.motion = continuous
|
|
? track.metrics.speed > 0
|
|
? 'moving'
|
|
: 'stopped'
|
|
: 'unknown';
|
|
out.nextMotionT = track.metrics.nextMotionT;
|
|
out.latestReportAgeMs = Number.isFinite(track.wallAnchor)
|
|
? track.wallAnchor +
|
|
track.lastMono -
|
|
track.monoAnchor -
|
|
timeAt(track, track.count - 1)
|
|
: NaN;
|
|
out.actualDelayMs = Number.isFinite(track.wallAnchor)
|
|
? track.wallAnchor + track.lastMono - track.monoAnchor - sampleTime
|
|
: NaN;
|
|
return out;
|
|
}
|
|
export function advance(track, clocks, out) {
|
|
const wall = anchoredWall(track, clocks),
|
|
previousMono = track.lastMono;
|
|
const dt = Number.isFinite(previousMono)
|
|
? Math.max(0, clocks.monoNowMs - previousMono)
|
|
: 0;
|
|
track.lastMono = clocks.monoNowMs;
|
|
if (!track.count) return sampleAt(track, NaN, out);
|
|
const newest = timeAt(track, track.count - 1),
|
|
oldest = timeAt(track, 0);
|
|
const ceiling = Math.min(newest, wall - track.targetDelayMs + track.offset);
|
|
const delayBlocked =
|
|
track.offset === 0 &&
|
|
track.phase !== 'playing' &&
|
|
ceiling < track.displayT &&
|
|
track.displayT < newest;
|
|
if (!Number.isFinite(track.displayT))
|
|
track.displayT = Math.min(
|
|
newest,
|
|
wall - track.targetDelayMs + track.offset,
|
|
);
|
|
else if (
|
|
track.offset === 0 &&
|
|
track.rate !== 0 &&
|
|
(ceiling - track.displayT > 90_000 ||
|
|
(track.truncated && track.displayT < oldest))
|
|
) {
|
|
track.resetReason =
|
|
ceiling - track.displayT > 90_000 ? 'backlog' : 'unavailable-bracket';
|
|
track.displayT = ceiling;
|
|
track.resets++;
|
|
} else if (track.rate !== 0) {
|
|
// While playing, finish the active segment at 1x even if lag grows.
|
|
const activeCeiling =
|
|
track.offset < 0 || track.phase === 'playing' ? newest : ceiling;
|
|
let next = track.displayT + dt * track.rate;
|
|
if (track.phase === 'held' && track.resumePending) {
|
|
const sinceReceipt = Number.isFinite(track.acceptedMono)
|
|
? Math.max(0, clocks.monoNowMs - track.acceptedMono)
|
|
: 0;
|
|
next = track.displayT + Math.min(dt, sinceReceipt) * track.rate;
|
|
}
|
|
track.displayT = Math.max(track.displayT, Math.min(next, activeCeiling));
|
|
}
|
|
track.phase =
|
|
track.rate === 0
|
|
? 'paused'
|
|
: track.displayT < oldest || delayBlocked
|
|
? 'buffering'
|
|
: track.displayT >= newest
|
|
? 'held'
|
|
: 'playing';
|
|
track.resumePending = false;
|
|
out.displayT = track.displayT;
|
|
sampleCurrent(track, out);
|
|
if (out.motion !== 'moving' || track.phase !== 'playing') {
|
|
const boundary = Math.max(oldest, out.nextMotionT);
|
|
if (
|
|
Number.isFinite(boundary) &&
|
|
boundary > track.displayT &&
|
|
track.rate > 0
|
|
)
|
|
out.nextWakeMonoMs =
|
|
clocks.monoNowMs + (boundary - track.displayT) / track.rate;
|
|
}
|
|
if (delayBlocked && track.rate > 0)
|
|
out.nextWakeMonoMs =
|
|
clocks.monoNowMs + Math.max(1, track.displayT - ceiling);
|
|
if (out.latestReportAgeMs > (track.policy.staleMs ?? 90_000))
|
|
out.motion = 'stale';
|
|
return out;
|
|
}
|
|
export function seek(track, displayTimeMs, clocks) {
|
|
if (!Number.isFinite(displayTimeMs))
|
|
throw new TypeError('A seek needs a finite time');
|
|
anchoredWall(track, clocks);
|
|
track.displayT = displayTimeMs;
|
|
track.offset = -1;
|
|
track.rate = 0;
|
|
track.phase = 'paused';
|
|
track.lastMono = clocks.monoNowMs;
|
|
track.bracket = 0;
|
|
track.resets++;
|
|
track.resetReason = 'seek';
|
|
}
|
|
export function setTimeOffsetMs(track, offset, clocks) {
|
|
if (!Number.isFinite(offset) || offset > 0)
|
|
throw new RangeError('Offset must be zero or negative');
|
|
if (Number.isFinite(track.displayT)) {
|
|
track.resets++;
|
|
track.resetReason = 'time-offset';
|
|
}
|
|
track.offset = offset;
|
|
track.rate = 1;
|
|
track.displayT = anchoredWall(track, clocks) - track.targetDelayMs + offset;
|
|
track.lastMono = clocks.monoNowMs;
|
|
track.phase = 'buffering';
|
|
track.bracket = 0;
|
|
}
|
|
export function setRate(track, rate, clocks) {
|
|
if (!Number.isFinite(rate) || rate < 0 || rate > 16)
|
|
throw new RangeError('Rate must be in [0, 16]');
|
|
if (track.offset === 0 && rate !== 0 && rate !== 1)
|
|
throw new RangeError('Live playback uses 1x or hold');
|
|
track.rate = rate;
|
|
track.lastMono = clocks.monoNowMs;
|
|
track.phase = rate === 0 ? 'paused' : 'buffering';
|
|
}
|