// ============================================================================ // 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 = { 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 | 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): 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(); 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>(); /** 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 }; }