2026-07-03 23:48:50 -04:00
|
|
|
// Cumulative lap-time delta math for driver comparison charts.
|
|
|
|
|
// Pure functions only — no React — so everything is unit-testable.
|
|
|
|
|
|
|
|
|
|
export interface DeltaSeries {
|
|
|
|
|
label: string
|
|
|
|
|
color: string
|
|
|
|
|
/** Lap duration in seconds; null = no time (pit/out lap). */
|
|
|
|
|
lapTimes: (number | null)[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DriverDeltaResult {
|
|
|
|
|
label: string
|
|
|
|
|
color: string
|
|
|
|
|
/** Cumulative delta vs reference (seconds); null when that lap has no time. */
|
|
|
|
|
deltas: (number | null)[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Format a delta value for axis labels and tooltips (e.g. "+2.5s", "-1.2s"). */
|
|
|
|
|
export function formatDeltaSeconds(delta: number): string {
|
|
|
|
|
const sign = delta >= 0 ? '+' : ''
|
|
|
|
|
return `${sign}${delta.toFixed(1)}s`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 01:04:55 -04:00
|
|
|
/**
|
|
|
|
|
* Cumulative lap time aligned to reference-valid laps only.
|
|
|
|
|
* Laps where the reference is null are skipped for every series so later deltas
|
|
|
|
|
* do not compare against a frozen baseline while challengers keep accumulating.
|
|
|
|
|
*/
|
|
|
|
|
function buildAlignedCumulative(
|
|
|
|
|
lapTimes: ReadonlyArray<number | null>,
|
|
|
|
|
referenceLapTimes: ReadonlyArray<number | null>,
|
|
|
|
|
): number[] {
|
2026-07-03 23:48:50 -04:00
|
|
|
const cumulative: number[] = []
|
|
|
|
|
let running = 0
|
2026-07-04 01:04:55 -04:00
|
|
|
const length = Math.max(lapTimes.length, referenceLapTimes.length)
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
|
|
|
if (referenceLapTimes[i] === null) {
|
|
|
|
|
cumulative.push(running)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const lap = lapTimes[i]
|
2026-07-03 23:48:50 -04:00
|
|
|
if (lap !== null) {
|
|
|
|
|
running += lap
|
|
|
|
|
}
|
|
|
|
|
cumulative.push(running)
|
|
|
|
|
}
|
2026-07-04 01:04:55 -04:00
|
|
|
|
2026-07-03 23:48:50 -04:00
|
|
|
return cumulative
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveReference(
|
|
|
|
|
series: ReadonlyArray<DeltaSeries>,
|
|
|
|
|
referenceLabel?: string,
|
|
|
|
|
): DeltaSeries | null {
|
|
|
|
|
if (series.length === 0) return null
|
|
|
|
|
if (referenceLabel) {
|
|
|
|
|
return series.find((s) => s.label === referenceLabel) ?? series[0]
|
|
|
|
|
}
|
|
|
|
|
return series[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute per-lap cumulative time delta for each non-reference driver.
|
|
|
|
|
* Positive = behind reference; negative = ahead.
|
2026-07-04 01:04:55 -04:00
|
|
|
* Deltas are only emitted where the reference lap is valid; reference-null laps
|
|
|
|
|
* gap every series. Challenger-null laps gap only that driver's line.
|
2026-07-03 23:48:50 -04:00
|
|
|
*/
|
|
|
|
|
export function computeCumulativeDeltas(
|
|
|
|
|
series: ReadonlyArray<DeltaSeries>,
|
|
|
|
|
referenceLabel?: string,
|
|
|
|
|
): DriverDeltaResult[] {
|
|
|
|
|
const reference = resolveReference(series, referenceLabel)
|
|
|
|
|
if (!reference) return []
|
|
|
|
|
|
2026-07-04 01:04:55 -04:00
|
|
|
const refLapTimes = reference.lapTimes
|
|
|
|
|
const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
|
2026-07-03 23:48:50 -04:00
|
|
|
|
|
|
|
|
return series
|
|
|
|
|
.filter((s) => s.label !== reference.label)
|
|
|
|
|
.map((driver) => {
|
2026-07-04 01:04:55 -04:00
|
|
|
const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
|
2026-07-03 23:48:50 -04:00
|
|
|
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
|
|
|
|
|
const deltas: (number | null)[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < lapCount; i++) {
|
2026-07-04 01:04:55 -04:00
|
|
|
if (refLapTimes[i] === null || driver.lapTimes[i] === null) {
|
2026-07-03 23:48:50 -04:00
|
|
|
deltas.push(null)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-04 01:04:55 -04:00
|
|
|
deltas.push(driverCumulative[i] - refCumulative[i])
|
2026-07-03 23:48:50 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
label: driver.label,
|
|
|
|
|
color: driver.color,
|
|
|
|
|
deltas,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Split delta samples into contiguous SVG polyline point strings (gaps at null laps). */
|
|
|
|
|
export function deltaPolylineSegments(
|
|
|
|
|
deltas: ReadonlyArray<number | null>,
|
|
|
|
|
toPoint: (lapIndex: number, delta: number) => string,
|
|
|
|
|
): string[] {
|
|
|
|
|
const segments: string[] = []
|
|
|
|
|
let current: string[] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < deltas.length; i++) {
|
|
|
|
|
const value = deltas[i]
|
|
|
|
|
if (value === null) {
|
|
|
|
|
if (current.length > 0) {
|
|
|
|
|
segments.push(current.join(' '))
|
|
|
|
|
current = []
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
current.push(toPoint(i, value))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (current.length > 0) {
|
|
|
|
|
segments.push(current.join(' '))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return segments
|
|
|
|
|
}
|