The Kit/Patterns

Resample Spine (GPS to centerline)

The projection spine: projects raw GPS samples onto a centerline -> arc-length s (m) + signed lateral offset d (m), with per-track metre calibration. Powers distance-aligned comparison + real lap windows. Pure, server-safe.

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.

resample.ts
// ============================================================================
// Track 2 / Phase 2 — THE PROJECTION PRIMITIVE (the spine).
// Project raw GPS car samples onto the 720-pt median centerline → arc-length `s`
// (metres) + signed lateral offset `d` (metres). Per-circuit metre calibration
// (known length ÷ outline arc-length). Pure (no React/DOM) — server-safe, like the
// outline geometry. Powers real lap/sector boundary crossings, speed-by-distance
// (Phase 3), gap-in-metres / on-track order / overtakes / curvature (Phase 4).
//
// HONEST: world units are RAW GPS (~0.1 m/unit, varies per circuit) — we calibrate,
// never assume 1:1. The centerline is the clean median racing line, so lateral `d`
// is relative to that line, not the geometric track centre.
// ============================================================================

import type { SessionBundle, TrackOutline } from "../shared/types";

/**
 * Official lap lengths (metres) for 2026 circuits, keyed by the F1
 * `sessionInfo.Meeting.Circuit.ShortName` (lowercased). Used only to convert world
 * units → metres; an unknown circuit falls back to the measured ~0.1013 m/unit GPS
 * scale (flagged `calibrated:false`). Add circuits here as needed.
 */
const CIRCUIT_LENGTH_M: Record<string, number> = {
  catalunya: 4657,
  shanghai: 5451,
  "shanghai international circuit": 5451,
  melbourne: 5278,
  "albert park": 5278,
  suzuka: 5807,
  sakhir: 5412,
  "bahrain international circuit": 5412,
  jeddah: 6174,
  miami: 5412,
  imola: 4909,
  monaco: 3337,
  "monte carlo": 3337,
  montreal: 4361,
  "gilles villeneuve": 4361,
  spielberg: 4318,
  "red bull ring": 4318,
  silverstone: 5891,
  hungaroring: 4381,
  "spa-francorchamps": 7004,
  spa: 7004,
  zandvoort: 4259,
  monza: 5793,
  baku: 6003,
  "marina bay": 4940,
  singapore: 4940,
  "the americas": 5513,
  cota: 5513,
  austin: 5513,
  "hermanos rodriguez": 4304,
  "mexico city": 4304,
  interlagos: 4309,
  "jose carlos pace": 4309,
  "las vegas": 6201,
  lusail: 5419,
  qatar: 5419,
  "yas marina": 5281,
  "abu dhabi": 5281,
};

const FALLBACK_M_PER_UNIT = 0.1013;

/** total arc-length of the closed outline, in world units */
export function outlineArc(o: TrackOutline): number {
  const n = o.x.length;
  let total = 0;
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n;
    total += Math.hypot(o.x[j] - o.x[i], o.y[j] - o.y[i]);
  }
  return total;
}

function circuitKey(sessionInfo: Record<string, unknown> | undefined): string | null {
  const m = (sessionInfo as { Meeting?: { Circuit?: { ShortName?: string }; Name?: string } } | undefined)?.Meeting;
  const sn = m?.Circuit?.ShortName;
  if (sn) return sn.toLowerCase().trim();
  if (m?.Name) return m.Name.toLowerCase().trim();
  return null;
}

export interface Projection {
  s: number; // metres along the centerline from the S/F seam
  d: number; // signed lateral offset (metres), + = left of racing direction
  frac: number; // 0..1 lap fraction
  seg: number; // outline segment index (for the next call's hint)
}

export interface Projector {
  o: TrackOutline;
  total: number; // world units
  lengthM: number; // calibrated lap length (metres)
  mPerUnit: number;
  calibrated: boolean; // false → fell back to the GPS-scale estimate
  /** project a world-unit point onto the centerline. Pass `hint` (prev seg) for O(1). */
  project(x: number, y: number, hint?: number): Projection;
}

/** build a projector for an outline (cumulative arc + nearest-segment search) */
export function makeProjector(o: TrackOutline, sessionInfo?: Record<string, unknown>): Projector {
  const n = o.x.length;
  const cum = new Float64Array(n + 1);
  const segLen = new Float64Array(n);
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n;
    const L = Math.hypot(o.x[j] - o.x[i], o.y[j] - o.y[i]);
    segLen[i] = L;
    cum[i + 1] = cum[i] + L;
  }
  const total = cum[n];
  const key = circuitKey(sessionInfo);
  const known = key ? CIRCUIT_LENGTH_M[key] : undefined;
  const mPerUnit = known && total > 0 ? known / total : FALLBACK_M_PER_UNIT;
  const lengthM = total * mPerUnit;
  const WIN = 45; // ±segments to search around the hint before falling back to a full scan

  const projOnSeg = (x: number, y: number, i: number) => {
    const j = (i + 1) % n;
    const ax = o.x[i], ay = o.y[i];
    const bx = o.x[j], by = o.y[j];
    const ex = bx - ax, ey = by - ay;
    const L2 = ex * ex + ey * ey || 1;
    let t = ((x - ax) * ex + (y - ay) * ey) / L2;
    if (t < 0) t = 0;
    else if (t > 1) t = 1;
    const fx = ax + ex * t, fy = ay + ey * t;
    const dx = x - fx, dy = y - fy;
    const dist2 = dx * dx + dy * dy;
    return { t, dist2, ex, ey };
  };

  const project = (x: number, y: number, hint?: number): Projection => {
    let bestI = 0;
    let bestT = 0;
    let bestD2 = Infinity;
    let bestEx = 1, bestEy = 0;
    const consider = (i: number) => {
      const r = projOnSeg(x, y, i);
      if (r.dist2 < bestD2) {
        bestD2 = r.dist2;
        bestI = i;
        bestT = r.t;
        bestEx = r.ex;
        bestEy = r.ey;
      }
    };
    if (hint != null) {
      for (let k = -WIN; k <= WIN; k++) consider((hint + k + n) % n);
    }
    // full scan when no hint, or the windowed match is implausibly far (teleport / pit)
    if (hint == null || bestD2 > 600 * 600) {
      bestD2 = Infinity;
      for (let i = 0; i < n; i++) consider(i);
    }
    const sUnits = cum[bestI] + bestT * segLen[bestI];
    // signed lateral: cross product of tangent × (point − foot)
    const fx = o.x[bestI] + bestEx * bestT, fy = o.y[bestI] + bestEy * bestT;
    const cross = bestEx * (y - fy) - bestEy * (x - fx);
    const dMag = Math.sqrt(bestD2) * mPerUnit;
    return {
      s: sUnits * mPerUnit,
      d: cross >= 0 ? dMag : -dMag,
      frac: total > 0 ? sUnits / total : 0,
      seg: bestI,
    };
  };

  return { o, total, lengthM, mPerUnit, calibrated: Boolean(known), project };
}

// ---- per-bundle projector cache (geometry is fixed for a bundle) ----
const projCache = new WeakMap<object, Projector>();

interface EngineLike {
  bundle: SessionBundle | null;
}

export function projectorFor(engine: EngineLike): Projector | null {
  const o = engine.bundle?.meta.outline;
  if (!o || !o.x?.length) return null;
  let p = projCache.get(o);
  if (!p) {
    p = makeProjector(o, engine.bundle?.meta.sessionInfo);
    projCache.set(o, p);
  }
  return p;
}

// ---- per-car arc-length track (project every raw GPS sample) ----
export interface CarTrack {
  t: number[]; // stream-clock ms
  s: number[]; // metres along the lap (0..lengthM, NOT cumulative across laps)
  d: number[]; // signed lateral metres
  seg: number[]; // outline segment per sample
}

const carTrackCache = new WeakMap<object, Map<string, CarTrack>>();

/** project a car's raw position column onto the centerline (cached per bundle+car) */
export function carTrack(engine: EngineLike, num: string): CarTrack | null {
  const b = engine.bundle;
  const proj = projectorFor(engine);
  const c = b?.pos[num];
  if (!b || !proj || !c || !c.t.length) return null;
  let byNum = carTrackCache.get(b);
  if (!byNum) {
    byNum = new Map();
    carTrackCache.set(b, byNum);
  }
  const hit = byNum.get(num);
  if (hit) return hit;
  const t: number[] = [];
  const s: number[] = [];
  const d: number[] = [];
  const seg: number[] = [];
  let hint: number | undefined;
  for (let i = 0; i < c.t.length; i++) {
    if (c.st[i] !== 1) continue;
    const x = c.x[i], y = c.y[i];
    if (x === 0 && y === 0) continue; // no-fix sentinel
    const p = proj.project(x, y, hint);
    hint = p.seg;
    t.push(c.t[i]);
    s.push(p.s);
    d.push(p.d);
    seg.push(p.seg);
  }
  const out = { t, s, d, seg };
  byNum.set(num, out);
  return out;
}

/** interpolate s (metres) for a car at any time t (handles the S/F wrap) */
export function sAt(ct: CarTrack, t: number): number | null {
  const arr = ct.t;
  if (!arr.length) return null;
  let lo = 0, hi = arr.length - 1;
  if (t <= arr[0]) return ct.s[0];
  if (t >= arr[hi]) return ct.s[hi];
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if (arr[mid] < t) lo = mid + 1;
    else hi = mid;
  }
  const i = Math.max(1, lo);
  const t0 = arr[i - 1], t1 = arr[i];
  const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0;
  let s0 = ct.s[i - 1], s1 = ct.s[i];
  return s0 + (s1 - s0) * f; // caller handles wrap if needed
}

// ---- lap boundaries from S/F crossings (the spine's first payoff) ----
export interface LapCrossing {
  t: number; // stream-clock ms when s crosses the seam
}

/**
 * Detect S/F line crossings from the projected s-track: s jumps from near the
 * lap length back toward 0. Interpolates the exact crossing instant. Gives REAL
 * lap windows in stream-clock ms (the engine otherwise only knows lap *times*,
 * not start/end instants).
 */
export function lapCrossings(ct: CarTrack, lengthM: number): number[] {
  const out: number[] = [];
  const half = lengthM * 0.5;
  for (let i = 1; i < ct.t.length; i++) {
    const a = ct.s[i - 1];
    const b = ct.s[i];
    // a wrap = big backward jump (end-of-lap → start) with both ends near the seam
    if (a - b > half && a > lengthM * 0.6 && b < lengthM * 0.4) {
      // fraction of the gap [a..length] before wrap
      const remA = lengthM - a;
      const span = remA + b; // distance travelled across the seam
      const f = span > 0 ? remA / span : 0.5;
      out.push(ct.t[i - 1] + (ct.t[i] - ct.t[i - 1]) * f);
    }
  }
  return out;
}

export interface LapWindow {
  tStart: number;
  tEnd: number;
  dur: number; // ms
}

/** real lap windows (S/F crossing to S/F crossing) for a car, via the projected s-track */
export function lapWindows(engine: EngineLike, num: string): LapWindow[] {
  const ct = carTrack(engine, num);
  const proj = projectorFor(engine);
  if (!ct || !proj) return [];
  const cross = lapCrossings(ct, proj.lengthM);
  const out: LapWindow[] = [];
  for (let i = 1; i < cross.length; i++) {
    const dur = cross[i] - cross[i - 1];
    if (dur > 50_000 && dur < 200_000) out.push({ tStart: cross[i - 1], tEnd: cross[i], dur });
  }
  return out;
}

/** a car's fastest complete lap window (for the by-distance compare / ghost) */
export function fastestLapWindow(engine: EngineLike, num: string): LapWindow | null {
  const w = lapWindows(engine, num);
  if (!w.length) return null;
  return w.reduce((a, b) => (b.dur < a.dur ? b : a));
}

// ---- speed-by-distance for a lap window (Phase 3 primitive) ----
export interface DistanceTrace {
  s: number[]; // metres, ascending grid
  v: number[]; // km/h at each s
  t: number[]; // stream-clock ms at each s (for delta-time)
  lengthM: number;
}

/**
 * Resample a car's lap [tStart,tEnd] onto an even s-grid: project its positions to
 * s, pair each with speed (from the car telemetry column), and monotonically map
 * t and v over s. Returns {s,v,t} on a `gridN`-point grid for clean by-distance
 * traces + delta-time. Telemetry reader is injected so this stays React/DOM-free.
 */
export function speedByDistance(
  ct: CarTrack,
  tStart: number,
  tEnd: number,
  lengthM: number,
  spdAt: (t: number) => number | null,
  gridN = 400,
): DistanceTrace | null {
  // collect (s, t) within the window; unwrap s so it's monotonic from lap start
  const sRaw: number[] = [];
  const tRaw: number[] = [];
  let sBase = 0;
  let prev = -1;
  for (let i = 0; i < ct.t.length; i++) {
    const tt = ct.t[i];
    if (tt < tStart || tt > tEnd) continue;
    let s = ct.s[i];
    if (prev >= 0 && s + sBase < prev - lengthM * 0.5) sBase += lengthM; // crossed the seam mid-window
    const su = s + sBase;
    if (su < prev) continue; // non-monotonic blip — skip
    sRaw.push(su);
    tRaw.push(tt);
    prev = su;
  }
  if (sRaw.length < 8) return null;
  const s0 = sRaw[0];
  const sEnd = sRaw[sRaw.length - 1];
  const lapLen = sEnd - s0;
  if (lapLen < lengthM * 0.5) return null; // partial lap
  const s: number[] = new Array(gridN);
  const v: number[] = new Array(gridN);
  const t: number[] = new Array(gridN);
  let j = 1;
  for (let g = 0; g < gridN; g++) {
    const target = s0 + (lapLen * g) / (gridN - 1);
    while (j < sRaw.length - 1 && sRaw[j] < target) j++;
    const a = j - 1, b = j;
    const f = sRaw[b] > sRaw[a] ? (target - sRaw[a]) / (sRaw[b] - sRaw[a]) : 0;
    const tt = tRaw[a] + (tRaw[b] - tRaw[a]) * f;
    s[g] = (target - s0); // 0..lapLen metres
    t[g] = tt;
    v[g] = spdAt(tt) ?? 0;
  }
  return { s, v, t, lengthM: lapLen };
}
← The whole kit