// 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() { 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>(); /** * 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 { return lapCache(e, () => { const out: Record = {}; const b = e.bundle; if (!b) return out; let td: any; const lastVal: Record = {}; const best: Record = {}; let overall = Infinity; for (const [, feed, patch] of b.events) { if (feed !== "TimingData") continue; td = applyPatch(td, patch); for (const [num, line] of asEntries(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 }>(); /** * 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 } { return traceCache(e, () => { const byDriver: Record = {}; let maxLap = 0; const b = e.bundle; if (!b) return { maxLap, byDriver }; let td: any; const lastLap: Record = {}; for (const [, feed, patch] of b.events) { if (feed !== "TimingData") continue; td = applyPatch(td, patch); for (const [num, line] of asEntries(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 }>(); /** * 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 } { return gapCache(e, () => { const byDriver: Record = {}; let maxLap = 0; const b = e.bundle; if (!b) return { maxLap, byDriver }; let td: any; const lastLap: Record = {}; for (const [, feed, patch] of b.events) { if (feed !== "TimingData") continue; td = applyPatch(td, patch); for (const [num, line] of asEntries(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>(); /** * 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 { return secCache(e, () => { const out: Record = {}; const b = e.bundle; if (!b) return out; let td: any; const lastVal: Record = {}; for (const [, feed, patch] of b.events) { if (feed !== "TimingData") continue; td = applyPatch(td, patch); for (const [num, line] of asEntries(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(line?.Sectors).map((sec) => parseSec(sec?.Value)); (out[num] ??= []).push({ lap, s: [secs[0] ?? null, secs[1] ?? null, secs[2] ?? null] }); } } return out; }); }