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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"javascript.preferences.quoteStyle": "single",
"cSpell.words": [
"iracing",
"irating",
"irdashies"
],
"svg.preview.background": "black"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,30 @@ export const IsLappingAhead: Story = {
},
};

export const IRatingChange: Story = {
name: 'iRating Positive Change',
args: {
...Primary.args,
iratingChange: 10,
},
};

export const IRatingChangeNegative: Story = {
name: 'iRating Negative Change',
args: {
...Primary.args,
iratingChange: -58,
},
};

export const IRatingNoChange: Story = {
name: 'iRating No Change',
args: {
...Primary.args,
iratingChange: 0,
},
};

export const Relative = () => {
const getRandomRating = () =>
Math.floor(Math.random() * (1300 - 700 + 1)) + 700;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SpeakerHighIcon } from '@phosphor-icons/react';
import { useMemo } from 'react';
import { SpeakerHighIcon, CaretUpIcon, CaretDownIcon } from '@phosphor-icons/react';
import { getTailwindStyle } from '@irdashies/utils/colors';
import { formatTime } from '@irdashies/utils/time';

Expand All @@ -12,6 +13,7 @@ interface DriverRowInfoProps {
delta?: number;
position: number;
badge: React.ReactNode;
iratingChange?: number;
lastTime?: number;
fastestTime?: number;
onPitRoad?: boolean;
Expand All @@ -38,11 +40,36 @@ export const DriverInfoRow = ({
radioActive,
isLapped,
isLappingAhead,
iratingChange,
}: DriverRowInfoProps) => {
// convert seconds to mm:ss:ms
const lastTimeString = formatTime(lastTime);
const fastestTimeString = formatTime(fastestTime);

const iratingChangeDisplay = useMemo(() => {
if (iratingChange === undefined || iratingChange === null) {
return { text: '-', color: 'text-gray-400' };
}
const roundedChange = Math.round(iratingChange);
let text: string;
let color = 'text-gray-400';
let icon: React.ReactNode;

if (roundedChange > 0) {
text = `${roundedChange}`;
color = 'text-green-400';
icon = <CaretUpIcon size={10} />;
} else if (roundedChange < 0) {
text = `${Math.abs(roundedChange)}`;
color = 'text-red-400';
icon = <CaretDownIcon size={10} />;
} else {
text = `${roundedChange}`;
icon = null;
}
return { text, color, icon };
}, [iratingChange]);

return (
<tr
key={carIdx}
Expand Down Expand Up @@ -82,6 +109,12 @@ export const DriverInfoRow = ({
</div>
</td>
<td>{badge}</td>
{iratingChange !== undefined && <td className={`px-2 text-left ${iratingChangeDisplay.color}`}>
<span className="flex items-center gap-0.5">
{iratingChangeDisplay.icon}
{iratingChangeDisplay.text}
</span>
</td>}
<td className={`px-2`}>{delta?.toFixed(1)}</td>
<td className={`px-2 ${hasFastestTime ? 'text-purple-400' : ''}`}>
{fastestTimeString}
Expand Down
1 change: 1 addition & 0 deletions src/frontend/components/Standings/Standings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const Standings = () => {
hasFastestTime={result.hasFastestTime}
delta={result.delta}
position={result.classPosition}
iratingChange={result.iratingChange}
lastTime={result.lastTime}
fastestTime={result.fastestTime}
onPitRoad={result.onPitRoad}
Expand Down
43 changes: 43 additions & 0 deletions src/frontend/components/Standings/createStandings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type {
Session,
Telemetry,
} from '@irdashies/types';
import {
calculateIRatingGain,
RaceResult,
CalculationResult,
} from '@irdashies/utils/iratingGain';

export interface Standings {
carIdx: number;
Expand Down Expand Up @@ -33,6 +38,7 @@ export interface Standings {
estLapTime: number;
};
radioActive?: boolean;
iratingChange?: number;
}

const calculateDelta = (
Expand Down Expand Up @@ -158,6 +164,43 @@ export const groupStandingsByClass = (standings: Standings[]) => {
return sorted;
};

/**
* This method will augment the standings with iRating changes
*/
export const augmentStandingsWithIRating = (
groupedStandings: [string, Standings[]][]
): [string, Standings[]][] => {
return groupedStandings.map(([classId, classStandings]) => {
const raceResultsInput: RaceResult<number>[] = classStandings.map(
(driverStanding) => ({
driver: driverStanding.carIdx,
finishRank: driverStanding.classPosition,
startIRating: driverStanding.driver.rating,
started: true, // This is a critical assumption.
})
);

if (raceResultsInput.length === 0) {
return [classId, classStandings];
}

const iratingCalculationResults = calculateIRatingGain(raceResultsInput);

const iratingChangeMap = new Map<number, number>();
iratingCalculationResults.forEach((calcResult: CalculationResult<number>) => {
iratingChangeMap.set(calcResult.raceResult.driver, calcResult.iratingChange);
});

const augmentedClassStandings = classStandings.map(
(driverStanding) => ({
...driverStanding,
iratingChange: iratingChangeMap.get(driverStanding.carIdx),
})
);
return [classId, augmentedClassStandings];
});
};

/**
* This method will slice up the standings and return only the relevant drivers
* Top 3 drivers are always included for each class
Expand Down
17 changes: 12 additions & 5 deletions src/frontend/components/Standings/hooks/useDriverStandings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createDriverStandings,
groupStandingsByClass,
sliceRelevantDrivers,
augmentStandingsWithIRating,
} from '../createStandings';

export const useDriverStandings = ({ buffer }: { buffer: number }) => {
Expand All @@ -28,8 +29,8 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
const carIdxTrackSurface = useTelemetry('CarIdxTrackSurface');
const radioTransmitCarIdx = useTelemetry('RadioTransmitCarIdx');

const standings = useMemo(() => {
const standings = createDriverStandings(
const standingsWithGain = useMemo(() => {
const initialStandings = createDriverStandings(
{
playerIdx: driverCarIdx,
drivers: sessionDrivers,
Expand All @@ -47,8 +48,14 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
sessionType,
}
);
const grouped = groupStandingsByClass(standings);
return sliceRelevantDrivers(grouped, { buffer });
const groupedByClass = groupStandingsByClass(initialStandings);

// Calculate iRating changes for race sessions
const augmentedGroupedByClass = sessionType === 'Race'
? augmentStandingsWithIRating(groupedByClass)
: groupedByClass;

return sliceRelevantDrivers(augmentedGroupedByClass, { buffer });
}, [
driverCarIdx,
sessionDrivers,
Expand All @@ -63,5 +70,5 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
buffer,
]);

return standings;
return standingsWithGain;
};
124 changes: 124 additions & 0 deletions src/frontend/utils/iratingGain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Based on the Rust implementation provided in https://github.com/Turbo87/irating-rs

export interface RaceResult<D = unknown> {
driver: D;
finishRank: number; // u32
startIRating: number; // u32
started: boolean;
}

export interface CalculationResult<D = unknown> {
raceResult: RaceResult<D>;
iratingChange: number; // f32
newIRating: number; // u32
}

function chance(a: number, b: number, factor: number): number {
const expA = Math.exp(-a / factor);
const expB = Math.exp(-b / factor);
return (
((1 - expA) * expB) / ((1 - expB) * expA + (1 - expA) * expB)
);
}

export function calculateIRatingGain<D>(
raceResults: RaceResult<D>[],
): CalculationResult<D>[] {
const br1 = 1600 / Math.LN2;

const numRegistrations = raceResults.length;
if (numRegistrations === 0) {
return [];
}

const numStarters = raceResults.filter((r) => r.started).length;
const numNonStarters = numRegistrations - numStarters;

const chances: number[][] = raceResults.map((resultA) =>
raceResults.map((resultB) =>
chance(resultA.startIRating, resultB.startIRating, br1),
),
);

const expectedScores: number[] = chances.map(
(chancesRow) => chancesRow.reduce((sum, val) => sum + val, 0) - 0.5,
);

const fudgeFactors: number[] = raceResults.map((result) => {
if (!result.started) {
return 0;
}
const x = numRegistrations - numNonStarters / 2;
return (x / 2 - result.finishRank) / 100;
});

let sumChangesStarters = 0;
const changesStarters: (number | null)[] = raceResults.map(
(result, index) => {
if (!result.started) {
return null;
}
if (numStarters === 0) return 0;

const expectedScore = expectedScores[index];
const fudgeFactor = fudgeFactors[index];
const change =
((numRegistrations -
result.finishRank -
expectedScore -
fudgeFactor) *
200) /
numStarters;
sumChangesStarters += change;
return change;
},
);

const expectedScoreNonStartersList: (number | null)[] = raceResults.map(
(result, index) => (!result.started ? expectedScores[index] : null)
);

const sumExpectedScoreNonStarters: number = expectedScoreNonStartersList
.filter((score): score is number => score !== null)
.reduce((sum, val) => sum + val, 0);

const avgExpectedScoreNonStarters = numNonStarters > 0 ? sumExpectedScoreNonStarters / numNonStarters : 0;

const changesNonStarters: (number | null)[] = expectedScoreNonStartersList.map(
(expectedScore) => {
if (expectedScore === null) {
return null;
}
if (numNonStarters === 0) return 0;
if (avgExpectedScoreNonStarters === 0) return 0;

return (
(-sumChangesStarters / numNonStarters) *
(expectedScore / avgExpectedScoreNonStarters)
);
},
);

const changes: number[] = raceResults.map((_result, index) => {
const starterChange = changesStarters[index];
const nonStarterChange = changesNonStarters[index];

if (starterChange !== null) {
return starterChange;
}
if (nonStarterChange !== null) {
return nonStarterChange;
}
throw new Error(`Driver at index ${index} is neither starter nor non-starter.`);
});

return raceResults.map((result, index) => {
const change = changes[index];
const newIRating = Math.max(0, Math.round(result.startIRating + change));
return {
raceResult: result,
iratingChange: change,
newIRating: newIRating,
};
});
}