Momentum Curve & Incidents
A derived session-intensity 'momentum' curve (weighted, smoothed, 0..1 across 200 bins) + kinematic incident detection (cars stopped on track) fused with race control. Memoized.
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 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<string, unknown>;
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<Record<string, unknown>>(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<Record<string, any>>(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<Record<string, unknown>>((patch as Record<string, unknown>)?.Messages)) {
const rn = String(m?.RacingNumber ?? "");
const msg = String(m?.Message ?? "");
if (rn === num || msg.includes(`(${num})`)) return msg;
}
}
return null;
}