// ============================================================================ // Track 2 / Phase 6 — event engine + momentum. // A derived session-INTENSITY curve (the "race narrative") under the scrubber, // computed by replaying the real event log and weighting what's happening: // incidents (red/SC/yellow), fastest-lap pushes, stewards/car events, radio, pits. // Plus kinematic incident detection (a car going stationary on track) fused with // the official Race Control text. Pure (no React). Memoized on (bundle, events.length). // // HONEST: every weight comes from a real fed event; intensity is a derived index // (badged "est."), not an official metric. Kinematic "stopped on track" is detected // from real position data, confirmed against Race Control / Retired flags. // ============================================================================ import type { FeedEvent, SessionBundle } from "../shared/types"; import { asEntries } from "../shared/merge"; export interface MomentumBin { f: number; // 0..1 fraction of the session timeline v: number; // 0..1 normalized intensity } export interface Momentum { bins: MomentumBin[]; peakF: number; // fraction of the single most intense moment (auto-highlight anchor) } interface EngineLike { bundle: SessionBundle | null; } let cacheKey = -1; let cacheBundle: unknown = null; let cache: Momentum | null = null; const BINS = 200; /** weight a single event by how much it moves the session narrative */ function weightOf(feed: string, patch: unknown): number { const p = patch as Record; if (feed === "TrackStatus") { const st = String(p?.Status ?? ""); return st === "5" ? 12 : st === "4" || st === "6" ? 9 : st === "2" ? 6 : st === "7" ? 2 : 0.5; } if (feed === "RaceControlMessages") { let w = 0; for (const [, m] of asEntries>(p?.Messages)) { const cat = String(m?.Category ?? ""); const flag = String(m?.Flag ?? "").toUpperCase(); if (cat === "SafetyCar") w += 8; else if (cat === "CarEvent") w += 6; else if (flag === "RED") w += 10; else if (flag.includes("YELLOW")) w += 4; else if (flag === "CHEQUERED") w += 3; else if (cat === "Drs") w += 0.4; else w += 3; // stewards / incident notes / other } return w; } if (feed === "TimingData") { let w = 0; for (const [, line] of asEntries>(p?.Lines)) { if (line?.LastLapTime?.OverallFastest === true || line?.LastLapTime?.OverallFastest === "true") w += 4; else if (line?.LastLapTime?.Value) w += 0.6; // a completed lap = mild activity if (typeof line?.InPit !== "undefined") w += 0.5; } return w; } if (feed === "TeamRadio") return 2; if (feed === "PitLaneTimeCollection") return 1; return 0; } /** smoothed, normalized session-intensity curve (memoized per bundle) */ export function momentumCurve(engine: EngineLike): Momentum { const b = engine.bundle; if (!b) return { bins: [], peakF: 0 }; if (b === cacheBundle && b.events.length === cacheKey && cache) return cache; const t1 = b.meta.t1 || (b.events.length ? b.events[b.events.length - 1][0] : 1); const raw = new Float64Array(BINS); for (const [t, feed, patch] of b.events as FeedEvent[]) { const w = weightOf(feed, patch); if (!w) continue; const bi = Math.min(BINS - 1, Math.max(0, Math.floor((t / t1) * BINS))); raw[bi] += w; } // gaussian-ish smoothing (3 box passes) let cur = raw; for (let pass = 0; pass < 3; pass++) { const next = new Float64Array(BINS); for (let i = 0; i < BINS; i++) { const a = cur[Math.max(0, i - 1)]; const c = cur[Math.min(BINS - 1, i + 1)]; next[i] = (a + cur[i] * 2 + c) / 4; } cur = next; } let max = 0, peakI = 0; for (let i = 0; i < BINS; i++) if (cur[i] > max) { max = cur[i]; peakI = i; } const bins: MomentumBin[] = []; for (let i = 0; i < BINS; i++) bins.push({ f: (i + 0.5) / BINS, v: max > 0 ? cur[i] / max : 0 }); const out = { bins, peakF: (peakI + 0.5) / BINS }; cacheKey = b.events.length; cacheBundle = b; cache = out; return out; } // ---- kinematic incident detection (cars stopping on track), fused with Race Control ---- export interface Incident { t: number; // stream-clock ms num: string; kind: "stopped" | "retired"; rc: string | null; // matched Race Control / steward text, when available } /** * Cars that go stationary out on track (not in the pit lane), detected from the raw * position column (no movement over a window), cross-referenced with Retired flags. * The official Race Control text (turn/driver) is attached when a nearby message exists. */ export function incidents(engine: EngineLike): Incident[] { const b = engine.bundle; if (!b) return []; const out: Incident[] = []; const WIN = 12_000; // ms stationary const MOVE = 250; // world units (≈25 m) for (const [num, c] of Object.entries(b.pos)) { let flagged = false; for (let i = 0; i < c.t.length; i++) { if (c.st[i] !== 1 || (c.x[i] === 0 && c.y[i] === 0)) continue; // find a sample ~WIN ago const t0 = c.t[i] - WIN; let j = i; while (j > 0 && c.t[j] > t0) j--; if (c.t[i] - c.t[j] < WIN * 0.7) continue; if (c.x[j] === 0 && c.y[j] === 0) continue; const moved = Math.hypot(c.x[i] - c.x[j], c.y[i] - c.y[j]); if (moved < MOVE) { if (!flagged) { out.push({ t: c.t[i], num, kind: "stopped", rc: rcNear(b, c.t[i], num) }); flagged = true; } } else { flagged = false; } } } return out.sort((a, z) => a.t - z.t); } function rcNear(b: SessionBundle, t: number, num: string): string | null { for (const [et, feed, patch] of b.events as FeedEvent[]) { if (feed !== "RaceControlMessages") continue; if (Math.abs(et - t) > 30_000) continue; for (const [, m] of asEntries>((patch as Record)?.Messages)) { const rn = String(m?.RacingNumber ?? ""); const msg = String(m?.Message ?? ""); if (rn === num || msg.includes(`(${num})`)) return msg; } } return null; }