diff --git a/frontend/src/components/CompareView.tsx b/frontend/src/components/CompareView.tsx
index 2328b09..eebe322 100644
--- a/frontend/src/components/CompareView.tsx
+++ b/frontend/src/components/CompareView.tsx
@@ -229,7 +229,9 @@ export function CompareView({ sessionKey, results, drivers }: Props) {
Race pace
- Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
+ Cumulative lap-time delta vs {referenceLabel ?? 'reference'}. Deltas are plotted
+ only where the reference lap is valid; gaps appear when the reference has no lap
+ time.
): number[] {
+/**
+ * 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,
+ referenceLapTimes: ReadonlyArray,
+): number[] {
const cumulative: number[] = []
let running = 0
- for (const lap of lapTimes) {
- if (lap !== null) {
+ 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]
+ if (lap != null) {
running += lap
}
cumulative.push(running)
}
+
return cumulative
}
@@ -47,7 +63,8 @@ function resolveReference(
/**
* Compute per-lap cumulative time delta for each non-reference driver.
* Positive = behind reference; negative = ahead.
- * Null laps carry cumulative forward but emit null in deltas (skip when plotting).
+ * 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.
*/
export function computeCumulativeDeltas(
series: ReadonlyArray,
@@ -56,24 +73,22 @@ export function computeCumulativeDeltas(
const reference = resolveReference(series, referenceLabel)
if (!reference) return []
- const refCumulative = buildCumulative(reference.lapTimes)
+ const refLapTimes = reference.lapTimes
+ const refCumulative = buildAlignedCumulative(refLapTimes, refLapTimes)
return series
.filter((s) => s.label !== reference.label)
.map((driver) => {
- const driverCumulative = buildCumulative(driver.lapTimes)
+ const driverCumulative = buildAlignedCumulative(driver.lapTimes, refLapTimes)
const lapCount = Math.max(driver.lapTimes.length, refCumulative.length)
const deltas: (number | null)[] = []
for (let i = 0; i < lapCount; i++) {
- if (driver.lapTimes[i] === null) {
+ if (refLapTimes[i] == null || driver.lapTimes[i] == null) {
deltas.push(null)
continue
}
- const refValue = refCumulative[i] ?? refCumulative[refCumulative.length - 1] ?? 0
- const driverValue =
- driverCumulative[i] ?? driverCumulative[driverCumulative.length - 1] ?? 0
- deltas.push(driverValue - refValue)
+ deltas.push(driverCumulative[i] - refCumulative[i])
}
return {
diff --git a/frontend/src/test/delta-time-graph.test.tsx b/frontend/src/test/delta-time-graph.test.tsx
index 291d136..7895dc4 100644
--- a/frontend/src/test/delta-time-graph.test.tsx
+++ b/frontend/src/test/delta-time-graph.test.tsx
@@ -47,7 +47,7 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(2)
})
- it('emits null for missing lap times while carrying cumulative forward', () => {
+ it('emits null for challenger missing lap times while carrying cumulative forward', () => {
const withNull: DeltaSeries = {
label: 'NOR',
color: '#FF8000',
@@ -60,6 +60,41 @@ describe('computeCumulativeDeltas', () => {
expect(result[0].deltas[2]).toBeCloseTo(-92)
})
+ it('gaps all drivers when the reference lap is null and resumes without that window', () => {
+ const refWithNull: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, null, 92],
+ }
+ const validChallenger: DeltaSeries = {
+ label: 'HAM',
+ color: '#E8002D',
+ lapTimes: [89, 91, 90],
+ }
+ const result = computeCumulativeDeltas([refWithNull, validChallenger])
+ expect(result[0].deltas[0]).toBeCloseTo(-1)
+ expect(result[0].deltas[1]).toBeNull()
+ // Lap 3 excludes the reference-null window for both: (89+90) - (90+92) = -3
+ expect(result[0].deltas[2]).toBeCloseTo(-3)
+ })
+
+ it('gaps challenger laps beyond a shorter reference series', () => {
+ const shortReference: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, 91],
+ }
+ const longerChallenger: DeltaSeries = {
+ label: 'HAM',
+ color: '#E8002D',
+ lapTimes: [89, 92, 90],
+ }
+ const result = computeCumulativeDeltas([shortReference, longerChallenger])
+ expect(result[0].deltas[0]).toBeCloseTo(-1)
+ expect(result[0].deltas[1]).toBeCloseTo(0)
+ expect(result[0].deltas[2]).toBeNull()
+ })
+
it('returns an empty array when only one series is provided', () => {
expect(computeCumulativeDeltas([reference])).toEqual([])
})
@@ -97,6 +132,19 @@ describe('DeltaTimeGraph', () => {
expect(screen.queryByTestId('delta-line-VER')).not.toBeInTheDocument()
})
+ it('splits polylines at reference-null laps', () => {
+ const refWithNull: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, null, 92],
+ }
+ const { container } = render(
+ ,
+ )
+ const lines = container.querySelectorAll('.delta-graph-driver-line')
+ expect(lines.length).toBeGreaterThan(1)
+ })
+
it('shows a crosshair tooltip on hover', () => {
vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
@@ -118,4 +166,31 @@ describe('DeltaTimeGraph', () => {
expect(screen.getByText(/Lap 1/)).toBeInTheDocument()
vi.restoreAllMocks()
})
+
+ it('omits tooltip rows on reference-null laps', () => {
+ vi.spyOn(SVGSVGElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ width: 640,
+ height: 220,
+ right: 640,
+ bottom: 220,
+ toJSON: () => ({}),
+ })
+ const refWithNull: DeltaSeries = {
+ label: 'VER',
+ color: '#3671C6',
+ lapTimes: [90, null, 92],
+ }
+ const { container } = render(
+ ,
+ )
+ const hoverLayer = container.querySelector('.delta-graph-hover-layer')
+ fireEvent.mouseMove(hoverLayer!, { clientX: 352, clientY: 100 })
+ expect(screen.getByTestId('delta-crosshair')).toBeInTheDocument()
+ expect(screen.queryByTestId('delta-tooltip')).not.toBeInTheDocument()
+ vi.restoreAllMocks()
+ })
})