The Kit/Patterns

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.

history.ts
// 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;
  });
}
← The whole kit