Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"irating",
"irdashies"
],
"svg.preview.background": "black"
"svg.preview.background": "black",
"eslint.useFlatConfig": true
}
1 change: 1 addition & 0 deletions src/app/irsdk/types/_GENERATED_telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export interface TelemetryVarList {
EnterExitReset: TelemetryVariable<number[]>;
TrackTemp: TelemetryVariable<number[]>;
TrackTempCrew: TelemetryVariable<number[]>;
TrackWetness: TelemetryVariable<number[]>;
AirTemp: TelemetryVariable<number[]>;
WeatherType: TelemetryVariable<number[]>;
Skies: TelemetryVariable<number[]>;
Expand Down
1 change: 1 addition & 0 deletions src/frontend/components/Standings/Relative.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const Relative = () => {
))}
</tbody>
</table>

<SessionFooter />
</div>
);
Expand Down
197 changes: 197 additions & 0 deletions src/frontend/components/Standings/hooks/useDriverRelatives.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useDriverRelatives } from './useDriverRelatives';
import { useDriverCarIdx, useSessionStore, useTelemetryValues } from '@irdashies/context';
import { useDriverStandings } from './useDriverPositions';
import type { Standings } from '../createStandings';

// Mock the context hooks
vi.mock('@irdashies/context', () => ({
useDriverCarIdx: vi.fn(),
useTelemetryValues: vi.fn(),
useSessionStore: vi.fn(),
}));

vi.mock('./useDriverPositions', () => ({
useDriverStandings: vi.fn(),
}));

describe('useDriverRelatives', () => {
const mockDrivers: Standings[] = [
{
carIdx: 0,
classPosition: 1,
isPlayer: true,
driver: {
name: 'Driver 1',
carNum: '1',
license: 'A',
rating: 2000,
},
fastestTime: 100,
hasFastestTime: true,
lastTime: 105,
onPitRoad: false,
onTrack: true,
carClass: {
id: 1,
color: 0,
name: 'Class 1',
relativeSpeed: 1.0,
estLapTime: 100,
},
},
{
carIdx: 1,
classPosition: 2,
isPlayer: false,
driver: {
name: 'Driver 2',
carNum: '2',
license: 'B',
rating: 1800,
},
fastestTime: 102,
hasFastestTime: false,
lastTime: 107,
onPitRoad: false,
onTrack: true,
carClass: {
id: 1,
color: 0,
name: 'Class 1',
relativeSpeed: 1.0,
estLapTime: 100,
},
},
{
carIdx: 2,
classPosition: 3,
isPlayer: false,
driver: {
name: 'Driver 3',
carNum: '3',
license: 'C',
rating: 1600,
},
fastestTime: 104,
hasFastestTime: false,
lastTime: 109,
onPitRoad: false,
onTrack: true,
carClass: {
id: 1,
color: 0,
name: 'Class 1',
relativeSpeed: 1.0,
estLapTime: 100,
},
},
];

const mockCarIdxLapDistPct = [0.5, 0.6, 0.4]; // Player, Ahead, Behind
const mockCarIdxEstTime = [99, 100, 90]; // Player, Same class, Faster class

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useDriverCarIdx).mockReturnValue(0);
vi.mocked(useTelemetryValues).mockImplementation((key: string) => {
if (key === 'CarIdxLapDistPct') return mockCarIdxLapDistPct;
if (key === 'CarIdxEstTime') return mockCarIdxEstTime;
return [];
});
vi.mocked(useDriverStandings).mockReturnValue(mockDrivers);
vi.mocked(useSessionStore).mockReturnValue({
session: {
DriverInfo: {
PaceCarIdx: 0,
},
},
});
});

it('should return empty array when no player is found', () => {
vi.mocked(useDriverCarIdx).mockReturnValue(undefined);

const { result } = renderHook(() => useDriverRelatives({ buffer: 2 }));
expect(result.current).toEqual([]);
});

it('should calculate correct deltas for cars ahead and behind', () => {
const { result } = renderHook(() => useDriverRelatives({ buffer: 2 }));

expect(result.current).toHaveLength(3); // Player + 1 ahead + 1 behind
expect(result.current[0].carIdx).toBe(1); // Car ahead
expect(result.current[1].carIdx).toBe(0); // Player
expect(result.current[2].carIdx).toBe(2); // Car behind

// Car ahead should have positive delta
expect(result.current[0].delta).toBeGreaterThan(0);
// Player should have zero delta
expect(result.current[1].delta).toBe(0);
// Car behind should have negative delta
expect(result.current[2].delta).toBeLessThan(0);
});

it('should respect buffer limit', () => {
const { result } = renderHook(() => useDriverRelatives({ buffer: 1 }));

// Should only include player and one car ahead/behind
expect(result.current).toHaveLength(3);
});

it.each([
[0.1, 0.2, 0.8], // Player near start, Car ahead near start, Car behind near finish
[0.2, 0.3, 0.9],
[0, 0.1, 0.7],
[0.9, 0, 0.6],
])(
'should handle cars crossing the start/finish line',
(playerDistPct, aheadDistPct, behindDistPct) => {
const mockCarIdxLapDistPctWithCrossing = [
playerDistPct,
aheadDistPct,
behindDistPct,
];

vi.mocked(useTelemetryValues).mockImplementation((key: string) => {
if (key === 'CarIdxLapDistPct') return mockCarIdxLapDistPctWithCrossing;
if (key === 'CarIdxEstTime') return mockCarIdxEstTime;
return [];
});

const { result } = renderHook(() => useDriverRelatives({ buffer: 1 }));

// Car ahead should still be ahead by 10%
expect(result.current[0].carIdx).toBe(1);
expect(result.current[0].relativePct).toBeCloseTo(0.1);
expect(result.current[0].delta).toBeCloseTo(10);

// Player should be in the middle
expect(result.current[1].carIdx).toBe(0);
expect(result.current[1].relativePct).toBe(0);
expect(result.current[1].delta).toBe(0);

// Car behind should be behind by 20%
expect(result.current[2].carIdx).toBe(2);
expect(result.current[2].relativePct).toBeCloseTo(-0.3);
expect(result.current[2].delta).toBeCloseTo(-30);
}
);

it('should filter out off-track cars', () => {
const mockDriversWithOffTrack = [
{ ...mockDrivers[0] },
{ ...mockDrivers[1], onTrack: false },
{ ...mockDrivers[2] },
];

vi.mocked(useDriverStandings).mockReturnValue(mockDriversWithOffTrack);

const { result } = renderHook(() => useDriverRelatives({ buffer: 2 }));

// Should not include the off-track car
expect(result.current).toHaveLength(2);
expect(result.current.some((driver) => driver.carIdx === 1)).toBe(false);
});
});
108 changes: 56 additions & 52 deletions src/frontend/components/Standings/hooks/useDriverRelatives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,32 +9,47 @@ import { useDriverStandings } from './useDriverPositions';
export const useDriverRelatives = ({ buffer }: { buffer: number }) => {
const drivers = useDriverStandings();
const carIdxLapDistPct = useTelemetryValues('CarIdxLapDistPct');
const carIdxLap = useTelemetryValues('CarIdxLap');

const playerIndex = useDriverCarIdx();
const driverCarEstLapTime = useSessionStore(
(s) => s.session?.DriverInfo?.DriverCarEstLapTime ?? 0
);
const paceCarIdx =
useSessionStore((s) => s.session?.DriverInfo?.PaceCarIdx) ?? -1;

const standings = useMemo(() => {
const calculateRelativePct = (carIdx: number) => {
if (playerIndex === undefined) {
return NaN;
}

const playerDistPct = carIdxLapDistPct?.[playerIndex];
const otherDistPct = carIdxLapDistPct?.[carIdx];

if (playerDistPct === undefined || otherDistPct === undefined) {
return NaN;
}

const relativePct = otherDistPct - playerDistPct;

if (relativePct > 0.5) {
return relativePct - 1.0;
} else if (relativePct < -0.5) {
return relativePct + 1.0;
}

return relativePct;
};

const calculateDelta = (otherCarIdx: number) => {
const playerCarIdx = playerIndex ?? 0;

const playerLapNum = carIdxLap?.[playerCarIdx];
const playerDistPct = carIdxLapDistPct?.[playerCarIdx];

const otherLapNum = carIdxLap?.[otherCarIdx];
const otherDistPct = carIdxLapDistPct?.[otherCarIdx];

const player = drivers.find((driver) => driver.carIdx === playerIndex);
const other = drivers.find((driver) => driver.carIdx === otherCarIdx);

if (
playerLapNum === undefined || playerLapNum < 0 ||
playerDistPct === undefined || playerDistPct < 0 || playerDistPct > 1 ||
otherLapNum === undefined || otherLapNum < 0 ||
otherDistPct === undefined || otherDistPct < 0 || otherDistPct > 1 ||
driverCarEstLapTime <= 0
) {
return NaN;
}
// Use the slower car's lap time for more conservative deltas in multiclass
const playerEstLapTime = player?.carClass?.estLapTime ?? 0;
const otherEstLapTime = other?.carClass?.estLapTime ?? 0;
const baseLapTime = Math.max(playerEstLapTime, otherEstLapTime);

let distPctDifference = otherDistPct - playerDistPct;

Expand All @@ -43,56 +58,45 @@ export const useDriverRelatives = ({ buffer }: { buffer: number }) => {
} else if (distPctDifference < -0.5) {
distPctDifference += 1.0;
}

const timeDelta = distPctDifference * driverCarEstLapTime;

return timeDelta;
};
const timeDelta = distPctDifference * baseLapTime;

const isHalfLapDifference = (car1: number, car2: number) => {
const diff = (car1 - car2 + 1) % 1;
return diff <= 0.5;
return timeDelta;
};

const filterAndMapDrivers = (isAhead: boolean) => {
const filterAndMapDrivers = () => {
return drivers
.filter((driver) => driver.onTrack || driver.carIdx === playerIndex)
.filter((result) => {
const playerDistPct = carIdxLapDistPct?.[playerIndex ?? 0];
const carDistPct = carIdxLapDistPct?.[result.carIdx];
return isAhead
? isHalfLapDifference(carDistPct, playerDistPct)
: isHalfLapDifference(playerDistPct, carDistPct);
})
.filter((driver) => driver.onTrack || driver.carIdx === playerIndex) // filter out drivers not on track
.filter((driver) => driver.carIdx > -1 && driver.carIdx !== paceCarIdx) // filter out pace car
.map((result) => ({
...result,
relativePct: calculateRelativePct(result.carIdx),
}))
.filter((result) => !isNaN(result.relativePct))
.sort((a, b) => b.relativePct - a.relativePct) // sort by relative pct
.map((result) => ({
...result,
delta: calculateDelta(result.carIdx),
}))
.filter((result) => (isAhead ? result.delta > 0 : result.delta < 0))
.sort((a, b) => (isAhead ? a.delta - b.delta : b.delta - a.delta))
.slice(0, buffer)
.sort((a, b) => b.delta - a.delta);
.filter((result) => !isNaN(result.delta));
};

const carsAhead = filterAndMapDrivers(true);
const player = drivers.find((result) => result.carIdx === playerIndex);
const carsBehind = filterAndMapDrivers(false);
const allRelatives = filterAndMapDrivers();
const playerArrIndex = allRelatives.findIndex(
(result) => result.carIdx === playerIndex
);

if (!player) {
// if the player is not in the list, return an empty array
if (playerArrIndex === -1) {
return [];
}

const relatives = [...carsAhead, { ...player, delta: 0 }, ...carsBehind];

return relatives;
}, [
drivers,
buffer,
playerIndex,
driverCarEstLapTime,
carIdxLapDistPct,
carIdxLap,
]);
// buffered slice to get only the drivers around the player
const start = Math.max(0, playerArrIndex - buffer);
const end = Math.min(allRelatives.length, playerArrIndex + buffer + 1);

return allRelatives.slice(start, end);
}, [buffer, playerIndex, carIdxLapDistPct, drivers, paceCarIdx]);

return standings;
};
Loading