From 5ce5cbe26b7224ca1e56680d1197101bf2f6a231 Mon Sep 17 00:00:00 2001 From: FCatalan Date: Sun, 11 May 2025 18:06:26 +0200 Subject: [PATCH 1/3] show irating change prediction --- .../Standings/DriverInfoRow/DriverInfoRow.tsx | 26 ++++ .../components/Standings/Standings.tsx | 2 + .../Standings/hooks/useDriverStandings.tsx | 56 +++++++- src/frontend/utils/iratingGain.ts | 124 ++++++++++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 src/frontend/utils/iratingGain.ts diff --git a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx index 4cbd984..47df8fa 100644 --- a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx +++ b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { SpeakerHigh } from '@phosphor-icons/react'; import { getTailwindStyle } from '../../../utils/colors'; import { formatTime } from '../../../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,32 @@ 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'; + + if (roundedChange > 0) { + text = `▲${roundedChange}`; + color = 'text-green-400'; + } else if (roundedChange < 0) { + text = `▼${Math.abs(roundedChange)}`; + color = 'text-red-400'; + } else { + text = `${roundedChange}`; + } + return { text, color }; + }, [iratingChange]); + return ( {badge} + + {iratingChangeDisplay.text} + {delta?.toFixed(1)} {fastestTimeString} diff --git a/src/frontend/components/Standings/Standings.tsx b/src/frontend/components/Standings/Standings.tsx index cf38508..ccfb598 100644 --- a/src/frontend/components/Standings/Standings.tsx +++ b/src/frontend/components/Standings/Standings.tsx @@ -4,6 +4,7 @@ import { useAutoAnimate } from '@formkit/auto-animate/react'; import { DriverClassHeader } from './DriverClassHeader/DriverClassHeader'; import { SessionBar } from './SessionBar/SessionBar'; import { Fragment } from 'react/jsx-runtime'; +import { StandingsWithIRatingGain } from './hooks'; import { useCarClassStats, useDriverStandings } from './hooks'; import { SessionFooter } from './SessionFooter/SessionFooter'; @@ -37,6 +38,7 @@ export const Standings = () => { hasFastestTime={result.hasFastestTime} delta={result.delta} position={result.classPosition} + iratingChange={(result as StandingsWithIRatingGain).iratingChange} lastTime={result.lastTime} fastestTime={result.fastestTime} onPitRoad={result.onPitRoad} diff --git a/src/frontend/components/Standings/hooks/useDriverStandings.tsx b/src/frontend/components/Standings/hooks/useDriverStandings.tsx index 8778d61..92e183d 100644 --- a/src/frontend/components/Standings/hooks/useDriverStandings.tsx +++ b/src/frontend/components/Standings/hooks/useDriverStandings.tsx @@ -13,7 +13,17 @@ import { createDriverStandings, groupStandingsByClass, sliceRelevantDrivers, + Standings as DriverStandingInfo, // Renaming to avoid conflict if needed locally } from '../createStandings'; +import { + calculateIRatingGain, + RaceResult, + CalculationResult, +} from '../../../utils/iratingGain'; + +export interface StandingsWithIRatingGain extends DriverStandingInfo { + iratingChange?: number; +} export const useDriverStandings = ({ buffer }: { buffer: number }) => { const sessionDrivers = useSessionDrivers(); @@ -28,8 +38,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 +57,44 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => { sessionType, } ); - const grouped = groupStandingsByClass(standings); - return sliceRelevantDrivers(grouped, { buffer }); + const groupedByClass = groupStandingsByClass(initialStandings); + + const augmentedGroupedByClass: [string, StandingsWithIRatingGain[]][] = groupedByClass.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 as StandingsWithIRatingGain[]]; + } + + const iratingCalculationResults = calculateIRatingGain(raceResultsInput); + + const iratingChangeMap = new Map(); + iratingCalculationResults.forEach((calcResult: CalculationResult) => { + iratingChangeMap.set( + calcResult.raceResult.driver, + calcResult.iratingChange, + ); + }); + + const augmentedClassStandings: StandingsWithIRatingGain[] = classStandings.map( + (driverStanding) => ({ + ...driverStanding, + iratingChange: iratingChangeMap.get(driverStanding.carIdx), + }), + ); + return [classId, augmentedClassStandings]; + }, + ); + + return sliceRelevantDrivers(augmentedGroupedByClass, { buffer }); }, [ driverCarIdx, sessionDrivers, @@ -63,5 +109,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, + }; + }); +} From c6521bc66c3332024e9f949e484148cdff1e34d1 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sat, 24 May 2025 08:29:10 +1200 Subject: [PATCH 2/3] tidy up icons --- .vscode/settings.json | 1 + .../DriverInfoRow/DriverInfoRow.stories.tsx | 24 ++++++++++++++++++ .../Standings/DriverInfoRow/DriverInfoRow.tsx | 25 ++++++++++++------- 3 files changed, 41 insertions(+), 9 deletions(-) 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 2994453..2f52952 100644 --- a/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx +++ b/src/frontend/components/Standings/DriverInfoRow/DriverInfoRow.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { SpeakerHighIcon } from '@phosphor-icons/react'; +import { SpeakerHighIcon, CaretUpIcon, CaretDownIcon } from '@phosphor-icons/react'; import { getTailwindStyle } from '@irdashies/utils/colors'; import { formatTime } from '@irdashies/utils/time'; @@ -53,17 +53,21 @@ export const DriverInfoRow = ({ const roundedChange = Math.round(iratingChange); let text: string; let color = 'text-gray-400'; + let icon: React.ReactNode; if (roundedChange > 0) { - text = `▲${roundedChange}`; + text = `${roundedChange}`; color = 'text-green-400'; - } else if (roundedChange < 0) { - text = `▼${Math.abs(roundedChange)}`; - color = 'text-red-400'; + icon = ; + } else if (roundedChange < 0) { + text = `${Math.abs(roundedChange)}`; + color = 'text-red-400'; + icon = ; } else { text = `${roundedChange}`; + icon = null; } - return { text, color }; + return { text, color, icon }; }, [iratingChange]); return ( @@ -105,9 +109,12 @@ export const DriverInfoRow = ({ {badge} - - {iratingChangeDisplay.text} - + {iratingChange !== undefined && + + {iratingChangeDisplay.icon} + {iratingChangeDisplay.text} + + } {delta?.toFixed(1)} {fastestTimeString} From a484e35802229f51dd9ad10dc31440c4ed0c86c6 Mon Sep 17 00:00:00 2001 From: tariknz Date: Sat, 24 May 2025 08:29:44 +1200 Subject: [PATCH 3/3] only apply to race sessions, move function out --- .../components/Standings/Standings.tsx | 3 +- .../components/Standings/createStandings.ts | 43 ++++++++++++++++ .../Standings/hooks/useDriverStandings.tsx | 51 +++---------------- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/frontend/components/Standings/Standings.tsx b/src/frontend/components/Standings/Standings.tsx index ccfb598..2bf3120 100644 --- a/src/frontend/components/Standings/Standings.tsx +++ b/src/frontend/components/Standings/Standings.tsx @@ -4,7 +4,6 @@ import { useAutoAnimate } from '@formkit/auto-animate/react'; import { DriverClassHeader } from './DriverClassHeader/DriverClassHeader'; import { SessionBar } from './SessionBar/SessionBar'; import { Fragment } from 'react/jsx-runtime'; -import { StandingsWithIRatingGain } from './hooks'; import { useCarClassStats, useDriverStandings } from './hooks'; import { SessionFooter } from './SessionFooter/SessionFooter'; @@ -38,7 +37,7 @@ export const Standings = () => { hasFastestTime={result.hasFastestTime} delta={result.delta} position={result.classPosition} - iratingChange={(result as StandingsWithIRatingGain).iratingChange} + 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 92e183d..cc15a5e 100644 --- a/src/frontend/components/Standings/hooks/useDriverStandings.tsx +++ b/src/frontend/components/Standings/hooks/useDriverStandings.tsx @@ -13,17 +13,8 @@ import { createDriverStandings, groupStandingsByClass, sliceRelevantDrivers, - Standings as DriverStandingInfo, // Renaming to avoid conflict if needed locally + augmentStandingsWithIRating, } from '../createStandings'; -import { - calculateIRatingGain, - RaceResult, - CalculationResult, -} from '../../../utils/iratingGain'; - -export interface StandingsWithIRatingGain extends DriverStandingInfo { - iratingChange?: number; -} export const useDriverStandings = ({ buffer }: { buffer: number }) => { const sessionDrivers = useSessionDrivers(); @@ -58,41 +49,11 @@ export const useDriverStandings = ({ buffer }: { buffer: number }) => { } ); const groupedByClass = groupStandingsByClass(initialStandings); - - const augmentedGroupedByClass: [string, StandingsWithIRatingGain[]][] = groupedByClass.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 as StandingsWithIRatingGain[]]; - } - - const iratingCalculationResults = calculateIRatingGain(raceResultsInput); - - const iratingChangeMap = new Map(); - iratingCalculationResults.forEach((calcResult: CalculationResult) => { - iratingChangeMap.set( - calcResult.raceResult.driver, - calcResult.iratingChange, - ); - }); - - const augmentedClassStandings: StandingsWithIRatingGain[] = classStandings.map( - (driverStanding) => ({ - ...driverStanding, - iratingChange: iratingChangeMap.get(driverStanding.carIdx), - }), - ); - return [classId, augmentedClassStandings]; - }, - ); + + // Calculate iRating changes for race sessions + const augmentedGroupedByClass = sessionType === 'Race' + ? augmentStandingsWithIRating(groupedByClass) + : groupedByClass; return sliceRelevantDrivers(augmentedGroupedByClass, { buffer }); }, [