// ============================================================================ // Track 2 / Phase 4 — the "F1 doesn't hand you this" analytics tier. // Built on the Phase-2 projection spine (resample.ts): lateral G-force (v²·curvature), // driving-style aggregates (full-throttle %, heavy braking %, top/avg speed), real // gap-in-metres + true on-track order from arc-length s. Pure (no React/DOM). // // HONEST: every metric is computed from real fed telemetry/geometry. We have NO // steering / DRS / brake-bias / tyre-temp channels (2026 cars) — those are refused, // never placeholdered. Lateral G is from the median racing line's curvature, so it's // a "racing-line" estimate (badged), not a per-wheel measurement. // ============================================================================ import type { TrackOutline } from "../shared/types"; import { projectorFor, carTrack, fastestLapWindow, type LapWindow, type Projector } from "./resample"; interface EngineLike { bundle: { pos: Record; car: Record; meta: { outline: TrackOutline | null } } | null; carTel(num: string, t: number): { spd: number; gear: number; rpm: number; thr: number; brk: number } | null; carPos(num: string, t: number): { x: number; y: number; on: boolean } | null; t: number; } // ---- outline curvature κ (rad per metre), cached per outline ---- const curvCache = new WeakMap(); /** signed-magnitude curvature at each outline point, in rad/metre (uses the projector's metre scale) */ export function outlineCurvature(proj: Projector): Float64Array { const o = proj.o; const cached = curvCache.get(o); if (cached) return cached; const n = o.x.length; const k = new Float64Array(n); for (let i = 0; i < n; i++) { const a = (i - 1 + n) % n; const b = (i + 1) % n; const h1 = Math.atan2(o.y[i] - o.y[a], o.x[i] - o.x[a]); const h2 = Math.atan2(o.y[b] - o.y[i], o.x[b] - o.x[i]); let dth = h2 - h1; while (dth > Math.PI) dth -= 2 * Math.PI; while (dth < -Math.PI) dth += 2 * Math.PI; const arcUnits = Math.hypot(o.x[i] - o.x[a], o.y[i] - o.y[a]) + Math.hypot(o.x[b] - o.x[i], o.y[b] - o.y[i]); const arcM = arcUnits * proj.mPerUnit || 1; k[i] = Math.abs(dth) / arcM; // rad per metre } // light smoothing (curvature from a discrete line is noisy) const sm = new Float64Array(n); for (let i = 0; i < n; i++) sm[i] = (k[(i - 1 + n) % n] + k[i] * 2 + k[(i + 1) % n]) / 4; curvCache.set(o, sm); return sm; } const GRAV = 9.80665; export interface LapAnalytics { num: string; lapMs: number; topSpeed: number; // km/h avgSpeed: number; // km/h fullThrottlePct: number; // % of the lap at ≥98% throttle heavyBrakePct: number; // % of the lap braking (brk ≥ 50) maxLatG: number; // peak lateral g (racing-line estimate) samples: number; } /** driving-style + G analytics for a car's lap window (telemetry × geometry) */ export function lapAnalytics(engine: EngineLike, num: string, lap: LapWindow): LapAnalytics | null { const b = engine.bundle; const proj = projectorFor(engine as never); const car = b?.car[num]; if (!b || !proj || !car || !car.t.length) return null; const curv = outlineCurvature(proj); const n = proj.o.x.length; let top = 0, sumV = 0, full = 0, brake = 0, count = 0, maxG = 0; for (let i = 0; i < car.t.length; i++) { const t = car.t[i]; if (t < lap.tStart || t > lap.tEnd) continue; const v = car.spd[i]; count++; sumV += v; if (v > top) top = v; if (car.thr[i] >= 98) full++; if (car.brk[i] >= 50) brake++; // lateral G at this instant: project the car's smoothed position → curvature const p = engine.carPos(num, t); if (p) { const pr = proj.project(p.x, p.y); const k = curv[Math.min(n - 1, pr.seg)]; const vms = v / 3.6; const g = (vms * vms * k) / GRAV; if (g > maxG && g < 8) maxG = g; // clamp absurd spikes from line noise } } if (count < 5) return null; return { num, lapMs: lap.dur, topSpeed: Math.round(top), avgSpeed: Math.round(sumV / count), fullThrottlePct: Math.round((full / count) * 100), heavyBrakePct: Math.round((brake / count) * 100), maxLatG: Math.round(maxG * 10) / 10, samples: count, }; } /** convenience: analytics for a driver's fastest lap */ export function fastestLapAnalytics(engine: EngineLike, num: string): LapAnalytics | null { const lap = fastestLapWindow(engine as never, num); if (!lap) return null; return lapAnalytics(engine, num, lap); } // ---- live on-track order + gap-in-metres (true geometry, not at-the-line) ---- export interface OnTrackRow { num: string; s: number; // metres along the current lap from S/F gapAheadM: number | null; // metres to the car physically ahead on track } /** * True on-track order at time t from each car's projected s — this leads the * classification line (which only updates at S/F). Cars not on a flying lap * (no fix / stopped) are dropped. NOTE: orders by current-lap s only (good for a * single-lap snapshot / quali out-laps); cross-lap race order needs lap counting. */ export function onTrackOrder(engine: EngineLike, t: number): OnTrackRow[] { const b = engine.bundle; const proj = projectorFor(engine as never); if (!b || !proj) return []; const rows: OnTrackRow[] = []; for (const num of Object.keys(b.pos)) { const ct = carTrack(engine as never, num); if (!ct || !ct.t.length) continue; // nearest sample at-or-before t (skip if stale > 5s) let lo = 0, hi = ct.t.length - 1; if (t < ct.t[0] || t > ct.t[hi] + 5000) continue; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (ct.t[mid] <= t) lo = mid; else hi = mid - 1; } if (t - ct.t[lo] > 5000) continue; rows.push({ num, s: ct.s[lo], gapAheadM: null }); } rows.sort((a, b2) => b2.s - a.s); // furthest along first for (let i = 1; i < rows.length; i++) rows[i].gapAheadM = Math.round(rows[i - 1].s - rows[i].s); return rows; }