diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9ce100e..3a863f2 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,6 +3,7 @@
"javascript.preferences.quoteStyle": "single",
"cSpell.words": [
"iracing",
+ "irating",
"irdashies"
],
"svg.preview.background": "black"
diff --git a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.stories.tsx b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.stories.tsx
index 4ab84ec..ca98b9f 100644
--- a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.stories.tsx
+++ b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.stories.tsx
@@ -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;
diff --git a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx
index 002c72a..2f52952 100644
--- a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx
+++ b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx
@@ -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';
@@ -12,6 +13,7 @@ interface DriverRowInfoProps {
delta?: number;
position: number;
badge: React.ReactNode;
+ iratingChange?: number;
lastTime?: number;
fastestTime?: number;
onPitRoad?: boolean;
@@ -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 = ;
+ } else if (roundedChange < 0) {
+ text = `${Math.abs(roundedChange)}`;
+ color = 'text-red-400';
+ icon = ;
+ } else {
+ text = `${roundedChange}`;
+ icon = null;
+ }
+ return { text, color, icon };
+ }, [iratingChange]);
+
return (
| {badge} |
+ {iratingChange !== undefined &&
+
+ {iratingChangeDisplay.icon}
+ {iratingChangeDisplay.text}
+
+ | }
{delta?.toFixed(1)} |
{fastestTimeString}
diff --git a/src/frontend/components/Standings/Standings.tsx b/src/frontend/components/Standings/Standings.tsx
index cf38508..2bf3120 100644
--- a/src/frontend/components/Standings/Standings.tsx
+++ b/src/frontend/components/Standings/Standings.tsx
@@ -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}
diff --git a/src/frontend/components/Standings/createStandings.ts b/src/frontend/components/Standings/createStandings.ts
index 6b5da1e..292fe6c 100644
--- a/src/frontend/components/Standings/createStandings.ts
+++ b/src/frontend/components/Standings/createStandings.ts
@@ -5,6 +5,11 @@ import type {
Session,
Telemetry,
} from '@irdashies/types';
+import {
+ calculateIRatingGain,
+ RaceResult,
+ CalculationResult,
+} from '@irdashies/utils/iratingGain';
export interface Standings {
carIdx: number;
@@ -33,6 +38,7 @@ export interface Standings {
estLapTime: number;
};
radioActive?: boolean;
+ iratingChange?: number;
}
const calculateDelta = (
@@ -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[] = 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();
+ iratingCalculationResults.forEach((calcResult: CalculationResult) => {
+ 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
diff --git a/src/frontend/components/Standings/hooks/useDriverStandings.tsx b/src/frontend/components/Standings/hooks/useDriverStandings.tsx
index 8778d61..cc15a5e 100644
--- a/src/frontend/components/Standings/hooks/useDriverStandings.tsx
+++ b/src/frontend/components/Standings/hooks/useDriverStandings.tsx
@@ -13,6 +13,7 @@ import {
createDriverStandings,
groupStandingsByClass,
sliceRelevantDrivers,
+ augmentStandingsWithIRating,
} from '../createStandings';
export const useDriverStandings = ({ buffer }: { buffer: number }) => {
@@ -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,
@@ -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,
@@ -63,5 +70,5 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => {
buffer,
]);
- return standings;
+ return standingsWithGain;
};
diff --git a/src/frontend/utils/iratingGain.ts b/src/frontend/utils/iratingGain.ts
new file mode 100644
index 0000000..b6f8037
--- /dev/null
+++ b/src/frontend/utils/iratingGain.ts
@@ -0,0 +1,124 @@
+// Based on the Rust implementation provided in https://github.com/Turbo87/irating-rs
+
+export interface RaceResult {
+ driver: D;
+ finishRank: number; // u32
+ startIRating: number; // u32
+ started: boolean;
+}
+
+export interface CalculationResult {
+ raceResult: RaceResult;
+ 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(
+ raceResults: RaceResult[],
+): CalculationResult[] {
+ 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,
+ };
+ });
+}
|