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.
// ============================================================================
// 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 };
}