Event-Replay History
Per-lap / per-driver series (lap times, race trace, gap-to-leader, sectors) built by REPLAYING the event stream rather than reading cursor selectors. Memoized on (bundle, events.length). Pure + engine-agnostic.
Reference implementation: this source keeps imports to modules from its home codebase. Read it for the technique, adapt the imports to yours; it is not drop-in compile-ready, on purpose.
// Event-replay history selectors. The merged cursor selectors (selectors.ts) only
// expose the CURRENT value at engine.t; to build per-lap / per-driver *series* we
// replay the preserved, unmerged patch log in bundle.events and fold a running
// state, emitting on the field-change of interest. Each selector is memoized on
// (bundle identity, events.length) — the same pattern as timelineMarkers — so a
// chart can call it every render for free.
import type { SessionEngine } from "./SessionEngine";
import { applyPatch, asEntries, asArray } from "../shared/merge";
import { parseLapTime } from "../shared/feeds";
/** seconds from a feed value: "1:18.305" (lap) or "28.456" (sector) → seconds, else null */
function parseSec(v: unknown): number | null {
if (typeof v !== "string" || !v) return null;
if (v.includes(":")) {
const ms = parseLapTime(v);
return ms == null ? null : ms / 1000;
}
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
}
/** "+1.234"/"1.234" → 1.234s; "+1 LAP" / blank → null (lapped breaks the series) */
function gapToSec(v: unknown): number | null {
if (typeof v !== "string") return null;
const m = /^\+?(\d+(?:\.\d+)?)$/.exec(v.trim());
return m ? Number(m[1]) : null;
}
/** memo keyed on bundle identity + event count (events only ever append) */
function replayCache<T>() {
let bundle: unknown = null;
let len = -1;
let val: T;
return (e: SessionEngine, compute: () => T): T => {
const n = e.bundle?.events.length ?? -1;
if (e.bundle === bundle && n === len) return val;
bundle = e.bundle;
len = n;
val = compute();
return val;
};
}
export interface LapPoint {
/** completed lap number (TimingData.NumberOfLaps at completion) */
lap: number;
/** lap time in ms */
ms: number;
/** raw feed value, e.g. "1:18.305" */
value: string;
/** the driver's personal best at the moment it was set */
personalBest: boolean;
/** set a new session-overall fastest (a purple lap) */
overallBest: boolean;
/** out-lap / in-lap / clearly non-representative (pit or traffic) — heuristic */
outIn: boolean;
}
const lapCache = replayCache<Record<string, LapPoint[]>>();
/**
* Per-driver sequence of completed laps with times, recovered by replaying
* TimingData. A new lap is emitted whenever a driver's LastLapTime.Value changes.
* REAL feed data (lap times are exact); outIn is a labelled heuristic (a lap far
* above the driver's running best is treated as an out/in/compromised lap).
*/
export function lapTimeSeries(e: SessionEngine): Record<string, LapPoint[]> {
return lapCache(e, () => {
const out: Record<string, LapPoint[]> = {};
const b = e.bundle;
if (!b) return out;
let td: any;
const lastVal: Record<string, string> = {};
const best: Record<string, number> = {};
let overall = Infinity;
for (const [, feed, patch] of b.events) {
if (feed !== "TimingData") continue;
td = applyPatch(td, patch);
for (const [num, line] of asEntries<any>(td?.Lines)) {
const v = line?.LastLapTime?.Value;
if (!v || typeof v !== "string") continue;
if (lastVal[num] === v) continue;
lastVal[num] = v;
const ms = parseLapTime(v);
if (ms === null || ms <= 0) continue;
const arr = (out[num] ??= []);
const lap = line.NumberOfLaps != null ? Number(line.NumberOfLaps) : arr.length + 1;
const prevBest = best[num] ?? Infinity;
const newOverall = ms < overall;
if (ms < prevBest) best[num] = ms;
if (newOverall) overall = ms;
arr.push({
lap,
ms,
value: v,
personalBest: Boolean(line.LastLapTime?.PersonalFastest) || ms < prevBest,
overallBest: Boolean(line.LastLapTime?.OverallFastest) || newOverall,
outIn: ms > prevBest * 1.5 && prevBest !== Infinity,
});
}
}
return out;
});
}
/** the session-overall fastest lap ms across all drivers (from the replay), or null */
export function overallFastestMs(e: SessionEngine): number | null {
let best: number | null = null;
const series = lapTimeSeries(e);
for (const arr of Object.values(series)) {
for (const p of arr) if (best === null || p.ms < best) best = p.ms;
}
return best;
}
// ---- position over laps (race trace) ----
export interface PosPoint { lap: number; pos: number; }
const traceCache = replayCache<{ maxLap: number; byDriver: Record<string, PosPoint[]> }>();
/**
* Per-driver classified position at each lap completion (the race-trace / lap chart).
* REAL feed data, but it's the official order at the timing line — it lags on-track
* overtakes between line crossings (stated on the panel).
*/
export function raceTrace(e: SessionEngine): { maxLap: number; byDriver: Record<string, PosPoint[]> } {
return traceCache(e, () => {
const byDriver: Record<string, PosPoint[]> = {};
let maxLap = 0;
const b = e.bundle;
if (!b) return { maxLap, byDriver };
let td: any;
const lastLap: Record<string, number> = {};
for (const [, feed, patch] of b.events) {
if (feed !== "TimingData") continue;
td = applyPatch(td, patch);
for (const [num, line] of asEntries<any>(td?.Lines)) {
const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : null;
const pos = line?.Position != null ? Number(line.Position) : null;
if (lap == null || pos == null || pos < 1 || lastLap[num] === lap) continue;
lastLap[num] = lap;
(byDriver[num] ??= []).push({ lap, pos });
if (lap > maxLap) maxLap = lap;
}
}
return { maxLap, byDriver };
});
}
// ---- gap-to-leader over laps ----
export interface GapPoint { lap: number; gap: number; }
const gapCache = replayCache<{ maxLap: number; byDriver: Record<string, GapPoint[]> }>();
/**
* Per-driver gap-to-leader (seconds) sampled at each of the driver's lap completions.
* REAL (GapToLeader), DERIVED only in that it's sampled per lap; lapped cars
* ("+1 LAP" → null) break the series rather than collapsing to 0.
*/
export function gapSeries(e: SessionEngine): { maxLap: number; byDriver: Record<string, GapPoint[]> } {
return gapCache(e, () => {
const byDriver: Record<string, GapPoint[]> = {};
let maxLap = 0;
const b = e.bundle;
if (!b) return { maxLap, byDriver };
let td: any;
const lastLap: Record<string, number> = {};
for (const [, feed, patch] of b.events) {
if (feed !== "TimingData") continue;
td = applyPatch(td, patch);
for (const [num, line] of asEntries<any>(td?.Lines)) {
const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : null;
if (lap == null || lastLap[num] === lap) continue;
lastLap[num] = lap;
const pos = Number(line?.Position ?? 99);
const gap = pos === 1 ? 0 : gapToSec(line?.GapToLeader);
if (gap == null) continue;
(byDriver[num] ??= []).push({ lap, gap });
if (lap > maxLap) maxLap = lap;
}
}
return { maxLap, byDriver };
});
}
// ---- sector times per lap ----
export interface SectorLap { lap: number; s: (number | null)[]; }
const secCache = replayCache<Record<string, SectorLap[]>>();
/**
* Per-driver per-lap sector times (seconds, S1/S2/S3) recovered at lap completion.
* REAL feed values; a sector not yet filled is null (async-safe).
*/
export function sectorSeries(e: SessionEngine): Record<string, SectorLap[]> {
return secCache(e, () => {
const out: Record<string, SectorLap[]> = {};
const b = e.bundle;
if (!b) return out;
let td: any;
const lastVal: Record<string, string> = {};
for (const [, feed, patch] of b.events) {
if (feed !== "TimingData") continue;
td = applyPatch(td, patch);
for (const [num, line] of asEntries<any>(td?.Lines)) {
const v = line?.LastLapTime?.Value;
if (!v || typeof v !== "string" || lastVal[num] === v) continue;
lastVal[num] = v;
const lap = line?.NumberOfLaps != null ? Number(line.NumberOfLaps) : (out[num]?.length ?? 0) + 1;
const secs = asArray<any>(line?.Sectors).map((sec) => parseSec(sec?.Value));
(out[num] ??= []).push({ lap, s: [secs[0] ?? null, secs[1] ?? null, secs[2] ?? null] });
}
}
return out;
});
}