From 937667d847bf7c60a90b94fd7c4e1ce447b08c6a Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 03:51:22 +0300 Subject: [PATCH 01/32] fix: radar shows false crashes, overlapping cars and wrong pace sides - Hazards ahead are judged by how fast the focus car closes on them, not by a speed profile learnt per track, so a slower class no longer looks like a crash ahead - A car beside us gets the spotter's side before cars that dropped back, and one with no side is no longer drawn on top of our car - The pole line of the pace field is on the left on Long Beach - The oval profile applies to super speedways and short tracks too - A dashed line ties our car to the one we follow on the formation lap Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 25 +++ src/app/processors/RadarProcessor.ts | 11 +- src/app/processors/radarFormation.spec.ts | 16 ++ src/app/processors/radarFormation.ts | 18 ++ src/app/processors/radarHazards.spec.ts | 59 ++++-- src/app/processors/radarHazards.ts | 192 ++++++++++-------- src/app/processors/radarLanes.spec.ts | 56 ++++- src/app/processors/radarLanes.ts | 61 ++++-- .../components/Radar/radarDraw.spec.ts | 21 +- src/frontend/components/Radar/radarDraw.ts | 55 +++++ .../domain/radar/radarProfile.spec.ts | 4 + src/frontend/domain/radar/radarProfile.ts | 8 +- 12 files changed, 402 insertions(+), 124 deletions(-) diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 5c416bb65..423a40fd4 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -359,6 +359,31 @@ describe('RadarProcessor', () => { expect(towardsPole.every((car) => car.lane < 0)).toBe(true); }); + it('puts the pole line on the left on Long Beach', () => { + const processor = new RadarProcessor(); + const session = rollingSession as unknown as Session; + processor.init({ + ...session, + WeekendInfo: { + ...session.WeekendInfo, + TrackName: 'longbeach', + TrackType: 'road course', + }, + }); + processor.onFrame(rollingTelemetry as unknown as Telemetry); + + const { cars } = processor.snapshot(); + const base = rollingTelemetry as unknown as Record< + string, + { value: unknown[] } + >; + const lines = base.CarIdxPaceLine.value as number[]; + const mine = lines[base.PlayerCarIdx.value[0] as number]; + const towardsPole = cars.filter((car) => lines[car.carIdx] < mine); + expect(towardsPole.length).toBeGreaterThan(0); + expect(towardsPole.every((car) => car.lane < 0)).toBe(true); + }); + it('clears everything on disconnect and session change', () => { const processor = createProcessor(); processor.onFrame(frame({ time: 1, pcts: [0.5, 0.5 + m(5)] })); diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 57830cb49..5db368fea 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -13,9 +13,10 @@ import { type RadarProcessorTuning, } from '@irdashies/types'; import type { TelemetryProcessor } from './TelemetryProcessor'; -import { RadarLaneTracker } from './radarLanes'; +import { RadarLaneTracker, separateInLaneCars } from './radarLanes'; import { computeFormation, + paceFieldPoleLeft, PaceMode, parseGridPositions, parseStartingGrid, @@ -140,7 +141,12 @@ export class RadarProcessor implements TelemetryProcessor { // TrackType is "road course", "short track", "super speedway", "dirt oval" // and so on; anything that is not road racing is an oval const trackType = session?.WeekendInfo?.TrackType ?? ''; - this.lanes.setOval(trackType !== '' && !/road|rally/i.test(trackType)); + this.lanes.setPacePoleLeft( + paceFieldPoleLeft( + session?.WeekendInfo?.TrackName, + trackType !== '' && !/road|rally/i.test(trackType) + ) + ); const options = session?.WeekendInfo?.WeekendOptions; this.grid = { standingStart: options?.StandingStart === 1, @@ -238,6 +244,7 @@ export class RadarProcessor implements TelemetryProcessor { } cars.sort((a, b) => Math.abs(a.dist) - Math.abs(b.dist)); this.assignLanes(frame, time, focus, isOnTrack, cars, formation); + separateInLaneCars(cars); } // The pedals are the player's, so they say nothing about a camera car. diff --git a/src/app/processors/radarFormation.spec.ts b/src/app/processors/radarFormation.spec.ts index 108cc0459..03ae43c0f 100644 --- a/src/app/processors/radarFormation.spec.ts +++ b/src/app/processors/radarFormation.spec.ts @@ -9,6 +9,7 @@ import pitStartSession from '../../../test-data/1732260478001/session.json'; import { computeFormation, PaceMode, + paceFieldPoleLeft, parseGridPositions, parseStartingGrid, type FormationInput, @@ -83,6 +84,21 @@ describe('parseStartingGrid', () => { }); }); +describe('paceFieldPoleLeft', () => { + it('puts the pole line on the left on Long Beach', () => { + expect(paceFieldPoleLeft('longbeach', false)).toBe(true); + }); + + it('keeps it on the right on other road courses', () => { + expect(paceFieldPoleLeft('interlagos gp', false)).toBe(false); + expect(paceFieldPoleLeft(undefined, false)).toBe(false); + }); + + it('puts it on the inside, the left, on ovals', () => { + expect(paceFieldPoleLeft('daytona 2011 oval', true)).toBe(true); + }); +}); + describe('parseGridPositions', () => { const entry = (CarIdx: number, Position: number) => ({ CarIdx, Position }); diff --git a/src/app/processors/radarFormation.ts b/src/app/processors/radarFormation.ts index b4b9a4b6b..b4e35cc9e 100644 --- a/src/app/processors/radarFormation.ts +++ b/src/app/processors/radarFormation.ts @@ -38,6 +38,24 @@ export const parseStartingGrid = ( return { columns }; }; +/** + * Road courses where the pole line of a pace field is on the left. Everywhere + * else on road it is on the right, as checked at Fuji, Watkins Glen and Mount + * Panorama. Found by driving: the Sporting Code names no side. Keys are + * WeekendInfo.TrackName; add a track here when its pace field shows the wrong + * way round. + */ +const POLE_LEFT_TRACKS: ReadonlySet = new Set(['longbeach']); + +/** + * Whether the pole line of a pace field is on the left: on ovals the inside, + * and on the road courses listed above. + */ +export const paceFieldPoleLeft = ( + trackName: string | undefined, + oval: boolean +): boolean => oval || POLE_LEFT_TRACKS.has(trackName ?? ''); + /** * Starting grid positions by car index, 0 for pole: the session's own grid * where the sim fills one in (heats, and some series), else the qualifying diff --git a/src/app/processors/radarHazards.spec.ts b/src/app/processors/radarHazards.spec.ts index f45cae165..ec814f58d 100644 --- a/src/app/processors/radarHazards.spec.ts +++ b/src/app/processors/radarHazards.spec.ts @@ -49,14 +49,49 @@ class Field { } return this.last; } - - /** Every car laps a few times, so the whole profile is learnt. */ - learnLaps(laps: number) { - this.run((laps * TRACK_M) / 50); - } } describe('RadarHazardTracker', () => { + it('does not warn of a slower car it is not closing in on fast', () => { + const field = new Field(); + // 80% of our speed: well above the slow share however long we follow it. + field.speeds = [50, 50, 40]; + + const reported: RadarHazard[] = []; + for (let second = 0; second < 120; second += 1) { + reported.push(...field.run(1)); + } + + expect(reported).toEqual([]); + }); + + it('warns when closing fast on a car far slower than us', () => { + const field = new Field(); + field.speeds = [60, 50, 25]; + field.run(3); + + expect(field.run(1)).toEqual([ + expect.objectContaining({ carIdx: 2, kind: 'slow' }), + ]); + }); + + it('stays quiet about a slow car while we crawl up to it', () => { + const field = new Field(); + // A hairpin: half our speed, but only 10 m/s of closing, 20 s away. + field.speeds = [20, 20, 10]; + + expect(field.run(3)).toEqual([]); + }); + + it('stays quiet about a sudden drop we are not closing on', () => { + const field = new Field(); + field.speeds = [20, 20, 50]; + field.run(5); + + // Braking hard in front of us at the same speed as us. + expect(field.run(1, { speed: 18 })).toEqual([]); + }); + it('reports nothing while the field drives normally', () => { const field = new Field(); expect(field.run(10)).toEqual([]); @@ -79,23 +114,16 @@ describe('RadarHazardTracker', () => { expect(field.run(1, { speed: 50 })).toEqual([]); }); - it('judges slow against what the field does at that spot', () => { + it('calls a car slowing gradually far below us slow, not a crash', () => { const field = new Field(); - field.learnLaps(3); - expect(field.tracker.expectedSpeed(field.pcts[2])).toBeCloseTo(50, 0); - // Gradually down to 25 m/s: no sudden drop, but half the field's speed. + field.run(5); + // Gradually down to 25 m/s: no sudden drop, but half our speed. for (let speed = 45; speed >= 25; speed -= 5) field.run(1, { speed }); expect(field.run(1.5)).toEqual([ expect.objectContaining({ carIdx: 2, kind: 'slow' }), ]); }); - it('leaves a moderately slow car alone until the profile is learnt', () => { - const field = new Field(); - for (let speed = 45; speed >= 25; speed -= 5) field.run(1, { speed }); - expect(field.run(2)).toEqual([]); - }); - it('clears once the car is back up to speed', () => { const field = new Field(); field.run(5); @@ -138,7 +166,6 @@ describe('RadarHazardTracker', () => { it('does not flag the field still at pace speed when the green comes', () => { const field = new Field(); - field.learnLaps(3); field.quiet = true; field.speeds = [25, 25, 25]; field.run(10); diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index 28ad0e4fe..4a86baf5d 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -4,25 +4,20 @@ import { TrackLocation } from '@irdashies/types'; /** Metres ahead hazards are reported to the renderer, past any setting. */ export const RADAR_HAZARD_MAX_M = 1000; -/** Length of one stretch of the speed profile, in metres. */ -const PROFILE_BIN_M = 25; -/** Samples a stretch needs before its speed is trusted. */ -const PROFILE_MIN_SAMPLES = 50; -/** - * The profile follows the fast cars: it rises quickly and sinks slowly, so a - * stopped car or a slow out-lap barely moves it. - */ -const PROFILE_RISE = 0.2; -const PROFILE_SINK = 0.005; - /** Below this, in m/s, a car counts as stopped wherever it is. */ const STOPPED_MS = 5; -/** Without a profile, a car this fast in m/s counts as at racing speed. */ -const FAST_FALLBACK_MS = 15; -/** Slower than this share of the field's speed at the spot is slow. */ +/** Slower than this share of the focus car's speed is slow for it. */ const SLOW_SHARE = 0.6; -/** Back above this share, the car is racing again. */ -const FAST_SHARE = 0.75; +/** A car must be closing at least this fast, in m/s, to concern the focus car. */ +const CLOSING_MIN_MS = 8; +/** ...and be reached within this many seconds at that closing speed. */ +const REACH_MAX_S = 8; +/** A car racing this fast, in m/s, can have crashed by slowing. */ +const RACING_MS = 15; +/** Dropping below this share of its own recent top speed is sudden. */ +const SUDDEN_SHARE = 0.4; +/** Back above this share of the speed before a drop, the car is racing again. */ +const RECOVER_SHARE = 0.75; /** Seconds a car must stay slow before it is reported. */ const SLOW_HOLD_S = 1; /** Seconds after a sudden drop before it is reported, to skip a single bad frame. */ @@ -40,24 +35,37 @@ const REJOIN_S = 5; const QUIET_GRACE_S = 5; interface CarHazardState { - /** Session time the car was last at racing speed, or -1. */ - lastFast: number; - /** Session time the current slow spell began, or -1. */ + /** The car's own top speed within the last SUDDEN_WINDOW_S, and when. */ + peak: number; + peakAt: number; + /** Speed the car had before a sudden drop, or -1 while it has not dropped. */ + droppedFrom: number; + droppedAt: number; + /** Session time the car was last below STOPPED_MS, or -1 while it moves. */ + stoppedSince: number; + /** Session time the car was last slow for the focus car, or -1. */ slowSince: number; - /** The slow spell began straight from racing speed. */ - sudden: boolean; /** Session time the current excursion began, or -1. */ offSince: number; + /** The car's speed when the current excursion began. */ + offFrom: number; /** Session time the car last came back from a counted excursion, or -1. */ rejoinedAt: number; + /** The car's speed before that excursion. */ + rejoinFrom: number; } const freshState = (): CarHazardState => ({ - lastFast: -1, + peak: 0, + peakAt: -1, + droppedFrom: -1, + droppedAt: -1, + stoppedSince: -1, slowSince: -1, - sudden: false, offSince: -1, + offFrom: 0, rejoinedAt: -1, + rejoinFrom: 0, }); export interface HazardFrame { @@ -86,15 +94,15 @@ const wrapDelta = (to: number, from: number): number => { /** * Finds cars ahead that are crashed, crawling, off the track or rejoining. * - * "Slow" depends on where a car is: 80 km/h is fine in a hairpin and a wreck - * on a straight. The tracker learns how fast the field goes along each - * stretch of the lap and judges every car against that. Until a stretch has - * been driven enough, only a near-stopped car counts. + * Nothing here depends on the track or on car classes. A stopped car, an + * excursion and a rejoin are facts about the car. A sudden drop from the + * car's own recent speed is a crash or a spin, and a car far slower than the + * focus car is slow; both only count while the focus car is closing in on + * them fast enough to reach them soon, so a hairpin, a braking zone or a + * slower class pulling away from nobody raises no alarm. */ export class RadarHazardTracker { private trackLength = 0; - private profile = new Float64Array(0); - private samples = new Uint32Array(0); private readonly states = new Map(); /** Session time the radar was last quiet. */ private lastQuiet = -Infinity; @@ -102,25 +110,15 @@ export class RadarHazardTracker { setTrackLength(metres: number): void { if (metres === this.trackLength) return; this.trackLength = metres; - const bins = metres > 0 ? Math.ceil(metres / PROFILE_BIN_M) : 0; - this.profile = new Float64Array(bins); - this.samples = new Uint32Array(bins); this.states.clear(); } - /** Forget every car; the speed profile is the track's and stays. */ + /** Forget every car. */ reset(): void { this.states.clear(); this.lastQuiet = -Infinity; } - /** Field speed at lap progress `pct` in m/s, or null while unknown. */ - expectedSpeed(pct: number): number | null { - const bin = this.binOf(pct); - if (bin < 0 || this.samples[bin] < PROFILE_MIN_SAMPLES) return null; - return this.profile[bin]; - } - update(frame: HazardFrame): RadarHazard[] { const hazards: RadarHazard[] = []; if (this.trackLength <= 0 || frame.settling) return hazards; @@ -134,6 +132,7 @@ export class RadarHazardTracker { const sinceQuiet = time - this.lastQuiet; if (sinceQuiet >= 0 && sinceQuiet < QUIET_GRACE_S) return hazards; + const focusSpeed = frame.speeds[frame.focus] ?? 0; for (let carIdx = 0; carIdx < pcts.length; carIdx += 1) { const pct = pcts[carIdx]; const surface = frame.surfaces[carIdx]; @@ -150,9 +149,19 @@ export class RadarHazardTracker { continue; } const speed = frame.speeds[carIdx] ?? 0; - const kind = this.judge(carIdx, pct, speed, surface, time); - if (!kind || carIdx === frame.focus) continue; const dist = wrapDelta(pct, frame.playerPct) * this.trackLength; + const closing = focusSpeed - speed; + const reachable = + dist > 0 && closing >= CLOSING_MIN_MS && dist / closing <= REACH_MAX_S; + const kind = this.judge( + carIdx, + speed, + surface, + time, + focusSpeed, + reachable + ); + if (!kind || carIdx === frame.focus) continue; // Only what is still ahead: once passed, it no longer concerns us. if (dist < 0 || dist > RADAR_HAZARD_MAX_M) continue; hazards.push({ carIdx, dist, kind, speed }); @@ -163,10 +172,11 @@ export class RadarHazardTracker { private judge( carIdx: number, - pct: number, speed: number, surface: unknown, - time: number + time: number, + focusSpeed: number, + reachable: boolean ): RadarHazardKind | null { let state = this.states.get(carIdx); if (!state) { @@ -174,37 +184,41 @@ export class RadarHazardTracker { this.states.set(carIdx, state); } - const expected = this.expectedSpeed(pct); - const fast = - expected !== null - ? speed >= expected * FAST_SHARE - : speed >= FAST_FALLBACK_MS; - const slow = - speed < STOPPED_MS || - (expected !== null && speed < expected * SLOW_SHARE); - const off = surface === TrackLocation.OffTrack; + // The car's own pace over the last moments, to tell a drop from a corner. + if (speed >= state.peak || time - state.peakAt > SUDDEN_WINDOW_S) { + state.peak = speed; + state.peakAt = time; + } + if ( + state.droppedFrom < 0 && + state.peak >= RACING_MS && + speed < state.peak * SUDDEN_SHARE + ) { + state.droppedFrom = state.peak; + state.droppedAt = time; + } else if ( + state.droppedFrom >= 0 && + speed >= state.droppedFrom * RECOVER_SHARE + ) { + state.droppedFrom = -1; + } + const off = surface === TrackLocation.OffTrack; if (off) { - if (state.offSince < 0) state.offSince = time; + if (state.offSince < 0) { + state.offSince = time; + state.offFrom = state.peak; + } } else if (state.offSince >= 0) { - if (time - state.offSince >= OFF_HOLD_S) state.rejoinedAt = time; + if (time - state.offSince >= OFF_HOLD_S) { + state.rejoinedAt = time; + state.rejoinFrom = state.offFrom; + } state.offSince = -1; } - - if (fast) { - state.lastFast = time; - state.slowSince = -1; - state.sudden = false; + // Back at the speed it had before the excursion: racing again. + if (state.rejoinedAt >= 0 && speed >= state.rejoinFrom * RECOVER_SHARE) { state.rejoinedAt = -1; - } else if (slow && state.slowSince < 0) { - state.slowSince = time; - state.sudden = - state.lastFast >= 0 && time - state.lastFast <= SUDDEN_WINDOW_S; - } - - // Only cars running normally on the track teach the profile. - if (!off && state.slowSince < 0 && state.rejoinedAt < 0) { - this.learn(pct, speed); } if (off) { @@ -214,29 +228,29 @@ export class RadarHazardTracker { if (time - state.rejoinedAt <= REJOIN_S) return 'rejoin'; state.rejoinedAt = -1; } - if (state.slowSince < 0) return null; - const held = time - state.slowSince; - if (held < (state.sudden ? SUDDEN_HOLD_S : SLOW_HOLD_S)) return null; - return state.sudden || speed < STOPPED_MS ? 'crash' : 'slow'; - } - private learn(pct: number, speed: number): void { - const bin = this.binOf(pct); - if (bin < 0 || speed <= 0) return; - const count = this.samples[bin]; - const current = this.profile[bin]; - if (count === 0) { - this.profile[bin] = speed; + if (speed < STOPPED_MS) { + if (state.stoppedSince < 0) state.stoppedSince = time; } else { - const weight = speed > current ? PROFILE_RISE : PROFILE_SINK; - this.profile[bin] = current + weight * (speed - current); + state.stoppedSince = -1; + } + if (state.stoppedSince >= 0 && time - state.stoppedSince >= SLOW_HOLD_S) { + return 'crash'; + } + if ( + state.droppedFrom >= 0 && + reachable && + time - state.droppedAt >= SUDDEN_HOLD_S + ) { + return 'crash'; } - if (count < PROFILE_MIN_SAMPLES) this.samples[bin] = count + 1; - } - private binOf(pct: number): number { - const bins = this.profile.length; - if (bins === 0 || pct < 0) return -1; - return Math.min(Math.floor((pct % 1) * bins), bins - 1); + const slow = reachable && speed < focusSpeed * SLOW_SHARE; + if (!slow) { + state.slowSince = -1; + return null; + } + if (state.slowSince < 0) state.slowSince = time; + return time - state.slowSince >= SLOW_HOLD_S ? 'slow' : null; } } diff --git a/src/app/processors/radarLanes.spec.ts b/src/app/processors/radarLanes.spec.ts index cb4a26d43..b5e3dc276 100644 --- a/src/app/processors/radarLanes.spec.ts +++ b/src/app/processors/radarLanes.spec.ts @@ -3,6 +3,7 @@ import { CarLeftRight } from '@irdashies/types'; import { MEMORY_HOLD_S, RadarLaneTracker, + separateInLaneCars, type LaneFormation, type LaneInput, type LaneOutput, @@ -30,6 +31,38 @@ const laneOf = (lanes: Map, carIdx: number) => lanes.get(carIdx)?.lane; describe('RadarLaneTracker', () => { + it("gives the spotter's sides to the nearest cars, not the longest remembered", () => { + const tracker = new RadarLaneTracker(); + // Two cars settle on our left. + const first = settle( + tracker, + 0, + [ + { carIdx: 1, dist: -1 }, + { carIdx: 2, dist: 1 }, + ], + CarLeftRight.Cars2Left + ); + // They drop back to the edge of the overlap while a third car comes + // alongside: the spotter still says two on the left, and the new car + // right beside us is one of them. + const { lanes } = settle( + tracker, + first.time + 0.1, + [ + { carIdx: 1, dist: -4.1 }, + { carIdx: 2, dist: -3.85 }, + { carIdx: 3, dist: 1.8 }, + ], + CarLeftRight.Cars2Left + ); + + expect(lanes.get(3)?.source).toBe('spotter'); + expect( + lanes.get(1)?.source === 'spotter' && lanes.get(2)?.source === 'spotter' + ).toBe(false); + }); + it('leaves rivals in our lane while the spotter is clear', () => { const tracker = new RadarLaneTracker(); const { lanes } = settle( @@ -277,7 +310,7 @@ describe('RadarLaneTracker', () => { it('puts the pole line on the left of the pace field on ovals', () => { const tracker = new RadarLaneTracker(); - tracker.setOval(true); + tracker.setPacePoleLeft(true); const { lanes } = settle( tracker, 0, @@ -340,3 +373,24 @@ describe('RadarLaneTracker', () => { }); }); }); + +describe('separateInLaneCars', () => { + it('pushes an unplaced car that overlaps us out to a body length', () => { + const cars = [ + { dist: 1.8, lane: 0, laneSource: 'none' }, + { dist: -2.7, lane: 0, laneSource: 'none' }, + ]; + separateInLaneCars(cars); + expect(cars.map((car) => car.dist)).toEqual([5, -5]); + }); + + it('leaves a car the spotter placed, and cars already clear', () => { + const cars = [ + { dist: 1.8, lane: -1, laneSource: 'spotter' }, + { dist: 6, lane: 0, laneSource: 'none' }, + { dist: -1, lane: 0, laneSource: 'pair' }, + ]; + separateInLaneCars(cars); + expect(cars.map((car) => car.dist)).toEqual([1.8, 6, -1]); + }); +}); diff --git a/src/app/processors/radarLanes.ts b/src/app/processors/radarLanes.ts index 5f9840a07..b3bec66b2 100644 --- a/src/app/processors/radarLanes.ts +++ b/src/app/processors/radarLanes.ts @@ -28,6 +28,13 @@ export interface LaneOutput { export const OVERLAP_M = 5.5; /** Two rivals closer than this to each other must be side by side. */ export const PAIR_M = 4; +/** + * Two cars in one lane cannot be closer than a body length along the track, + * so one that is, with no side from the spotter, is drawn this far off. + */ +export const IN_LANE_GAP_M = 5; +/** Distances this close, in metres, count as equally near when ranking. */ +const NEAR_BAND_M = 1.5; /** How long a side learnt from the spotter is kept after the overlap ends. */ export const MEMORY_HOLD_S = 3; const MAX_LANE = 2; @@ -59,13 +66,13 @@ const demandFor = (carLeftRight: number): SpotterDemand | null => { export type FormationKind = 'grid' | 'pace'; /** - * Pole column side: on the left of a standing grid, on the right of the pace - * line on road courses and on the left of it on ovals (the inside line). - * iRacing does not say; this is what its grids do. +1 means the pole is on - * the left, so higher columns lie to the right. + * Pole column side: on the left of a standing grid; for a pace line it + * depends on the track, see paceFieldPoleLeft. iRacing does not say; this is + * what its grids do. +1 means the pole is on the left, so higher columns lie + * to the right. */ -const poleSign = (kind: FormationKind, oval: boolean): number => - kind === 'grid' || oval ? 1 : -1; +const poleSign = (kind: FormationKind, pacePoleLeft: boolean): number => + kind === 'grid' || pacePoleLeft ? 1 : -1; export interface LaneFormation { kind: FormationKind; @@ -107,15 +114,15 @@ export class RadarLaneTracker { private readonly rememberedAt = new Map(); private readonly drawnLane = new Map(); private previousTime = -1; - private oval = false; + private pacePoleLeft = false; constructor( private readonly tuning: () => RadarProcessorTuning = () => DEFAULT_RADAR_TUNING ) {} - /** Ovals line the pace field up with the pole on the inside, the left. */ - setOval(oval: boolean): void { - this.oval = oval; + /** Which side the pole line of a pace field is on, see paceFieldPoleLeft. */ + setPacePoleLeft(left: boolean): void { + this.pacePoleLeft = left; } reset(): void { @@ -220,14 +227,19 @@ export class RadarLaneTracker { const fill = (count: number, side: -1 | 1) => { if (count === 0) return; - // Cars already known on this side first, then unknown, then the far - // side; nearest first within each group. + // The nearest cars are the ones the spotter sees. Among cars about as + // near, those already known on this side first, then unknown, then the + // far side; a remembered car that has dropped back loses its place to + // one that is now alongside. const ranked = candidates .filter((car) => !taken.has(car.carIdx)) .sort((a, b) => { const rank = (car: LaneInput) => sideOf(car.carIdx) === side ? 0 : sideOf(car.carIdx) === 0 ? 1 : 2; + const band = (car: LaneInput) => + Math.floor(Math.abs(car.dist) / NEAR_BAND_M); return ( + band(a) - band(b) || rank(a) - rank(b) || Math.abs(a.dist) - Math.abs(b.dist) || a.carIdx - b.carIdx @@ -262,7 +274,9 @@ export class RadarLaneTracker { for (const car of cars) { const slot = formation.slots.get(car.carIdx); if (slot === undefined) continue; - const lane = clampLane(slot * poleSign(formation.kind, this.oval)); + const lane = clampLane( + slot * poleSign(formation.kind, this.pacePoleLeft) + ); targets.set(car.carIdx, { lane, source: formation.kind }); // Remembered too, so the formation fades out gently after the green. this.rememberedLane.set(car.carIdx, lane); @@ -326,3 +340,24 @@ export class RadarLaneTracker { } } } + +/** + * Pushes cars that overlap the focus car along the track out to a body + * length when nothing says they are beside it. The spotter is silent about + * them, so they are not alongside; the overlap is noise in lap distance, and + * drawing them on top of the focus car is worse than drawing them nose to + * tail. + */ +export const separateInLaneCars = ( + cars: { dist: number; lane: number; laneSource: string }[] +): void => { + for (const car of cars) { + if ( + car.laneSource === 'none' && + Math.abs(car.lane) < 0.5 && + Math.abs(car.dist) < IN_LANE_GAP_M + ) { + car.dist = (car.dist < 0 ? -1 : 1) * IN_LANE_GAP_M; + } + } +}; diff --git a/src/frontend/components/Radar/radarDraw.spec.ts b/src/frontend/components/Radar/radarDraw.spec.ts index 06ebafff2..7fcb8aae0 100644 --- a/src/frontend/components/Radar/radarDraw.spec.ts +++ b/src/frontend/components/Radar/radarDraw.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { warningLevel } from './radarDraw'; +import { followLineEnds, warningLevel } from './radarDraw'; describe('warningLevel', () => { // Two 4.6 m cars: bodies overlap while the centres are within 4.6 m. @@ -23,3 +23,22 @@ describe('warningLevel', () => { expect(warningLevel(4.7, 5.2, 4.0, 7)).toBe('close'); }); }); + +describe('followLineEnds', () => { + it('runs from the nose of our car to the tail of the one ahead', () => { + const ends = followLineEnds({ x: 100, y: 100 }, { x: 100, y: 20 }, 10, 12); + expect(ends).toEqual({ x1: 100, y1: 90, x2: 100, y2: 32 }); + }); + + it('follows a car off to the side', () => { + const ends = followLineEnds({ x: 0, y: 0 }, { x: 30, y: -40 }, 5, 5); + expect(ends?.x1).toBeCloseTo(3, 5); + expect(ends?.y1).toBeCloseTo(-4, 5); + expect(ends?.x2).toBeCloseTo(27, 5); + expect(ends?.y2).toBeCloseTo(-36, 5); + }); + + it('draws nothing when the cars nearly touch', () => { + expect(followLineEnds({ x: 0, y: 0 }, { x: 0, y: -20 }, 10, 10)).toBeNull(); + }); +}); diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index dee26a6ff..42a095b35 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -294,6 +294,59 @@ const drawCarBody = ( ctx.restore(); }; +const FOLLOW_DASH = [6, 5]; + +/** + * Ends of the line from our car to the one we follow, each pulled in by half + * a car so it runs nose to tail, or null when the cars nearly touch. + */ +export const followLineEnds = ( + from: { x: number; y: number }, + to: { x: number; y: number }, + fromHalfLength: number, + toHalfLength: number +) => { + const dx = to.x - from.x; + const dy = to.y - from.y; + const distance = Math.hypot(dx, dy); + if (distance <= fromHalfLength + toHalfLength + 2) return null; + const ux = dx / distance; + const uy = dy / distance; + return { + x1: from.x + ux * fromHalfLength, + y1: from.y + uy * fromHalfLength, + x2: to.x - ux * toHalfLength, + y2: to.y - uy * toHalfLength, + }; +}; + +/** Dashed line tying our car to the one we have to hold station behind. */ +const drawFollowLine = ( + ctx: CanvasRenderingContext2D, + centre: number, + target: { x: number; y: number }, + playerLength: number, + targetLength: number +) => { + const ends = followLineEnds( + { x: centre, y: centre }, + target, + playerLength / 2, + targetLength / 2 + ); + if (!ends) return; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(ends.x1, ends.y1); + ctx.lineTo(ends.x2, ends.y2); + ctx.setLineDash(FOLLOW_DASH); + ctx.lineWidth = 2; + ctx.strokeStyle = FOLLOW_COLOR; + ctx.globalAlpha = 0.85; + ctx.stroke(); + ctx.restore(); +}; + const drawFollowOutline = ( ctx: CanvasRenderingContext2D, length: number, @@ -1162,12 +1215,14 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { const car = scene.cars.find((c) => c.carIdx === follow.carIdx); if (car) { projector.project(car.dist, car.lateral, pose); + drawFollowLine(ctx, centre, pose, length, car.length * pixelsPerMetre); drawFollowOutline( ctx, car.length * pixelsPerMetre, car.width * pixelsPerMetre ); } else { + drawFollowLine(ctx, centre, pose, length, length); drawCarBody( ctx, pose.x, diff --git a/src/frontend/domain/radar/radarProfile.spec.ts b/src/frontend/domain/radar/radarProfile.spec.ts index 7eaa2d761..d02dd9aa1 100644 --- a/src/frontend/domain/radar/radarProfile.spec.ts +++ b/src/frontend/domain/radar/radarProfile.spec.ts @@ -13,7 +13,11 @@ describe('isOvalTrack', () => { it('recognises every kind of oval', () => { expect(isOvalTrack('oval')).toBe(true); expect(isOvalTrack('dirt oval')).toBe(true); + expect(isOvalTrack('super speedway')).toBe(true); + expect(isOvalTrack('short track')).toBe(true); expect(isOvalTrack('road course')).toBe(false); + expect(isOvalTrack('dirt road')).toBe(false); + expect(isOvalTrack('')).toBe(false); expect(isOvalTrack(undefined)).toBe(false); }); }); diff --git a/src/frontend/domain/radar/radarProfile.ts b/src/frontend/domain/radar/radarProfile.ts index d76b881bf..06205955e 100644 --- a/src/frontend/domain/radar/radarProfile.ts +++ b/src/frontend/domain/radar/radarProfile.ts @@ -7,9 +7,13 @@ import { export type RadarProfileId = 'road' | 'oval'; -/** WeekendInfo.TrackType: "road course", "oval", "dirt oval", "dirt road". */ +/** + * WeekendInfo.TrackType: "road course", "short track", "super speedway", + * "dirt oval", "dirt road" and so on. Anything that is not road racing is an + * oval; an unknown type (empty, as LMU gives) counts as a road course. + */ export const isOvalTrack = (trackType: string | undefined): boolean => - /oval/i.test(trackType ?? ''); + !!trackType && !/road|rally/i.test(trackType); const PROFILE_KEY_SET: ReadonlySet = new Set(RADAR_PROFILE_KEYS); From bfee57512db3c92734b6ea719e548264b1a0c570 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 04:03:22 +0300 Subject: [PATCH 02/32] fix: radar keeps pit lane cars alongside and splits the draw module Cars across the pit wall were pushed a body length away by separateInLaneCars; they now stay where they are. radarDraw is split into types, shared, follow, warnings, hazards and debug modules, wrapDelta is shared, closingSpeed is renamed relativeSpeed, and auto-hide is derived during render. Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 18 +- src/app/processors/RadarProcessor.ts | 18 +- src/app/processors/radarHazards.ts | 9 +- src/app/processors/radarLanes.spec.ts | 8 + src/app/processors/radarLanes.ts | 12 +- src/app/processors/radarMath.ts | 7 + src/frontend/components/Radar/Radar.tsx | 24 +- .../Radar/components/RadarDisplay.tsx | 6 +- src/frontend/components/Radar/radarDemo.ts | 10 +- src/frontend/components/Radar/radarDraw.ts | 973 ++---------------- .../components/Radar/radarDrawDebug.ts | 49 + .../components/Radar/radarDrawFollow.ts | 121 +++ .../components/Radar/radarDrawHazards.ts | 232 +++++ .../components/Radar/radarDrawShared.ts | 31 + .../components/Radar/radarDrawTypes.ts | 152 +++ .../components/Radar/radarDrawWarnings.ts | 277 +++++ .../components/Radar/radarHints.spec.ts | 6 +- src/frontend/components/Radar/radarHints.ts | 12 +- .../components/Radar/radarModel.spec.ts | 2 +- src/types/channels/channel.ts | 2 +- 20 files changed, 1037 insertions(+), 932 deletions(-) create mode 100644 src/app/processors/radarMath.ts create mode 100644 src/frontend/components/Radar/radarDrawDebug.ts create mode 100644 src/frontend/components/Radar/radarDrawFollow.ts create mode 100644 src/frontend/components/Radar/radarDrawHazards.ts create mode 100644 src/frontend/components/Radar/radarDrawShared.ts create mode 100644 src/frontend/components/Radar/radarDrawTypes.ts create mode 100644 src/frontend/components/Radar/radarDrawWarnings.ts diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 423a40fd4..0ec5df580 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Session, Telemetry } from '@irdashies/types'; import { CarLeftRight, GlobalFlags, TrackLocation } from '@irdashies/types'; import { DEFAULT_RADAR_TUNING } from '../../types/widgetConfigs'; +import radarManifest from '../../types/widgets/radar'; import rollingTelemetry from '../../../test-data/1752616787255/telemetry.json'; import rollingSession from '../../../test-data/1752616787255/session.json'; import { @@ -151,7 +152,7 @@ describe('RadarProcessor', () => { const snapshot = processor.snapshot(); expect(snapshot.playerSpeed).toBeCloseTo(50, 1); - expect(snapshot.cars[0].closingSpeed).toBeCloseTo(5, 1); + expect(snapshot.cars[0].relativeSpeed).toBeCloseTo(5, 1); }); it('smooths speeds the same at any call rate', () => { @@ -165,7 +166,7 @@ describe('RadarProcessor', () => { rival += m((time <= 1 ? 50 : 60) / hz); processor.onFrame(frame({ time, pcts: [0.5 + m(50 * time), rival] })); } - return processor.snapshot().cars[0].closingSpeed; + return processor.snapshot().cars[0].relativeSpeed; }; expect(closingAfterStep(25)).toBeCloseTo(closingAfterStep(60), 2); @@ -182,7 +183,7 @@ describe('RadarProcessor', () => { } const { speedSmoothing } = DEFAULT_RADAR_TUNING; - expect(Math.abs(processor.snapshot().cars[0].closingSpeed)).toBeCloseTo( + expect(Math.abs(processor.snapshot().cars[0].relativeSpeed)).toBeCloseTo( 10 * speedSmoothing, 1 ); @@ -193,7 +194,7 @@ describe('RadarProcessor', () => { processor.onFrame(frame({ time: 0, pcts: [0.5, 0.5 - m(5)] })); processor.onFrame(frame({ time: 1 / 60, pcts: [0.5, 0.5 + m(5)] })); - expect(processor.snapshot().cars[0].closingSpeed).toBe(0); + expect(processor.snapshot().cars[0].relativeSpeed).toBe(0); }); it('reports pit road and off-track state per car', () => { @@ -398,3 +399,12 @@ describe('RadarProcessor', () => { }); }); }); + +describe('RADAR_MAX_RANGE_M', () => { + it('covers the largest range the settings allow', () => { + const range = radarManifest.properties?.range; + expect(range && 'max' in range ? range.max : NaN).toBeLessThanOrEqual( + RADAR_MAX_RANGE_M + ); + }); +}); diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 5db368fea..786135884 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -24,6 +24,7 @@ import { type GridOptions, } from './radarFormation'; import { RadarHazardTracker } from './radarHazards'; +import { wrapDelta } from './radarMath'; /** * Metres either side of the focus car that still reach the renderer. The @@ -75,14 +76,6 @@ const numberAt = ( const scalarNumber = (frame: Telemetry, key: string, fallback = -1): number => numberAt(valuesOf(frame, key), 0, fallback); -/** Shortest signed lap fraction from `from` to `to`, in -0.5..0.5. */ -const wrapDelta = (to: number, from: number): number => { - let delta = to - from; - if (delta > 0.5) delta -= 1; - else if (delta < -0.5) delta += 1; - return delta; -}; - /** WeekendInfo.TrackLength is a string such as "5.51 km" or "3.42 mi". */ export const parseTrackLength = (raw: string | undefined): number => { const [value, unit] = raw?.trim().split(/\s+/) ?? []; @@ -235,7 +228,7 @@ export class RadarProcessor implements TelemetryProcessor { cars.push({ carIdx, dist, - closingSpeed: (this.speeds[carIdx] ?? playerSpeed) - playerSpeed, + relativeSpeed: (this.speeds[carIdx] ?? playerSpeed) - playerSpeed, lane: 0, laneSource: 'none', onPitRoad: onPitRoad[carIdx] === true, @@ -244,7 +237,7 @@ export class RadarProcessor implements TelemetryProcessor { } cars.sort((a, b) => Math.abs(a.dist) - Math.abs(b.dist)); this.assignLanes(frame, time, focus, isOnTrack, cars, formation); - separateInLaneCars(cars); + separateInLaneCars(cars, onPitRoad[focus] === true); } // The pedals are the player's, so they say nothing about a camera car. @@ -398,8 +391,9 @@ const emptySnapshot = (version: number): RadarSnapshot => ({ /** * Any car in range moves every frame, so only an empty radar can stay - * unchanged (and a hazard ahead closes in, so it counts as a car). The focus car's progress still counts there: with auto-hide off - * the map keeps turning under an empty radar. + * unchanged (and a hazard ahead closes in, so it counts as a car). The focus + * car's progress still counts there: with auto-hide off the map keeps + * turning under an empty radar. */ const sameSnapshot = ( previous: RadarSnapshot, diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index 4a86baf5d..d6b51fed8 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -1,5 +1,6 @@ import type { RadarHazard, RadarHazardKind } from '@irdashies/types'; import { TrackLocation } from '@irdashies/types'; +import { wrapDelta } from './radarMath'; /** Metres ahead hazards are reported to the renderer, past any setting. */ export const RADAR_HAZARD_MAX_M = 1000; @@ -83,14 +84,6 @@ export interface HazardFrame { quiet: boolean; } -/** Shortest signed lap fraction from `from` to `to`, in -0.5..0.5. */ -const wrapDelta = (to: number, from: number): number => { - let delta = to - from; - if (delta > 0.5) delta -= 1; - else if (delta < -0.5) delta += 1; - return delta; -}; - /** * Finds cars ahead that are crashed, crawling, off the track or rejoining. * diff --git a/src/app/processors/radarLanes.spec.ts b/src/app/processors/radarLanes.spec.ts index b5e3dc276..62c449071 100644 --- a/src/app/processors/radarLanes.spec.ts +++ b/src/app/processors/radarLanes.spec.ts @@ -393,4 +393,12 @@ describe('separateInLaneCars', () => { separateInLaneCars(cars); expect(cars.map((car) => car.dist)).toEqual([1.8, 6, -1]); }); + + it('leaves a car across the pit wall alongside', () => { + const cars = [{ dist: 1.8, lane: 0, laneSource: 'none', onPitRoad: true }]; + separateInLaneCars(cars, false); + expect(cars[0].dist).toBe(1.8); + separateInLaneCars(cars, true); + expect(cars[0].dist).toBe(5); + }); }); diff --git a/src/app/processors/radarLanes.ts b/src/app/processors/radarLanes.ts index b3bec66b2..35632b857 100644 --- a/src/app/processors/radarLanes.ts +++ b/src/app/processors/radarLanes.ts @@ -346,12 +346,20 @@ export class RadarLaneTracker { * length when nothing says they are beside it. The spotter is silent about * them, so they are not alongside; the overlap is noise in lap distance, and * drawing them on top of the focus car is worse than drawing them nose to - * tail. + * tail. Cars on the other side of the pit wall are left where they are. */ export const separateInLaneCars = ( - cars: { dist: number; lane: number; laneSource: string }[] + cars: { + dist: number; + lane: number; + laneSource: string; + onPitRoad?: boolean; + }[], + focusOnPitRoad = false ): void => { for (const car of cars) { + // Across the pit wall is beside the track, not in our lane of it. + if ((car.onPitRoad ?? focusOnPitRoad) !== focusOnPitRoad) continue; if ( car.laneSource === 'none' && Math.abs(car.lane) < 0.5 && diff --git a/src/app/processors/radarMath.ts b/src/app/processors/radarMath.ts new file mode 100644 index 000000000..8259fe75e --- /dev/null +++ b/src/app/processors/radarMath.ts @@ -0,0 +1,7 @@ +/** Shortest signed lap fraction from `from` to `to`, in -0.5..0.5. */ +export const wrapDelta = (to: number, from: number): number => { + let delta = to - from; + if (delta > 0.5) delta -= 1; + else if (delta < -0.5) delta += 1; + return delta; +}; diff --git a/src/frontend/components/Radar/Radar.tsx b/src/frontend/components/Radar/Radar.tsx index d654eaa9e..0e00ee6f7 100644 --- a/src/frontend/components/Radar/Radar.tsx +++ b/src/frontend/components/Radar/Radar.tsx @@ -196,23 +196,15 @@ export const Radar = () => { hazardWanted(hazard, style) ); const nearest = hazardShown ? 0 : nearestDistance(frame.cars); + // Hysteresis needs the last answer, so it is derived while rendering. const [autoVisible, setAutoVisible] = useState(false); - useEffect(() => { - setAutoVisible((wasVisible) => - nextAutoHideVisible(wasVisible, nearest, { - showDistance: settings.showDistance, - hideDistance: settings.hideDistance, - range: settings.range, - carLength: settings.carLength, - }) - ); - }, [ - nearest, - settings.showDistance, - settings.hideDistance, - settings.range, - settings.carLength, - ]); + const nextAutoVisible = nextAutoHideVisible(autoVisible, nearest, { + showDistance: settings.showDistance, + hideDistance: settings.hideDistance, + range: settings.range, + carLength: settings.carLength, + }); + if (nextAutoVisible !== autoVisible) setAutoVisible(nextAutoVisible); const onTrackOk = !settings.showOnlyWhenOnTrack || snapshot.isOnTrack; const inPitBox = settings.hideInPitBox && snapshot.focusInPitBox; diff --git a/src/frontend/components/Radar/components/RadarDisplay.tsx b/src/frontend/components/Radar/components/RadarDisplay.tsx index 4a56c80db..aeb854ef7 100644 --- a/src/frontend/components/Radar/components/RadarDisplay.tsx +++ b/src/frontend/components/Radar/components/RadarDisplay.tsx @@ -15,7 +15,7 @@ import type { RadarHazard } from '@irdashies/types'; export interface RadarFrameCar { carIdx: number; dist: number; - closingSpeed: number; + relativeSpeed: number; lane: number; offTrack: boolean; onPitRoad?: boolean; @@ -183,8 +183,8 @@ export const RadarDisplay = memo( current.appearance.get(car.carIdx) ?? FALLBACK_APPEARANCE; const target = cars[index] ?? ({} as RadarDrawCar); target.carIdx = car.carIdx; - target.dist = car.dist + car.closingSpeed * elapsed; - target.closingSpeed = car.closingSpeed; + target.dist = car.dist + car.relativeSpeed * elapsed; + target.relativeSpeed = car.relativeSpeed; target.lateral = -car.lane * laneWidth; target.lane = car.lane; target.offTrack = car.offTrack; diff --git a/src/frontend/components/Radar/radarDemo.ts b/src/frontend/components/Radar/radarDemo.ts index 8548dc237..0db26e8b9 100644 --- a/src/frontend/components/Radar/radarDemo.ts +++ b/src/frontend/components/Radar/radarDemo.ts @@ -39,7 +39,7 @@ export const demoRadarFrame = (seconds: number): RadarFrame => { const wreck = { carIdx: 5, dist: wreckDist, - closingSpeed: -DEMO_SPEED_MS, + relativeSpeed: -DEMO_SPEED_MS, lane: 2, offTrack: false, }; @@ -54,28 +54,28 @@ export const demoRadarFrame = (seconds: number): RadarFrame => { { carIdx: 1, dist: dive.dist, - closingSpeed: dive.closing, + relativeSpeed: dive.closing, lane: diverLane, offTrack: false, }, { carIdx: 2, dist: 3 - wave * 12, - closingSpeed: 0, + relativeSpeed: 0, lane: 1, offTrack: false, }, { carIdx: 3, dist: 16 + wave * 2, - closingSpeed: 0, + relativeSpeed: 0, lane: 0, offTrack: false, }, { carIdx: 4, dist: 23 + wave * 2, - closingSpeed: 0, + relativeSpeed: 0, lane: 0, offTrack: false, }, diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index 42a095b35..219cf75a9 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -1,171 +1,46 @@ -import { RadarProjector, type ScreenPose } from './radarProjection'; -import type { TrackGeometry } from '@irdashies/domain/track'; -import { overlapOf, type DiveHint } from './radarHints'; -import type { RadarArcStyle, RadarHazardKind } from '@irdashies/types'; -import { arcThicknessPx, paintRimArc } from './radarArcs'; -import { formatDistance, speedIn, speedUnit } from './radarStyle'; - -export interface RadarDrawCar { - carIdx: number; - /** Metres along the track from the focus car, positive ahead. */ - dist: number; - /** Metres from our line, positive to the left. */ - lateral: number; - /** Rate of change of `dist` in m/s. */ - closingSpeed: number; - /** Lane from the spotter, for the debug labels. */ - lane: number; - offTrack: boolean; - /** Body size in metres. */ - length: number; - width: number; - fill: string; - textColor: string; - label: string; -} - -export type WarningLevel = 'none' | 'close' | 'alongside'; - -/** - * How worried to be about a rival: alongside once the bodies overlap along - * the track, close inside `cautionDistance` metres of bumper-to-bumper gap. - */ -export const warningLevel = ( - dist: number, - carLength: number, - playerLength: number, - cautionDistance: number -): WarningLevel => { - const gap = Math.abs(dist) - (carLength + playerLength) / 2; - if (gap <= 0) return 'alongside'; - if (gap < cautionDistance) return 'close'; - return 'none'; -}; - -export interface RadarStyle { - /** Labels in metres and km/h; imperial otherwise. */ - metric: boolean; - range: number; - /** Our car's body size in metres; also used for the pace car. */ - carLength: number; - carWidth: number; - showWarnings: boolean; - warningArcs: boolean; - warningArcStyle: RadarArcStyle; - /** Metres of bumper gap below which a rival counts as close. */ - cautionDistance: number; - /** Bumper gap written next to a close rival. */ - showGapLabel: boolean; - showCarNumbers: boolean; - showTrackMap: boolean; - trackWidth: number; - /** 0-100 */ - mapOpacity: number; - showRings: boolean; - ringSpacing: number; - playerColor: string; - /** 0-100 */ - backgroundOpacity: number; - /** Share of the radius, 0-100, over which the picture fades out at the rim. */ - edgeFade: number; - showCrosshair: boolean; - /** Run the dashes of the line ahead/behind past at our speed. */ - axisMotion: boolean; - /** Metres of each moving dash; the gaps are twice as long. */ - axisDashLength: number; - /** Speed of the moving dashes, % of our own. */ - axisSpeed: number; - closeColor: string; - alongsideColor: string; - /** Pulses per second on cars alongside; 0 keeps them steady. */ - pulseHz: number; - arcMinDeg: number; - arcMaxDeg: number; - /** Rim arc thickness, % of the radius. */ - arcThickness: number; - showOverlap: boolean; - /** Share of a car's length from which the overlap strip turns red. */ - overlapThreshold: number; - overlapShowPercent: boolean; - diveGhost: boolean; - diveShowClosing: boolean; - diveArcs: boolean; - diveArcStyle: RadarArcStyle; - diveArcMinDeg: number; - diveArcMaxDeg: number; - showHazards: boolean; - /** Metres ahead a hazard is shown from. */ - hazardRange: number; - /** Metres under which a hazard flashes. */ - hazardBlinkDistance: number; - hazardCrash: boolean; - hazardSlow: boolean; - hazardOff: boolean; - hazardShowLabel: boolean; - hazardShowSpeed: boolean; - hazardArcs: boolean; - hazardArcStyle: RadarArcStyle; - hazardArcMinDeg: number; - hazardArcMaxDeg: number; - /** Car numbers are left out on cars drawn smaller than this, in px. */ - minLabelPx: number; - debugLabels: boolean; - showFrameTime: boolean; -} - -/** The car to line up behind while pacing. */ -export interface RadarDrawFollow { - /** Car index, or null for the pace car (which is not in `cars`). */ - carIdx: number | null; - dist: number; - label: string; -} - -/** A car in trouble ahead, already filtered by the hazard settings. */ -export interface RadarDrawHazard { - carIdx: number; - /** Metres along the track from the focus car, positive ahead. */ - dist: number; - kind: RadarHazardKind; - /** Its own speed in m/s. */ - speed: number; -} - -export interface RadarScene { - /** CSS pixels of the square the radar fills. */ - size: number; - geometry: TrackGeometry | null; - trackLength: number; - playerPct: number; - cars: readonly RadarDrawCar[]; - follow: RadarDrawFollow | null; - style: RadarStyle; - /** Seconds, for the pulse on cars alongside. */ - time: number; - /** Metres we have driven, for the moving dashes. */ - travelled: number; - /** Milliseconds the previous frame took, for the dev readout. */ - frameMs?: number; - /** Cars coming up fast behind, by car index. */ - dives?: ReadonlyMap; - /** Metres between lane centres, to place a diving car's ghost. */ - laneWidth?: number; - /** Cars in trouble ahead, nearest first. */ - hazards?: readonly RadarDrawHazard[]; -} - -const FOLLOW_COLOR = '#22c55e'; -const DEBUG_COLOR = '#fbbf24'; -const PACE_CAR_FILL = '#e2e8f0'; +import { overlapOf } from './radarHints'; +import { formatDistance, speedIn } from './radarStyle'; +import { warningLevel } from './radarDrawTypes'; +import type { RadarScene, RadarStyle } from './radarDrawTypes'; + +import { MIN_TEXT_PX, drawCarBody, pose, projector } from './radarDrawShared'; +import { + GHOST_AHEAD_S, + drawDiveGhost, + drawHintText, + drawOverlapPercent, + drawOverlapStrip, + drawWarningArc, + drawWarningOutline, + ghostPose, + ourStrips, + queueRimWarning, + resetRimWarnings, + rimWarningAt, + rimWarningTotal, +} from './radarDrawWarnings'; +import { + CRASH_TILT, + HAZARD_FLASH_HZ, + drawHazardMarker, + drawHazardOnCar, + hazardOf, +} from './radarDrawHazards'; +import { drawDebug, queueDebugLabel, resetDebugLabels } from './radarDrawDebug'; +import { + PACE_CAR_FILL, + drawFollowLine, + drawFollowOutline, + drawFollowPointer, +} from './radarDrawFollow'; + +export * from './radarDrawTypes'; +export { followLineEnds } from './radarDrawFollow'; +export { hazardWanted } from './radarDrawHazards'; /** How far past the rim the road is drawn, so it never ends inside the disc. */ const ROAD_OVERSCAN = 1.5; const ROAD_STEP_M = 1; -/** Smallest ring label and pointer text, whatever the label setting. */ -const MIN_TEXT_PX = 8; - -const projector = new RadarProjector(); -const pose: ScreenPose = { x: 0, y: 0, angle: 0 }; const drawRoad = ( ctx: CanvasRenderingContext2D, @@ -270,671 +145,81 @@ const drawRings = ( } }; -const drawCarBody = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - angle: number, - length: number, - width: number, - fill: string -) => { - ctx.save(); - ctx.translate(x, y); - // Body length runs along the direction of travel. - ctx.rotate(angle); - const radius = Math.min(width, length) * 0.25; - ctx.beginPath(); - ctx.roundRect(-length / 2, -width / 2, length, width, radius); - ctx.fillStyle = fill; - ctx.fill(); - ctx.lineWidth = 1; - ctx.strokeStyle = 'rgba(15, 23, 42, 0.9)'; - ctx.stroke(); - ctx.restore(); -}; - -const FOLLOW_DASH = [6, 5]; - -/** - * Ends of the line from our car to the one we follow, each pulled in by half - * a car so it runs nose to tail, or null when the cars nearly touch. - */ -export const followLineEnds = ( - from: { x: number; y: number }, - to: { x: number; y: number }, - fromHalfLength: number, - toHalfLength: number -) => { - const dx = to.x - from.x; - const dy = to.y - from.y; - const distance = Math.hypot(dx, dy); - if (distance <= fromHalfLength + toHalfLength + 2) return null; - const ux = dx / distance; - const uy = dy / distance; - return { - x1: from.x + ux * fromHalfLength, - y1: from.y + uy * fromHalfLength, - x2: to.x - ux * toHalfLength, - y2: to.y - uy * toHalfLength, - }; -}; - -/** Dashed line tying our car to the one we have to hold station behind. */ -const drawFollowLine = ( - ctx: CanvasRenderingContext2D, - centre: number, - target: { x: number; y: number }, - playerLength: number, - targetLength: number -) => { - const ends = followLineEnds( - { x: centre, y: centre }, - target, - playerLength / 2, - targetLength / 2 - ); - if (!ends) return; - ctx.save(); - ctx.beginPath(); - ctx.moveTo(ends.x1, ends.y1); - ctx.lineTo(ends.x2, ends.y2); - ctx.setLineDash(FOLLOW_DASH); - ctx.lineWidth = 2; - ctx.strokeStyle = FOLLOW_COLOR; - ctx.globalAlpha = 0.85; - ctx.stroke(); - ctx.restore(); -}; - -const drawFollowOutline = ( - ctx: CanvasRenderingContext2D, - length: number, - width: number -) => { - ctx.save(); - ctx.translate(pose.x, pose.y); - ctx.rotate(pose.angle); - const pad = Math.max(2, width * 0.25); - ctx.beginPath(); - ctx.roundRect( - -length / 2 - pad, - -width / 2 - pad, - length + pad * 2, - width + pad * 2, - pad * 2 - ); - ctx.lineWidth = 2; - ctx.strokeStyle = FOLLOW_COLOR; - ctx.stroke(); - ctx.restore(); -}; - -/** Chevron on the rim pointing at a follow target beyond the range. */ -const drawFollowPointer = ( +/** Our own overlap strips, one per side, with our car pointing up. */ +const drawOurStrips = ( ctx: CanvasRenderingContext2D, centre: number, - radius: number, - follow: RadarDrawFollow, - metric: boolean -) => { - const bearing = Math.atan2(pose.y - centre, pose.x - centre); - const tipX = centre + Math.cos(bearing) * (radius - 3); - const tipY = centre + Math.sin(bearing) * (radius - 3); - const size = Math.max(7, radius / 12); - ctx.save(); - ctx.translate(tipX, tipY); - ctx.rotate(bearing); - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(-size, -size * 0.7); - ctx.lineTo(-size, size * 0.7); - ctx.closePath(); - ctx.fillStyle = FOLLOW_COLOR; - ctx.fill(); - ctx.restore(); - - const fontSize = Math.max(MIN_TEXT_PX, Math.round(radius / 11)); - ctx.font = `bold ${fontSize}px Lato, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const labelDistance = radius - size - fontSize * 1.2; - const text = - `${follow.label} ${formatDistance(Math.abs(follow.dist), metric)}`.trim(); - ctx.fillStyle = FOLLOW_COLOR; - ctx.fillText( - text, - centre + Math.cos(bearing) * labelDistance, - centre + Math.sin(bearing) * labelDistance - ); -}; - -const drawWarningOutline = ( - ctx: CanvasRenderingContext2D, - length: number, - width: number, - color: string -) => { - ctx.save(); - ctx.translate(pose.x, pose.y); - ctx.rotate(pose.angle); - const pad = Math.max(1.5, width * 0.12); - ctx.beginPath(); - ctx.roundRect( - -length / 2 - pad, - -width / 2 - pad, - length + pad * 2, - width + pad * 2, - pad * 2 - ); - ctx.lineWidth = 2; - ctx.strokeStyle = color; - ctx.stroke(); - ctx.restore(); -}; - -interface RimWarning { - x: number; - y: number; - carLengthPx: number; - color: string; - alpha: number; - arcStyle: RadarArcStyle; - minDeg: number; - maxDeg: number; - /** 0-1, for how many segments light. */ - urgency: number; -} - -/** - * A strip along one side of a car (side -1 its left, +1 its right) filled - * from the rear to `share` of its length, with a notch at `threshold`. - */ -const drawOverlapStrip = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - angle: number, - length: number, - width: number, - side: number, - share: number, - threshold: number, - color: string -) => { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - const thickness = Math.max(2, width * 0.3); - const across = side * (width / 2 - thickness / 2); - const rear = -length / 2; - ctx.lineCap = 'butt'; - ctx.lineWidth = thickness; - ctx.beginPath(); - ctx.moveTo(rear, across); - ctx.lineTo(-rear, across); - ctx.strokeStyle = 'rgba(15, 23, 42, 0.25)'; - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(rear, across); - ctx.lineTo(rear + length * share, across); - ctx.strokeStyle = color; - ctx.stroke(); - const notch = rear + length * threshold; - ctx.beginPath(); - ctx.moveTo(notch, across - thickness / 2 - 1); - ctx.lineTo(notch, across + thickness / 2 + 1); - ctx.lineWidth = Math.max(1, thickness * 0.4); - ctx.strokeStyle = '#0f172a'; - ctx.stroke(); - ctx.restore(); -}; - -/** Text with a dark rim so it reads over cars and the road. */ -const drawHintText = ( - ctx: CanvasRenderingContext2D, - text: string, - x: number, - y: number, - color: string, - fontSize: number, - align: CanvasTextAlign -) => { - // Saved, so the car-number font set up for the loop survives. - ctx.save(); - ctx.font = `bold ${fontSize}px Lato, sans-serif`; - ctx.textAlign = align; - ctx.textBaseline = 'middle'; - ctx.lineWidth = 3; - ctx.lineJoin = 'round'; - ctx.strokeStyle = 'rgba(15, 23, 42, 0.85)'; - ctx.strokeText(text, x, y); - ctx.fillStyle = color; - ctx.fillText(text, x, y); - ctx.restore(); -}; - -/** The overlap in per cent, behind the car on the strip's side. */ -const drawOverlapPercent = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - angle: number, - length: number, - width: number, - side: number, - share: number, - color: string, - fontSize: number -) => { - // Beside the car is where the rival is, so the space behind is clearer. - const along = -length / 2 - fontSize * 0.9; - // Cars near ours point up too, so their local left is the screen's left. - const across = side * 2; - const cos = Math.cos(angle); - const sin = Math.sin(angle); - drawHintText( - ctx, - `${Math.round(share * 100)}%`, - x + cos * along - sin * across, - y + sin * along + cos * across, - color, - fontSize, - side < 0 ? 'right' : 'left' - ); -}; - -const ghostPose: ScreenPose = { x: 0, y: 0, angle: 0 }; - -/** Dashed outline where a diving car is headed, and an arrow to it. */ -const drawDiveGhost = ( - ctx: CanvasRenderingContext2D, - from: ScreenPose, - to: ScreenPose, length: number, width: number, - color: string -) => { - ctx.save(); - ctx.translate(to.x, to.y); - ctx.rotate(to.angle); - ctx.beginPath(); - ctx.roundRect( - -length / 2, - -width / 2, - length, - width, - Math.min(width, length) * 0.25 - ); - ctx.fillStyle = color; - ctx.globalAlpha *= 0.15; - ctx.fill(); - ctx.globalAlpha /= 0.15; - ctx.setLineDash([Math.max(2, width * 0.35), Math.max(2, width * 0.25)]); - ctx.lineWidth = 1.5; - ctx.strokeStyle = color; - ctx.stroke(); - ctx.restore(); - - const dx = to.x - from.x; - const dy = to.y - from.y; - const reach = Math.hypot(dx, dy); - const trim = length / 2; - if (reach <= trim * 2) return; - const ux = dx / reach; - const uy = dy / reach; - const startX = from.x + ux * trim; - const startY = from.y + uy * trim; - const tipX = to.x - ux * trim; - const tipY = to.y - uy * trim; - const head = Math.max(4, width * 0.6); - ctx.beginPath(); - ctx.moveTo(startX, startY); - ctx.lineTo(tipX - ux * head * 0.6, tipY - uy * head * 0.6); - ctx.lineWidth = 2; - ctx.strokeStyle = color; - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(tipX, tipY); - ctx.lineTo( - tipX - ux * head - uy * head * 0.6, - tipY - uy * head + ux * head * 0.6 - ); - ctx.lineTo( - tipX - ux * head + uy * head * 0.6, - tipY - uy * head - ux * head * 0.6 - ); - ctx.closePath(); - ctx.fillStyle = color; - ctx.fill(); -}; - -/** Seconds ahead a diving car's ghost is drawn. */ -const GHOST_AHEAD_S = 0.8; - -/** Our own strips, one per side, drawn once our car is. */ -const ourStrips = { left: 0, right: 0 }; - -/** Warning arcs are drawn after the fade, so they are kept here till then. */ -const rimWarnings: RimWarning[] = []; -let rimWarningCount = 0; - -const queueRimWarning = ( - carLengthPx: number, - color: string, - alpha: number, - arcStyle: RadarArcStyle, - minDeg: number, - maxDeg: number, - urgency: number -) => { - const warning = rimWarnings[rimWarningCount] ?? ({} as RimWarning); - warning.x = pose.x; - warning.y = pose.y; - warning.carLengthPx = carLengthPx; - warning.color = color; - warning.alpha = alpha; - warning.arcStyle = arcStyle; - warning.minDeg = minDeg; - warning.maxDeg = maxDeg; - warning.urgency = urgency; - rimWarnings[rimWarningCount] = warning; - rimWarningCount += 1; -}; - -interface DebugLabel { - x: number; - y: number; - text: string; -} - -/** Written last, so the fade does not swallow them. */ -const debugLabels: DebugLabel[] = []; -let debugLabelCount = 0; - -const queueDebugLabel = (text: string, carWidthPx: number) => { - const label = debugLabels[debugLabelCount] ?? ({} as DebugLabel); - label.x = pose.x + carWidthPx / 2 + 3; - label.y = pose.y; - label.text = text; - debugLabels[debugLabelCount] = label; - debugLabelCount += 1; -}; - -const drawDebug = ( - ctx: CanvasRenderingContext2D, - size: number, style: RadarStyle, - frameMs: number | undefined + hintFont: number ) => { - ctx.font = `${MIN_TEXT_PX + 2}px monospace`; - ctx.fillStyle = DEBUG_COLOR; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - if (style.debugLabels) { - for (let index = 0; index < debugLabelCount; index += 1) { - const label = debugLabels[index]; - ctx.fillText(label.text, label.x, label.y); + for (const key of ['left', 'right'] as const) { + const share = ourStrips[key]; + if (share <= 0) continue; + // Our car points up, so its left is the local -y side. + const side = key === 'left' ? -1 : 1; + const color = + share >= style.overlapThreshold ? style.alongsideColor : style.closeColor; + drawOverlapStrip( + ctx, + centre, + centre, + -Math.PI / 2, + length, + width, + side, + share, + style.overlapThreshold, + color + ); + if (style.overlapShowPercent) { + drawOverlapPercent( + ctx, + centre, + centre, + -Math.PI / 2, + length, + width, + side, + share, + color, + hintFont + ); } } - if (style.showFrameTime && frameMs !== undefined) { - ctx.textAlign = 'center'; - ctx.fillText(`${frameMs.toFixed(2)} ms`, size / 2, size - 14); - } -}; - -/** A mark on the rim in the direction of a rival, as wide as the car looks. */ -const drawWarningArc = ( - ctx: CanvasRenderingContext2D, - centre: number, - radius: number, - warning: RimWarning, - style: RadarStyle -) => { - const dx = warning.x - centre; - const dy = warning.y - centre; - const distance = Math.max(Math.hypot(dx, dy), 1); - const bearing = Math.atan2(dy, dx); - const minArc = (warning.minDeg * Math.PI) / 180; - const maxArc = (Math.max(warning.maxDeg, warning.minDeg) * Math.PI) / 180; - const half = Math.min( - maxArc, - Math.max(minArc, Math.atan2(warning.carLengthPx / 2, distance)) - ); - paintRimArc( - ctx, - centre, - radius, - bearing, - half, - warning.color, - warning.arcStyle, - warning.urgency, - arcThicknessPx(radius, style.arcThickness) - ); -}; - -const HAZARD_YELLOW = '#facc15'; -const HAZARD_LABELS: Record = { - crash: 'CRASH', - slow: 'SLOW', - off: 'OFF', - rejoin: 'REJOIN', -}; -/** Flashes per second of a near hazard. */ -const HAZARD_FLASH_HZ = 2; -/** A crashed car is drawn turned across the road, as wrecks usually are. */ -const CRASH_TILT = 1.1; -/** Furthest from straight ahead a rim marker may sit, so it never points back. */ -const HAZARD_MAX_BEARING = (100 * Math.PI) / 180; - -const hazardColor = (kind: RadarHazardKind, style: RadarStyle): string => - kind === 'crash' - ? style.alongsideColor - : kind === 'slow' - ? style.closeColor - : HAZARD_YELLOW; - -/** Whether this hazard is wanted at all, with the settings given. */ -export const hazardWanted = ( - hazard: { dist: number; kind: RadarHazardKind }, - style: Pick< - RadarStyle, - 'showHazards' | 'hazardRange' | 'hazardCrash' | 'hazardSlow' | 'hazardOff' - > -): boolean => { - if ( - !style.showHazards || - hazard.dist < 0 || - hazard.dist > style.hazardRange - ) { - return false; - } - if (hazard.kind === 'crash') return style.hazardCrash; - if (hazard.kind === 'slow') return style.hazardSlow; - return style.hazardOff; }; -/** - * Off the track is steady: the car is out of the way. Everything else - * flashes once it is near, and a car coming back on flashes at any distance. - */ -const hazardFlashes = (hazard: RadarDrawHazard, style: RadarStyle) => - hazard.kind === 'rejoin' || - (hazard.kind !== 'off' && hazard.dist < style.hazardBlinkDistance); - -/** Warning triangle with an exclamation mark, centred on (x, y). */ -const drawHazardTriangle = ( +/** Slides the background in underneath and fades the disc to the rim. */ +const drawBackdrop = ( ctx: CanvasRenderingContext2D, - x: number, - y: number, size: number, - color: string -) => { - const height = size * 0.9; - ctx.beginPath(); - ctx.moveTo(x, y - height * 0.6); - ctx.lineTo(x + size / 2, y + height * 0.4); - ctx.lineTo(x - size / 2, y + height * 0.4); - ctx.closePath(); - ctx.lineJoin = 'round'; - ctx.lineWidth = Math.max(1, size * 0.08); - ctx.strokeStyle = '#0f172a'; - ctx.fillStyle = color; - ctx.fill(); - ctx.stroke(); - ctx.font = `bold ${Math.round(size * 0.6)}px Lato, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = '#0f172a'; - ctx.fillText('!', x, y + height * 0.1); -}; - -/** - * A hazard beyond the disc: an arc and a triangle on the rim in its - * direction along the track map, with what it is and how far. Both grow as - * it nears; `others` more hazards beyond the rim are counted beside it. - */ -const drawHazardMarker = ( - ctx: CanvasRenderingContext2D, centre: number, radius: number, - hazard: RadarDrawHazard, - others: number, - style: RadarStyle, - flash: number + style: RadarStyle ) => { - projector.project(hazard.dist, 0, pose); - let bearing = Math.atan2(pose.y - centre, pose.x - centre); - // Measured from straight up, then kept in front of us. - let fromUp = bearing + Math.PI / 2; - if (fromUp > Math.PI) fromUp -= Math.PI * 2; - fromUp = Math.min(Math.max(fromUp, -HAZARD_MAX_BEARING), HAZARD_MAX_BEARING); - bearing = fromUp - Math.PI / 2; - - const span = Math.max(style.hazardRange - style.range, 1); - const near = Math.min( - Math.max((style.hazardRange - hazard.dist) / span, 0), - 1 - ); - const color = hazardColor(hazard.kind, style); - ctx.globalAlpha = hazardFlashes(hazard, style) ? flash : 1; - - const minArc = style.hazardArcMinDeg; - const maxArc = Math.max(style.hazardArcMaxDeg, minArc); - const half = ((minArc + (maxArc - minArc) * near) * Math.PI) / 180; - // Without the arc the triangle moves out to the rim in its place. - const thickness = style.hazardArcs - ? arcThicknessPx(radius, style.arcThickness) - : 2; - if (style.hazardArcs) { - paintRimArc( - ctx, + // Slide the background in underneath, then fade the whole disc towards + // the rim, so cars and the disc itself ease out at the edge of the range. + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = `rgba(15, 23, 42, ${style.backgroundOpacity / 100})`; + ctx.fillRect(0, 0, size, size); + const fadeShare = Math.min(Math.max(style.edgeFade, 0), 100) / 100; + if (fadeShare > 0) { + const fade = ctx.createRadialGradient( centre, - radius, - bearing, - half, - color, - style.hazardArcStyle, - near, - thickness - ); - } - - const size = radius * (0.12 + 0.06 * near); - const cos = Math.cos(bearing); - const sin = Math.sin(bearing); - const triangleAt = radius - thickness - size * 0.75; - drawHazardTriangle( - ctx, - centre + cos * triangleAt, - centre + sin * triangleAt, - size, - color - ); - ctx.globalAlpha = 1; - - const fontSize = Math.max(MIN_TEXT_PX, Math.round(radius / 11)); - if (others > 0) { - drawHintText( - ctx, - `+${others}`, - centre + cos * triangleAt + size * 0.6, - centre + sin * triangleAt - size * 0.4, - '#e2e8f0', - Math.max(MIN_TEXT_PX, fontSize - 2), - 'left' - ); - } - const labelAt = triangleAt - size * 0.6 - fontSize; - const labelX = centre + cos * labelAt; - const labelY = centre + sin * labelAt; - drawHintText( - ctx, - style.hazardShowLabel - ? `${HAZARD_LABELS[hazard.kind]} ${formatDistance(hazard.dist, style.metric)}` - : formatDistance(hazard.dist, style.metric), - labelX, - labelY, - color, - fontSize, - 'center' - ); - if (style.hazardShowSpeed) { - drawHintText( - ctx, - `${speedIn(hazard.speed, style.metric)} ${speedUnit(style.metric)}`, - labelX, - labelY + fontSize * 1.15, - '#e2e8f0', - Math.max(MIN_TEXT_PX, fontSize - 2), - 'center' - ); - } -}; - -/** Outline, triangle and speed on a hazard car drawn on the disc. */ -const drawHazardOnCar = ( - ctx: CanvasRenderingContext2D, - hazard: RadarDrawHazard, - carLength: number, - carWidth: number, - style: RadarStyle, - flash: number, - fontSize: number, - centre: number -) => { - const color = hazardColor(hazard.kind, style); - ctx.globalAlpha = hazardFlashes(hazard, style) ? flash : 1; - drawWarningOutline(ctx, carLength, carWidth, color); - // On the car itself: beside it, it would leave the disc near the rim. - drawHazardTriangle(ctx, pose.x, pose.y, Math.max(12, fontSize * 1.8), color); - ctx.globalAlpha = 1; - if (style.hazardShowSpeed) { - // On the side away from our line, where the other cars are not. - const outward = pose.x >= centre ? 1 : -1; - const reach = Math.max(carLength, carWidth) / 2 + 4; - drawHintText( - ctx, - `${speedIn(hazard.speed, style.metric)} ${speedUnit(style.metric)}`, - pose.x + outward * reach, - pose.y, - color, - fontSize, - outward > 0 ? 'left' : 'right' + centre, + radius * (1 - fadeShare), + centre, + centre, + radius ); + fade.addColorStop(0, 'rgba(0, 0, 0, 1)'); + fade.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.globalCompositeOperation = 'destination-in'; + ctx.fillStyle = fade; + ctx.fillRect(0, 0, size, size); } -}; - -const hazardOf = ( - hazards: readonly RadarDrawHazard[] | undefined, - carIdx: number -): RadarDrawHazard | undefined => { - if (!hazards) return undefined; - for (const hazard of hazards) { - if (hazard.carIdx === carIdx) return hazard; - } - return undefined; + ctx.globalCompositeOperation = 'source-over'; }; export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { @@ -956,8 +241,8 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { ctx.beginPath(); ctx.arc(centre, centre, radius, 0, Math.PI * 2); ctx.clip(); - rimWarningCount = 0; - debugLabelCount = 0; + resetRimWarnings(); + resetDebugLabels(); if (style.showTrackMap) drawRoad(ctx, style, pixelsPerMetre); if (style.showCrosshair) { @@ -1253,67 +538,13 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { width, style.playerColor ); - for (const key of ['left', 'right'] as const) { - const share = ourStrips[key]; - if (share <= 0) continue; - // Our car points up, so its left is the local -y side. - const side = key === 'left' ? -1 : 1; - const color = - share >= style.overlapThreshold ? style.alongsideColor : style.closeColor; - drawOverlapStrip( - ctx, - centre, - centre, - -Math.PI / 2, - length, - width, - side, - share, - style.overlapThreshold, - color - ); - if (style.overlapShowPercent) { - drawOverlapPercent( - ctx, - centre, - centre, - -Math.PI / 2, - length, - width, - side, - share, - color, - hintFont - ); - } - } + drawOurStrips(ctx, centre, length, width, style, hintFont); - // Slide the background in underneath, then fade the whole disc towards - // the rim, so cars and the disc itself ease out at the edge of the range. - ctx.globalCompositeOperation = 'destination-over'; - ctx.fillStyle = `rgba(15, 23, 42, ${style.backgroundOpacity / 100})`; - ctx.fillRect(0, 0, size, size); - const fadeShare = Math.min(Math.max(style.edgeFade, 0), 100) / 100; - if (fadeShare > 0) { - const fade = ctx.createRadialGradient( - centre, - centre, - radius * (1 - fadeShare), - centre, - centre, - radius - ); - fade.addColorStop(0, 'rgba(0, 0, 0, 1)'); - fade.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.globalCompositeOperation = 'destination-in'; - ctx.fillStyle = fade; - ctx.fillRect(0, 0, size, size); - } - ctx.globalCompositeOperation = 'source-over'; + drawBackdrop(ctx, size, centre, radius, style); // Rim markers stay at full strength above the fade. - for (let index = 0; index < rimWarningCount; index += 1) { - const warning = rimWarnings[index]; + for (let index = 0; index < rimWarningTotal(); index += 1) { + const warning = rimWarningAt(index); ctx.globalAlpha = warning.alpha; drawWarningArc(ctx, centre, radius, warning, style); } diff --git a/src/frontend/components/Radar/radarDrawDebug.ts b/src/frontend/components/Radar/radarDrawDebug.ts new file mode 100644 index 000000000..df0388435 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawDebug.ts @@ -0,0 +1,49 @@ +import type { RadarStyle } from './radarDrawTypes'; +import { MIN_TEXT_PX, pose } from './radarDrawShared'; + +export const DEBUG_COLOR = '#fbbf24'; + +export interface DebugLabel { + x: number; + y: number; + text: string; +} + +/** Written last, so the fade does not swallow them. */ +const debugLabels: DebugLabel[] = []; +let debugLabelCount = 0; + +export const resetDebugLabels = () => { + debugLabelCount = 0; +}; + +export const queueDebugLabel = (text: string, carWidthPx: number) => { + const label = debugLabels[debugLabelCount] ?? ({} as DebugLabel); + label.x = pose.x + carWidthPx / 2 + 3; + label.y = pose.y; + label.text = text; + debugLabels[debugLabelCount] = label; + debugLabelCount += 1; +}; + +export const drawDebug = ( + ctx: CanvasRenderingContext2D, + size: number, + style: RadarStyle, + frameMs: number | undefined +) => { + ctx.font = `${MIN_TEXT_PX + 2}px monospace`; + ctx.fillStyle = DEBUG_COLOR; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + if (style.debugLabels) { + for (let index = 0; index < debugLabelCount; index += 1) { + const label = debugLabels[index]; + ctx.fillText(label.text, label.x, label.y); + } + } + if (style.showFrameTime && frameMs !== undefined) { + ctx.textAlign = 'center'; + ctx.fillText(`${frameMs.toFixed(2)} ms`, size / 2, size - 14); + } +}; diff --git a/src/frontend/components/Radar/radarDrawFollow.ts b/src/frontend/components/Radar/radarDrawFollow.ts new file mode 100644 index 000000000..86ce8e183 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawFollow.ts @@ -0,0 +1,121 @@ +import type { RadarDrawFollow } from './radarDrawTypes'; +import { MIN_TEXT_PX, pose } from './radarDrawShared'; +import { formatDistance } from './radarStyle'; + +export const FOLLOW_COLOR = '#22c55e'; +export const PACE_CAR_FILL = '#e2e8f0'; + +export const FOLLOW_DASH = [6, 5]; + +/** + * Ends of the line from our car to the one we follow, each pulled in by half + * a car so it runs nose to tail, or null when the cars nearly touch. + */ +export const followLineEnds = ( + from: { x: number; y: number }, + to: { x: number; y: number }, + fromHalfLength: number, + toHalfLength: number +) => { + const dx = to.x - from.x; + const dy = to.y - from.y; + const distance = Math.hypot(dx, dy); + if (distance <= fromHalfLength + toHalfLength + 2) return null; + const ux = dx / distance; + const uy = dy / distance; + return { + x1: from.x + ux * fromHalfLength, + y1: from.y + uy * fromHalfLength, + x2: to.x - ux * toHalfLength, + y2: to.y - uy * toHalfLength, + }; +}; + +/** Dashed line tying our car to the one we have to hold station behind. */ +export const drawFollowLine = ( + ctx: CanvasRenderingContext2D, + centre: number, + target: { x: number; y: number }, + playerLength: number, + targetLength: number +) => { + const ends = followLineEnds( + { x: centre, y: centre }, + target, + playerLength / 2, + targetLength / 2 + ); + if (!ends) return; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(ends.x1, ends.y1); + ctx.lineTo(ends.x2, ends.y2); + ctx.setLineDash(FOLLOW_DASH); + ctx.lineWidth = 2; + ctx.strokeStyle = FOLLOW_COLOR; + ctx.globalAlpha = 0.85; + ctx.stroke(); + ctx.restore(); +}; + +export const drawFollowOutline = ( + ctx: CanvasRenderingContext2D, + length: number, + width: number +) => { + ctx.save(); + ctx.translate(pose.x, pose.y); + ctx.rotate(pose.angle); + const pad = Math.max(2, width * 0.25); + ctx.beginPath(); + ctx.roundRect( + -length / 2 - pad, + -width / 2 - pad, + length + pad * 2, + width + pad * 2, + pad * 2 + ); + ctx.lineWidth = 2; + ctx.strokeStyle = FOLLOW_COLOR; + ctx.stroke(); + ctx.restore(); +}; + +/** Chevron on the rim pointing at a follow target beyond the range. */ +export const drawFollowPointer = ( + ctx: CanvasRenderingContext2D, + centre: number, + radius: number, + follow: RadarDrawFollow, + metric: boolean +) => { + const bearing = Math.atan2(pose.y - centre, pose.x - centre); + const tipX = centre + Math.cos(bearing) * (radius - 3); + const tipY = centre + Math.sin(bearing) * (radius - 3); + const size = Math.max(7, radius / 12); + ctx.save(); + ctx.translate(tipX, tipY); + ctx.rotate(bearing); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-size, -size * 0.7); + ctx.lineTo(-size, size * 0.7); + ctx.closePath(); + ctx.fillStyle = FOLLOW_COLOR; + ctx.fill(); + ctx.restore(); + + const fontSize = Math.max(MIN_TEXT_PX, Math.round(radius / 11)); + ctx.font = `bold ${fontSize}px Lato, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const labelDistance = radius - size - fontSize * 1.2; + const text = + `${follow.label} ${formatDistance(Math.abs(follow.dist), metric)}`.trim(); + ctx.fillStyle = FOLLOW_COLOR; + ctx.fillText( + text, + centre + Math.cos(bearing) * labelDistance, + centre + Math.sin(bearing) * labelDistance + ); +}; diff --git a/src/frontend/components/Radar/radarDrawHazards.ts b/src/frontend/components/Radar/radarDrawHazards.ts new file mode 100644 index 000000000..5c34936c3 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawHazards.ts @@ -0,0 +1,232 @@ +import type { RadarHazardKind } from '@irdashies/types'; +import type { RadarDrawHazard, RadarStyle } from './radarDrawTypes'; +import { MIN_TEXT_PX, pose, projector } from './radarDrawShared'; +import { arcThicknessPx, paintRimArc } from './radarArcs'; +import { drawHintText, drawWarningOutline } from './radarDrawWarnings'; +import { formatDistance, speedIn, speedUnit } from './radarStyle'; + +export const HAZARD_YELLOW = '#facc15'; +export const HAZARD_LABELS: Record = { + crash: 'CRASH', + slow: 'SLOW', + off: 'OFF', + rejoin: 'REJOIN', +}; +/** Flashes per second of a near hazard. */ +export const HAZARD_FLASH_HZ = 2; +/** A crashed car is drawn turned across the road, as wrecks usually are. */ +export const CRASH_TILT = 1.1; +/** Furthest from straight ahead a rim marker may sit, so it never points back. */ +export const HAZARD_MAX_BEARING = (100 * Math.PI) / 180; + +export const hazardColor = ( + kind: RadarHazardKind, + style: RadarStyle +): string => + kind === 'crash' + ? style.alongsideColor + : kind === 'slow' + ? style.closeColor + : HAZARD_YELLOW; + +/** Whether this hazard is wanted at all, with the settings given. */ +export const hazardWanted = ( + hazard: { dist: number; kind: RadarHazardKind }, + style: Pick< + RadarStyle, + 'showHazards' | 'hazardRange' | 'hazardCrash' | 'hazardSlow' | 'hazardOff' + > +): boolean => { + if ( + !style.showHazards || + hazard.dist < 0 || + hazard.dist > style.hazardRange + ) { + return false; + } + if (hazard.kind === 'crash') return style.hazardCrash; + if (hazard.kind === 'slow') return style.hazardSlow; + return style.hazardOff; +}; + +/** + * Off the track is steady: the car is out of the way. Everything else + * flashes once it is near, and a car coming back on flashes at any distance. + */ +export const hazardFlashes = (hazard: RadarDrawHazard, style: RadarStyle) => + hazard.kind === 'rejoin' || + (hazard.kind !== 'off' && hazard.dist < style.hazardBlinkDistance); + +/** Warning triangle with an exclamation mark, centred on (x, y). */ +export const drawHazardTriangle = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + color: string +) => { + const height = size * 0.9; + ctx.beginPath(); + ctx.moveTo(x, y - height * 0.6); + ctx.lineTo(x + size / 2, y + height * 0.4); + ctx.lineTo(x - size / 2, y + height * 0.4); + ctx.closePath(); + ctx.lineJoin = 'round'; + ctx.lineWidth = Math.max(1, size * 0.08); + ctx.strokeStyle = '#0f172a'; + ctx.fillStyle = color; + ctx.fill(); + ctx.stroke(); + ctx.font = `bold ${Math.round(size * 0.6)}px Lato, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#0f172a'; + ctx.fillText('!', x, y + height * 0.1); +}; + +/** + * A hazard beyond the disc: an arc and a triangle on the rim in its + * direction along the track map, with what it is and how far. Both grow as + * it nears; `others` more hazards beyond the rim are counted beside it. + */ +export const drawHazardMarker = ( + ctx: CanvasRenderingContext2D, + centre: number, + radius: number, + hazard: RadarDrawHazard, + others: number, + style: RadarStyle, + flash: number +) => { + projector.project(hazard.dist, 0, pose); + let bearing = Math.atan2(pose.y - centre, pose.x - centre); + // Measured from straight up, then kept in front of us. + let fromUp = bearing + Math.PI / 2; + if (fromUp > Math.PI) fromUp -= Math.PI * 2; + fromUp = Math.min(Math.max(fromUp, -HAZARD_MAX_BEARING), HAZARD_MAX_BEARING); + bearing = fromUp - Math.PI / 2; + + const span = Math.max(style.hazardRange - style.range, 1); + const near = Math.min( + Math.max((style.hazardRange - hazard.dist) / span, 0), + 1 + ); + const color = hazardColor(hazard.kind, style); + ctx.globalAlpha = hazardFlashes(hazard, style) ? flash : 1; + + const minArc = style.hazardArcMinDeg; + const maxArc = Math.max(style.hazardArcMaxDeg, minArc); + const half = ((minArc + (maxArc - minArc) * near) * Math.PI) / 180; + // Without the arc the triangle moves out to the rim in its place. + const thickness = style.hazardArcs + ? arcThicknessPx(radius, style.arcThickness) + : 2; + if (style.hazardArcs) { + paintRimArc( + ctx, + centre, + radius, + bearing, + half, + color, + style.hazardArcStyle, + near, + thickness + ); + } + + const size = radius * (0.12 + 0.06 * near); + const cos = Math.cos(bearing); + const sin = Math.sin(bearing); + const triangleAt = radius - thickness - size * 0.75; + drawHazardTriangle( + ctx, + centre + cos * triangleAt, + centre + sin * triangleAt, + size, + color + ); + ctx.globalAlpha = 1; + + const fontSize = Math.max(MIN_TEXT_PX, Math.round(radius / 11)); + if (others > 0) { + drawHintText( + ctx, + `+${others}`, + centre + cos * triangleAt + size * 0.6, + centre + sin * triangleAt - size * 0.4, + '#e2e8f0', + Math.max(MIN_TEXT_PX, fontSize - 2), + 'left' + ); + } + const labelAt = triangleAt - size * 0.6 - fontSize; + const labelX = centre + cos * labelAt; + const labelY = centre + sin * labelAt; + drawHintText( + ctx, + style.hazardShowLabel + ? `${HAZARD_LABELS[hazard.kind]} ${formatDistance(hazard.dist, style.metric)}` + : formatDistance(hazard.dist, style.metric), + labelX, + labelY, + color, + fontSize, + 'center' + ); + if (style.hazardShowSpeed) { + drawHintText( + ctx, + `${speedIn(hazard.speed, style.metric)} ${speedUnit(style.metric)}`, + labelX, + labelY + fontSize * 1.15, + '#e2e8f0', + Math.max(MIN_TEXT_PX, fontSize - 2), + 'center' + ); + } +}; + +/** Outline, triangle and speed on a hazard car drawn on the disc. */ +export const drawHazardOnCar = ( + ctx: CanvasRenderingContext2D, + hazard: RadarDrawHazard, + carLength: number, + carWidth: number, + style: RadarStyle, + flash: number, + fontSize: number, + centre: number +) => { + const color = hazardColor(hazard.kind, style); + ctx.globalAlpha = hazardFlashes(hazard, style) ? flash : 1; + drawWarningOutline(ctx, carLength, carWidth, color); + // On the car itself: beside it, it would leave the disc near the rim. + drawHazardTriangle(ctx, pose.x, pose.y, Math.max(12, fontSize * 1.8), color); + ctx.globalAlpha = 1; + if (style.hazardShowSpeed) { + // On the side away from our line, where the other cars are not. + const outward = pose.x >= centre ? 1 : -1; + const reach = Math.max(carLength, carWidth) / 2 + 4; + drawHintText( + ctx, + `${speedIn(hazard.speed, style.metric)} ${speedUnit(style.metric)}`, + pose.x + outward * reach, + pose.y, + color, + fontSize, + outward > 0 ? 'left' : 'right' + ); + } +}; + +export const hazardOf = ( + hazards: readonly RadarDrawHazard[] | undefined, + carIdx: number +): RadarDrawHazard | undefined => { + if (!hazards) return undefined; + for (const hazard of hazards) { + if (hazard.carIdx === carIdx) return hazard; + } + return undefined; +}; diff --git a/src/frontend/components/Radar/radarDrawShared.ts b/src/frontend/components/Radar/radarDrawShared.ts new file mode 100644 index 000000000..951d7d6a9 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawShared.ts @@ -0,0 +1,31 @@ +import { RadarProjector, type ScreenPose } from './radarProjection'; + +/** Smallest ring label and pointer text, whatever the label setting. */ +export const MIN_TEXT_PX = 8; + +export const projector = new RadarProjector(); +export const pose: ScreenPose = { x: 0, y: 0, angle: 0 }; + +export const drawCarBody = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + length: number, + width: number, + fill: string +) => { + ctx.save(); + ctx.translate(x, y); + // Body length runs along the direction of travel. + ctx.rotate(angle); + const radius = Math.min(width, length) * 0.25; + ctx.beginPath(); + ctx.roundRect(-length / 2, -width / 2, length, width, radius); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(15, 23, 42, 0.9)'; + ctx.stroke(); + ctx.restore(); +}; diff --git a/src/frontend/components/Radar/radarDrawTypes.ts b/src/frontend/components/Radar/radarDrawTypes.ts new file mode 100644 index 000000000..80acd936c --- /dev/null +++ b/src/frontend/components/Radar/radarDrawTypes.ts @@ -0,0 +1,152 @@ +import type { TrackGeometry } from '@irdashies/domain/track'; +import type { DiveHint } from './radarHints'; +import type { RadarArcStyle, RadarHazardKind } from '@irdashies/types'; + +export interface RadarDrawCar { + carIdx: number; + /** Metres along the track from the focus car, positive ahead. */ + dist: number; + /** Metres from our line, positive to the left. */ + lateral: number; + /** Rate of change of `dist` in m/s. */ + relativeSpeed: number; + /** Lane from the spotter, for the debug labels. */ + lane: number; + offTrack: boolean; + /** Body size in metres. */ + length: number; + width: number; + fill: string; + textColor: string; + label: string; +} + +export type WarningLevel = 'none' | 'close' | 'alongside'; + +/** + * How worried to be about a rival: alongside once the bodies overlap along + * the track, close inside `cautionDistance` metres of bumper-to-bumper gap. + */ +export const warningLevel = ( + dist: number, + carLength: number, + playerLength: number, + cautionDistance: number +): WarningLevel => { + const gap = Math.abs(dist) - (carLength + playerLength) / 2; + if (gap <= 0) return 'alongside'; + if (gap < cautionDistance) return 'close'; + return 'none'; +}; + +export interface RadarStyle { + /** Labels in metres and km/h; imperial otherwise. */ + metric: boolean; + range: number; + /** Our car's body size in metres; also used for the pace car. */ + carLength: number; + carWidth: number; + showWarnings: boolean; + warningArcs: boolean; + warningArcStyle: RadarArcStyle; + /** Metres of bumper gap below which a rival counts as close. */ + cautionDistance: number; + /** Bumper gap written next to a close rival. */ + showGapLabel: boolean; + showCarNumbers: boolean; + showTrackMap: boolean; + trackWidth: number; + /** 0-100 */ + mapOpacity: number; + showRings: boolean; + ringSpacing: number; + playerColor: string; + /** 0-100 */ + backgroundOpacity: number; + /** Share of the radius, 0-100, over which the picture fades out at the rim. */ + edgeFade: number; + showCrosshair: boolean; + /** Run the dashes of the line ahead/behind past at our speed. */ + axisMotion: boolean; + /** Metres of each moving dash; the gaps are twice as long. */ + axisDashLength: number; + /** Speed of the moving dashes, % of our own. */ + axisSpeed: number; + closeColor: string; + alongsideColor: string; + /** Pulses per second on cars alongside; 0 keeps them steady. */ + pulseHz: number; + arcMinDeg: number; + arcMaxDeg: number; + /** Rim arc thickness, % of the radius. */ + arcThickness: number; + showOverlap: boolean; + /** Share of a car's length from which the overlap strip turns red. */ + overlapThreshold: number; + overlapShowPercent: boolean; + diveGhost: boolean; + diveShowClosing: boolean; + diveArcs: boolean; + diveArcStyle: RadarArcStyle; + diveArcMinDeg: number; + diveArcMaxDeg: number; + showHazards: boolean; + /** Metres ahead a hazard is shown from. */ + hazardRange: number; + /** Metres under which a hazard flashes. */ + hazardBlinkDistance: number; + hazardCrash: boolean; + hazardSlow: boolean; + hazardOff: boolean; + hazardShowLabel: boolean; + hazardShowSpeed: boolean; + hazardArcs: boolean; + hazardArcStyle: RadarArcStyle; + hazardArcMinDeg: number; + hazardArcMaxDeg: number; + /** Car numbers are left out on cars drawn smaller than this, in px. */ + minLabelPx: number; + debugLabels: boolean; + showFrameTime: boolean; +} + +/** The car to line up behind while pacing. */ +export interface RadarDrawFollow { + /** Car index, or null for the pace car (which is not in `cars`). */ + carIdx: number | null; + dist: number; + label: string; +} + +/** A car in trouble ahead, already filtered by the hazard settings. */ +export interface RadarDrawHazard { + carIdx: number; + /** Metres along the track from the focus car, positive ahead. */ + dist: number; + kind: RadarHazardKind; + /** Its own speed in m/s. */ + speed: number; +} + +export interface RadarScene { + /** CSS pixels of the square the radar fills. */ + size: number; + geometry: TrackGeometry | null; + trackLength: number; + playerPct: number; + cars: readonly RadarDrawCar[]; + follow: RadarDrawFollow | null; + style: RadarStyle; + /** Seconds, for the pulse on cars alongside. */ + time: number; + /** Metres we have driven, for the moving dashes. */ + travelled: number; + /** Milliseconds the previous frame took, for the dev readout. */ + frameMs?: number; + /** Cars coming up fast behind, by car index. */ + dives?: ReadonlyMap; + /** Metres between lane centres, to place a diving car's ghost. */ + laneWidth?: number; + /** Cars in trouble ahead, nearest first. */ + hazards?: readonly RadarDrawHazard[]; +} diff --git a/src/frontend/components/Radar/radarDrawWarnings.ts b/src/frontend/components/Radar/radarDrawWarnings.ts new file mode 100644 index 000000000..86ee2b2fb --- /dev/null +++ b/src/frontend/components/Radar/radarDrawWarnings.ts @@ -0,0 +1,277 @@ +import type { ScreenPose } from './radarProjection'; +import type { RadarArcStyle } from '@irdashies/types'; +import { arcThicknessPx, paintRimArc } from './radarArcs'; +import type { RadarStyle } from './radarDrawTypes'; +import { pose } from './radarDrawShared'; + +export const drawWarningOutline = ( + ctx: CanvasRenderingContext2D, + length: number, + width: number, + color: string +) => { + ctx.save(); + ctx.translate(pose.x, pose.y); + ctx.rotate(pose.angle); + const pad = Math.max(1.5, width * 0.12); + ctx.beginPath(); + ctx.roundRect( + -length / 2 - pad, + -width / 2 - pad, + length + pad * 2, + width + pad * 2, + pad * 2 + ); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.stroke(); + ctx.restore(); +}; + +export interface RimWarning { + x: number; + y: number; + carLengthPx: number; + color: string; + alpha: number; + arcStyle: RadarArcStyle; + minDeg: number; + maxDeg: number; + /** 0-1, for how many segments light. */ + urgency: number; +} + +/** + * A strip along one side of a car (side -1 its left, +1 its right) filled + * from the rear to `share` of its length, with a notch at `threshold`. + */ +export const drawOverlapStrip = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + length: number, + width: number, + side: number, + share: number, + threshold: number, + color: string +) => { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + const thickness = Math.max(2, width * 0.3); + const across = side * (width / 2 - thickness / 2); + const rear = -length / 2; + ctx.lineCap = 'butt'; + ctx.lineWidth = thickness; + ctx.beginPath(); + ctx.moveTo(rear, across); + ctx.lineTo(-rear, across); + ctx.strokeStyle = 'rgba(15, 23, 42, 0.25)'; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(rear, across); + ctx.lineTo(rear + length * share, across); + ctx.strokeStyle = color; + ctx.stroke(); + const notch = rear + length * threshold; + ctx.beginPath(); + ctx.moveTo(notch, across - thickness / 2 - 1); + ctx.lineTo(notch, across + thickness / 2 + 1); + ctx.lineWidth = Math.max(1, thickness * 0.4); + ctx.strokeStyle = '#0f172a'; + ctx.stroke(); + ctx.restore(); +}; + +/** Text with a dark rim so it reads over cars and the road. */ +export const drawHintText = ( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + color: string, + fontSize: number, + align: CanvasTextAlign +) => { + // Saved, so the car-number font set up for the loop survives. + ctx.save(); + ctx.font = `bold ${fontSize}px Lato, sans-serif`; + ctx.textAlign = align; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 3; + ctx.lineJoin = 'round'; + ctx.strokeStyle = 'rgba(15, 23, 42, 0.85)'; + ctx.strokeText(text, x, y); + ctx.fillStyle = color; + ctx.fillText(text, x, y); + ctx.restore(); +}; + +/** The overlap in per cent, behind the car on the strip's side. */ +export const drawOverlapPercent = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + length: number, + width: number, + side: number, + share: number, + color: string, + fontSize: number +) => { + // Beside the car is where the rival is, so the space behind is clearer. + const along = -length / 2 - fontSize * 0.9; + // Cars near ours point up too, so their local left is the screen's left. + const across = side * 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + drawHintText( + ctx, + `${Math.round(share * 100)}%`, + x + cos * along - sin * across, + y + sin * along + cos * across, + color, + fontSize, + side < 0 ? 'right' : 'left' + ); +}; + +export const ghostPose: ScreenPose = { x: 0, y: 0, angle: 0 }; + +/** Dashed outline where a diving car is headed, and an arrow to it. */ +export const drawDiveGhost = ( + ctx: CanvasRenderingContext2D, + from: ScreenPose, + to: ScreenPose, + length: number, + width: number, + color: string +) => { + ctx.save(); + ctx.translate(to.x, to.y); + ctx.rotate(to.angle); + ctx.beginPath(); + ctx.roundRect( + -length / 2, + -width / 2, + length, + width, + Math.min(width, length) * 0.25 + ); + ctx.fillStyle = color; + ctx.globalAlpha *= 0.15; + ctx.fill(); + ctx.globalAlpha /= 0.15; + ctx.setLineDash([Math.max(2, width * 0.35), Math.max(2, width * 0.25)]); + ctx.lineWidth = 1.5; + ctx.strokeStyle = color; + ctx.stroke(); + ctx.restore(); + + const dx = to.x - from.x; + const dy = to.y - from.y; + const reach = Math.hypot(dx, dy); + const trim = length / 2; + if (reach <= trim * 2) return; + const ux = dx / reach; + const uy = dy / reach; + const startX = from.x + ux * trim; + const startY = from.y + uy * trim; + const tipX = to.x - ux * trim; + const tipY = to.y - uy * trim; + const head = Math.max(4, width * 0.6); + ctx.beginPath(); + ctx.moveTo(startX, startY); + ctx.lineTo(tipX - ux * head * 0.6, tipY - uy * head * 0.6); + ctx.lineWidth = 2; + ctx.strokeStyle = color; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo( + tipX - ux * head - uy * head * 0.6, + tipY - uy * head + ux * head * 0.6 + ); + ctx.lineTo( + tipX - ux * head + uy * head * 0.6, + tipY - uy * head - ux * head * 0.6 + ); + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); +}; + +/** Seconds ahead a diving car's ghost is drawn. */ +export const GHOST_AHEAD_S = 0.8; + +/** Our own strips, one per side, drawn once our car is. */ +export const ourStrips = { left: 0, right: 0 }; + +/** Warning arcs are drawn after the fade, so they are kept here till then. */ +const rimWarnings: RimWarning[] = []; +let rimWarningCount = 0; + +export const resetRimWarnings = () => { + rimWarningCount = 0; +}; + +/** The queued warning at `index`, below `rimWarningTotal()`. */ +export const rimWarningAt = (index: number): RimWarning => rimWarnings[index]; +export const rimWarningTotal = () => rimWarningCount; + +export const queueRimWarning = ( + carLengthPx: number, + color: string, + alpha: number, + arcStyle: RadarArcStyle, + minDeg: number, + maxDeg: number, + urgency: number +) => { + const warning = rimWarnings[rimWarningCount] ?? ({} as RimWarning); + warning.x = pose.x; + warning.y = pose.y; + warning.carLengthPx = carLengthPx; + warning.color = color; + warning.alpha = alpha; + warning.arcStyle = arcStyle; + warning.minDeg = minDeg; + warning.maxDeg = maxDeg; + warning.urgency = urgency; + rimWarnings[rimWarningCount] = warning; + rimWarningCount += 1; +}; + +/** A mark on the rim in the direction of a rival, as wide as the car looks. */ +export const drawWarningArc = ( + ctx: CanvasRenderingContext2D, + centre: number, + radius: number, + warning: RimWarning, + style: RadarStyle +) => { + const dx = warning.x - centre; + const dy = warning.y - centre; + const distance = Math.max(Math.hypot(dx, dy), 1); + const bearing = Math.atan2(dy, dx); + const minArc = (warning.minDeg * Math.PI) / 180; + const maxArc = (Math.max(warning.maxDeg, warning.minDeg) * Math.PI) / 180; + const half = Math.min( + maxArc, + Math.max(minArc, Math.atan2(warning.carLengthPx / 2, distance)) + ); + paintRimArc( + ctx, + centre, + radius, + bearing, + half, + warning.color, + warning.arcStyle, + warning.urgency, + arcThicknessPx(radius, style.arcThickness) + ); +}; diff --git a/src/frontend/components/Radar/radarHints.spec.ts b/src/frontend/components/Radar/radarHints.spec.ts index 4aa55fe31..ec8c49063 100644 --- a/src/frontend/components/Radar/radarHints.spec.ts +++ b/src/frontend/components/Radar/radarHints.spec.ts @@ -50,7 +50,7 @@ const ctx = (change: Partial = {}): DiveContext => ({ const car = (change: Partial = {}): DiveInput => ({ carIdx: 1, dist: -15, - closingSpeed: 7, + relativeSpeed: 7, lane: 0, length: 4.5, ...change, @@ -70,10 +70,10 @@ describe('DiveTracker', () => { const tracker = new DiveTracker(); const hints = tracker.update( [ - car({ carIdx: 1, closingSpeed: 3 }), + car({ carIdx: 1, relativeSpeed: 3 }), car({ carIdx: 2, dist: -60 }), car({ carIdx: 3, dist: -3 }), - car({ carIdx: 4, dist: 10, closingSpeed: -9 }), + car({ carIdx: 4, dist: 10, relativeSpeed: -9 }), ], ctx() ); diff --git a/src/frontend/components/Radar/radarHints.ts b/src/frontend/components/Radar/radarHints.ts index 1116ef191..fc89a3b27 100644 --- a/src/frontend/components/Radar/radarHints.ts +++ b/src/frontend/components/Radar/radarHints.ts @@ -70,7 +70,7 @@ export interface DiveInput { carIdx: number; dist: number; /** Rate of change of `dist`; for a car behind, positive closes in. */ - closingSpeed: number; + relativeSpeed: number; lane: number; length: number; } @@ -152,9 +152,9 @@ export class DiveTracker { this.held.set(car.carIdx, { hint: fresh, until: ctx.time + HOLD_S }); this.hints.set(car.carIdx, fresh); } else if (held) { - held.hint.closingKmh = Math.max(car.closingSpeed, 0) * 3.6; + held.hint.closingKmh = Math.max(car.relativeSpeed, 0) * 3.6; held.hint.secondsToSide = - car.closingSpeed > 0 ? gap / car.closingSpeed : Infinity; + car.relativeSpeed > 0 ? gap / car.relativeSpeed : Infinity; this.hints.set(car.carIdx, held.hint); } else { this.held.delete(car.carIdx); @@ -178,8 +178,8 @@ export class DiveTracker { braking: boolean, ctx: DiveContext ): DiveHint | null { - if (car.closingSpeed < minClosing) return null; - const secondsToSide = gap / car.closingSpeed; + if (car.relativeSpeed < minClosing) return null; + const secondsToSide = gap / car.relativeSpeed; if (secondsToSide > ctx.warnSeconds * FAST_LOOKAHEAD) return null; const laneSide = Math.abs(car.lane) >= SIDE_LANE ? Math.sign(car.lane) : 0; const side = laneSide || (braking ? ctx.cornerInside : 0); @@ -187,7 +187,7 @@ export class DiveTracker { secondsToSide <= ctx.warnSeconds && (braking || laneSide !== 0); return { level: diving ? 'dive' : 'fast', - closingKmh: car.closingSpeed * 3.6, + closingKmh: car.relativeSpeed * 3.6, secondsToSide, side, }; diff --git a/src/frontend/components/Radar/radarModel.spec.ts b/src/frontend/components/Radar/radarModel.spec.ts index 1faf39cc8..17d2e3a61 100644 --- a/src/frontend/components/Radar/radarModel.spec.ts +++ b/src/frontend/components/Radar/radarModel.spec.ts @@ -9,7 +9,7 @@ import { const car = (carIdx: number, dist: number, onPitRoad = false): RadarCar => ({ carIdx, dist, - closingSpeed: 0, + relativeSpeed: 0, lane: 0, laneSource: 'none', onPitRoad, diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index d74c3e9e6..f60a9fee2 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -149,7 +149,7 @@ export interface RadarCar { /** Metres along the track from the focus car; positive is ahead. */ dist: number; /** Rate of change of `dist` in m/s; positive means pulling away ahead. */ - closingSpeed: number; + relativeSpeed: number; /** * Lateral lane relative to the focus car, in lanes; 0 is our lane, * negative is left. iRacing publishes no lateral position, so this is an From b7dcf283ad5593be5661a95552a46fbb4af0e2f3 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 04:13:19 +0300 Subject: [PATCH 03/32] chore: filter radar pit wall side once and trim draw module exports Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 18 +++++++++++++++ src/app/processors/RadarProcessor.ts | 16 ++++++-------- src/app/processors/radarLanes.spec.ts | 8 ------- src/app/processors/radarLanes.ts | 12 ++-------- src/frontend/components/Radar/radarDraw.ts | 22 +++++++------------ .../components/Radar/radarDrawDebug.ts | 4 ++-- .../components/Radar/radarDrawFollow.ts | 4 ++-- .../components/Radar/radarDrawHazards.ts | 15 +++++-------- .../components/Radar/radarDrawWarnings.ts | 2 +- 9 files changed, 46 insertions(+), 55 deletions(-) diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 0ec5df580..eaa945bf7 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -221,6 +221,24 @@ describe('RadarProcessor', () => { ]); }); + it('leaves a pit road car alongside, but spaces a track car out', () => { + const processor = createProcessor(); + processor.onFrame( + frame({ + time: 1, + pcts: [0.5, 0.5 + m(2), 0.5 - m(2)], + onPitRoad: [false, true, false], + carLeftRight: CarLeftRight.Off, + }) + ); + + const byIdx = new Map( + processor.snapshot().cars.map((car) => [car.carIdx, car.dist]) + ); + expect(byIdx.get(1)).toBeCloseTo(2); + expect(byIdx.get(2)).toBeCloseTo(-5); + }); + it('reports the focus car parked in its pit box', () => { const processor = createProcessor(); processor.onFrame( diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 786135884..544877301 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -236,8 +236,12 @@ export class RadarProcessor implements TelemetryProcessor { }); } cars.sort((a, b) => Math.abs(a.dist) - Math.abs(b.dist)); - this.assignLanes(frame, time, focus, isOnTrack, cars, formation); - separateInLaneCars(cars, onPitRoad[focus] === true); + // Cars across the pit wall are beside the track, not in a lane of it. + const sameSide = cars.filter( + (car) => car.onPitRoad === (onPitRoad[focus] === true) + ); + this.assignLanes(frame, time, focus, isOnTrack, sameSide, formation); + separateInLaneCars(sameSide); } // The pedals are the player's, so they say nothing about a camera car. @@ -313,17 +317,11 @@ export class RadarProcessor implements TelemetryProcessor { this.lanes.reset(); this.laneFocus = focus; } - const focusOnPitRoad = valuesOf(frame, 'CarIdxOnPitRoad')[focus] === true; const spotter = isOnTrack && focus === scalarNumber(frame, 'PlayerCarIdx') ? scalarNumber(frame, 'CarLeftRight', CarLeftRight.Off) : null; - const lanes = this.lanes.update( - time, - cars.filter((car) => car.onPitRoad === focusOnPitRoad), - spotter, - formation - ); + const lanes = this.lanes.update(time, cars, spotter, formation); for (const car of cars) { const lane = lanes.get(car.carIdx); if (!lane) continue; diff --git a/src/app/processors/radarLanes.spec.ts b/src/app/processors/radarLanes.spec.ts index 62c449071..b5e3dc276 100644 --- a/src/app/processors/radarLanes.spec.ts +++ b/src/app/processors/radarLanes.spec.ts @@ -393,12 +393,4 @@ describe('separateInLaneCars', () => { separateInLaneCars(cars); expect(cars.map((car) => car.dist)).toEqual([1.8, 6, -1]); }); - - it('leaves a car across the pit wall alongside', () => { - const cars = [{ dist: 1.8, lane: 0, laneSource: 'none', onPitRoad: true }]; - separateInLaneCars(cars, false); - expect(cars[0].dist).toBe(1.8); - separateInLaneCars(cars, true); - expect(cars[0].dist).toBe(5); - }); }); diff --git a/src/app/processors/radarLanes.ts b/src/app/processors/radarLanes.ts index 35632b857..7c5ba84a8 100644 --- a/src/app/processors/radarLanes.ts +++ b/src/app/processors/radarLanes.ts @@ -346,20 +346,12 @@ export class RadarLaneTracker { * length when nothing says they are beside it. The spotter is silent about * them, so they are not alongside; the overlap is noise in lap distance, and * drawing them on top of the focus car is worse than drawing them nose to - * tail. Cars on the other side of the pit wall are left where they are. + * tail. Pass only cars on the focus car's side of the pit wall. */ export const separateInLaneCars = ( - cars: { - dist: number; - lane: number; - laneSource: string; - onPitRoad?: boolean; - }[], - focusOnPitRoad = false + cars: { dist: number; lane: number; laneSource: string }[] ): void => { for (const car of cars) { - // Across the pit wall is beside the track, not in our lane of it. - if ((car.onPitRoad ?? focusOnPitRoad) !== focusOnPitRoad) continue; if ( car.laneSource === 'none' && Math.abs(car.lane) < 0.5 && diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index 219cf75a9..b8b6a18d3 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -356,15 +356,15 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { } } + const level = warningLevel( + car.dist, + car.length, + style.carLength, + style.cautionDistance + ); const dive = scene.dives?.get(car.carIdx); let diveArcCar = -1; if (dive) { - const level = warningLevel( - car.dist, - car.length, - style.carLength, - style.cautionDistance - ); const carX = pose.x; const carY = pose.y; if (dive.level === 'dive') { @@ -431,7 +431,7 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { ? `+${closing} ${dive.secondsToSide.toFixed(1)}s` : `+${closing}`; // On the side away from where it is heading. - const away = dive.side < 0 ? 1 : dive.side > 0 ? -1 : 1; + const away = dive.side > 0 ? -1 : 1; drawHintText( ctx, text, @@ -446,12 +446,6 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { } if (!style.showWarnings) continue; - const level = warningLevel( - car.dist, - car.length, - style.carLength, - style.cautionDistance - ); if (level === 'none') continue; const color = level === 'alongside' ? style.alongsideColor : style.closeColor; @@ -496,7 +490,6 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { const { follow } = scene; const followBeyondRange = !!follow && Math.abs(follow.dist) > style.range; if (follow && !followBeyondRange) { - projector.project(follow.dist, 0, pose); const car = scene.cars.find((c) => c.carIdx === follow.carIdx); if (car) { projector.project(car.dist, car.lateral, pose); @@ -507,6 +500,7 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { car.width * pixelsPerMetre ); } else { + projector.project(follow.dist, 0, pose); drawFollowLine(ctx, centre, pose, length, length); drawCarBody( ctx, diff --git a/src/frontend/components/Radar/radarDrawDebug.ts b/src/frontend/components/Radar/radarDrawDebug.ts index df0388435..31224b0e7 100644 --- a/src/frontend/components/Radar/radarDrawDebug.ts +++ b/src/frontend/components/Radar/radarDrawDebug.ts @@ -1,9 +1,9 @@ import type { RadarStyle } from './radarDrawTypes'; import { MIN_TEXT_PX, pose } from './radarDrawShared'; -export const DEBUG_COLOR = '#fbbf24'; +const DEBUG_COLOR = '#fbbf24'; -export interface DebugLabel { +interface DebugLabel { x: number; y: number; text: string; diff --git a/src/frontend/components/Radar/radarDrawFollow.ts b/src/frontend/components/Radar/radarDrawFollow.ts index 86ce8e183..e99fd140e 100644 --- a/src/frontend/components/Radar/radarDrawFollow.ts +++ b/src/frontend/components/Radar/radarDrawFollow.ts @@ -2,10 +2,10 @@ import type { RadarDrawFollow } from './radarDrawTypes'; import { MIN_TEXT_PX, pose } from './radarDrawShared'; import { formatDistance } from './radarStyle'; -export const FOLLOW_COLOR = '#22c55e'; +const FOLLOW_COLOR = '#22c55e'; export const PACE_CAR_FILL = '#e2e8f0'; -export const FOLLOW_DASH = [6, 5]; +const FOLLOW_DASH = [6, 5]; /** * Ends of the line from our car to the one we follow, each pulled in by half diff --git a/src/frontend/components/Radar/radarDrawHazards.ts b/src/frontend/components/Radar/radarDrawHazards.ts index 5c34936c3..8597380b6 100644 --- a/src/frontend/components/Radar/radarDrawHazards.ts +++ b/src/frontend/components/Radar/radarDrawHazards.ts @@ -5,8 +5,8 @@ import { arcThicknessPx, paintRimArc } from './radarArcs'; import { drawHintText, drawWarningOutline } from './radarDrawWarnings'; import { formatDistance, speedIn, speedUnit } from './radarStyle'; -export const HAZARD_YELLOW = '#facc15'; -export const HAZARD_LABELS: Record = { +const HAZARD_YELLOW = '#facc15'; +const HAZARD_LABELS: Record = { crash: 'CRASH', slow: 'SLOW', off: 'OFF', @@ -17,12 +17,9 @@ export const HAZARD_FLASH_HZ = 2; /** A crashed car is drawn turned across the road, as wrecks usually are. */ export const CRASH_TILT = 1.1; /** Furthest from straight ahead a rim marker may sit, so it never points back. */ -export const HAZARD_MAX_BEARING = (100 * Math.PI) / 180; +const HAZARD_MAX_BEARING = (100 * Math.PI) / 180; -export const hazardColor = ( - kind: RadarHazardKind, - style: RadarStyle -): string => +const hazardColor = (kind: RadarHazardKind, style: RadarStyle): string => kind === 'crash' ? style.alongsideColor : kind === 'slow' @@ -53,12 +50,12 @@ export const hazardWanted = ( * Off the track is steady: the car is out of the way. Everything else * flashes once it is near, and a car coming back on flashes at any distance. */ -export const hazardFlashes = (hazard: RadarDrawHazard, style: RadarStyle) => +const hazardFlashes = (hazard: RadarDrawHazard, style: RadarStyle) => hazard.kind === 'rejoin' || (hazard.kind !== 'off' && hazard.dist < style.hazardBlinkDistance); /** Warning triangle with an exclamation mark, centred on (x, y). */ -export const drawHazardTriangle = ( +const drawHazardTriangle = ( ctx: CanvasRenderingContext2D, x: number, y: number, diff --git a/src/frontend/components/Radar/radarDrawWarnings.ts b/src/frontend/components/Radar/radarDrawWarnings.ts index 86ee2b2fb..dc476e6a1 100644 --- a/src/frontend/components/Radar/radarDrawWarnings.ts +++ b/src/frontend/components/Radar/radarDrawWarnings.ts @@ -28,7 +28,7 @@ export const drawWarningOutline = ( ctx.restore(); }; -export interface RimWarning { +interface RimWarning { x: number; y: number; carLengthPx: number; From 89c86422541487d43d377671814a2eb8be673d1e Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 04:14:43 +0300 Subject: [PATCH 04/32] test: cover radar pit wall separation with the focus car in the pits Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 18 ++++++++++++++++++ src/app/processors/RadarProcessor.ts | 5 ++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index eaa945bf7..48811bf0f 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -239,6 +239,24 @@ describe('RadarProcessor', () => { expect(byIdx.get(2)).toBeCloseTo(-5); }); + it('spaces out pit road cars, not track cars, for a focus car in the pits', () => { + const processor = createProcessor(); + processor.onFrame( + frame({ + time: 1, + pcts: [0.5, 0.5 + m(2), 0.5 - m(2)], + onPitRoad: [true, true, false], + carLeftRight: CarLeftRight.Off, + }) + ); + + const byIdx = new Map( + processor.snapshot().cars.map((car) => [car.carIdx, car.dist]) + ); + expect(byIdx.get(1)).toBeCloseTo(5); + expect(byIdx.get(2)).toBeCloseTo(-2); + }); + it('reports the focus car parked in its pit box', () => { const processor = createProcessor(); processor.onFrame( diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 544877301..de4740fc0 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -237,9 +237,8 @@ export class RadarProcessor implements TelemetryProcessor { } cars.sort((a, b) => Math.abs(a.dist) - Math.abs(b.dist)); // Cars across the pit wall are beside the track, not in a lane of it. - const sameSide = cars.filter( - (car) => car.onPitRoad === (onPitRoad[focus] === true) - ); + const focusOnPit = onPitRoad[focus] === true; + const sameSide = cars.filter((car) => car.onPitRoad === focusOnPit); this.assignLanes(frame, time, focus, isOnTrack, sameSide, formation); separateInLaneCars(sameSide); } From f57913beefe14567bedf7512f30bda7962acdc29 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 09:45:01 +0300 Subject: [PATCH 05/32] fix: radar hazards ignore corner pace and replay scrubs, no spacing without a spotter Slow cars now need to crawl or still be shedding speed, a sudden drop is a crash only down to a crawl, the tracker starts over when session time runs backwards, cars are spaced apart only while the spotter speaks, and the Slow Cars description no longer promises a learned track profile. Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 15 ++++++++ src/app/processors/RadarProcessor.ts | 19 +++++++-- src/app/processors/radarHazards.spec.ts | 47 ++++++++++++++++++++--- src/app/processors/radarHazards.ts | 31 +++++++++++++-- src/types/widgets/radar.ts | 2 +- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 48811bf0f..1d525e8d2 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -352,6 +352,21 @@ describe('RadarProcessor', () => { ]); }); + it('does not space cars out while watching another car', () => { + const processor = createProcessor(); + processor.onFrame( + frame({ + time: 1, + pcts: [0.1, 0.5, 0.5 + m(2)], + camCarIdx: 1, + carLeftRight: CarLeftRight.Off, + }) + ); + + // No spotter for a camera car: side by side stays side by side. + expect(processor.snapshot().cars[0].dist).toBeCloseTo(2); + }); + it('reads the pace formation from a recorded rolling start', () => { const processor = new RadarProcessor(); processor.init(rollingSession as unknown as Session); diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index de4740fc0..f042465af 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -239,8 +239,17 @@ export class RadarProcessor implements TelemetryProcessor { // Cars across the pit wall are beside the track, not in a lane of it. const focusOnPit = onPitRoad[focus] === true; const sameSide = cars.filter((car) => car.onPitRoad === focusOnPit); - this.assignLanes(frame, time, focus, isOnTrack, sameSide, formation); - separateInLaneCars(sameSide); + const spotterSpeaks = this.assignLanes( + frame, + time, + focus, + isOnTrack, + sameSide, + formation + ); + // Without the spotter nobody says a car is not alongside: cars side by + // side on a replay or a camera car must not be pulled apart. + if (spotterSpeaks) separateInLaneCars(sameSide); } // The pedals are the player's, so they say nothing about a camera car. @@ -302,7 +311,8 @@ export class RadarProcessor implements TelemetryProcessor { * The spotter speaks for the player's car only, and only while it is * driving; when the camera is on someone else, lanes come from memory and * pairing alone. Cars across the pit wall are beside the track, not in a - * lane of it, so they stay out of the estimate. + * lane of it, so they stay out of the estimate. Returns whether the spotter + * was listened to. */ private assignLanes( frame: Telemetry, @@ -311,7 +321,7 @@ export class RadarProcessor implements TelemetryProcessor { isOnTrack: boolean, cars: RadarCar[], formation: Formation | null - ): void { + ): boolean { if (focus !== this.laneFocus) { this.lanes.reset(); this.laneFocus = focus; @@ -327,6 +337,7 @@ export class RadarProcessor implements TelemetryProcessor { car.lane = lane.lane; car.laneSource = lane.source; } + return spotter !== null; } snapshot(): RadarSnapshot { diff --git a/src/app/processors/radarHazards.spec.ts b/src/app/processors/radarHazards.spec.ts index ec814f58d..ddaa7988f 100644 --- a/src/app/processors/radarHazards.spec.ts +++ b/src/app/processors/radarHazards.spec.ts @@ -65,9 +65,9 @@ describe('RadarHazardTracker', () => { expect(reported).toEqual([]); }); - it('warns when closing fast on a car far slower than us', () => { + it('warns when closing fast on a car that crawls', () => { const field = new Field(); - field.speeds = [60, 50, 25]; + field.speeds = [40, 50, 11]; field.run(3); expect(field.run(1)).toEqual([ @@ -75,6 +75,43 @@ describe('RadarHazardTracker', () => { ]); }); + it('stays quiet about a car steady at corner pace far below us', () => { + const field = new Field(); + field.speeds = [60, 50, 25]; + + // A hairpin ahead while we are still on the straight. + expect(field.run(3)).toEqual([]); + }); + + it('does not call braking for a hairpin a crash', () => { + const field = new Field(); + field.run(5); + // 50 to 20 m/s in 1.5 s: far under its own top speed, but not stopped. + for (const speed of [40, 30, 20]) field.run(0.5, { speed }); + + expect(field.run(1).map((hazard) => hazard.kind)).not.toContain('crash'); + }); + + it('starts over when session time runs backwards', () => { + const field = new Field(); + field.run(5); + field.time = 1; + // A scrub back: the stale top speed must not make a cornering car crash. + field.speeds[2] = 20; + field.tracker.update({ + time: field.time, + focus: 0, + playerPct: field.pcts[0], + pcts: field.pcts, + speeds: field.speeds, + surfaces: field.surfaces, + onPitRoad: field.onPitRoad, + excluded: new Set(), + quiet: false, + }); + expect(field.run(1)).toEqual([]); + }); + it('stays quiet about a slow car while we crawl up to it', () => { const field = new Field(); // A hairpin: half our speed, but only 10 m/s of closing, 20 s away. @@ -117,9 +154,9 @@ describe('RadarHazardTracker', () => { it('calls a car slowing gradually far below us slow, not a crash', () => { const field = new Field(); field.run(5); - // Gradually down to 25 m/s: no sudden drop, but half our speed. - for (let speed = 45; speed >= 25; speed -= 5) field.run(1, { speed }); - expect(field.run(1.5)).toEqual([ + // Gradually down to 15 m/s: no sudden drop, but far below our speed. + for (let speed = 45; speed >= 15; speed -= 5) field.run(0.5, { speed }); + expect(field.last).toEqual([ expect.objectContaining({ carIdx: 2, kind: 'slow' }), ]); }); diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index d6b51fed8..fb0b3aeda 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -17,6 +17,19 @@ const REACH_MAX_S = 8; const RACING_MS = 15; /** Dropping below this share of its own recent top speed is sudden. */ const SUDDEN_SHARE = 0.4; +/** + * ...and below this speed in m/s, so braking for a hairpin is not a crash; a + * car that is really crashed or spun is down to a crawl within moments. + */ +const SUDDEN_MAX_MS = 12; +/** Below this, in m/s, a car crawls: slow on any part of the track. */ +const CRAWL_MS = 12; +/** + * Above CRAWL_MS a slow car only counts while it is still shedding speed, below + * this share of its own top speed of the last moments: a car steady at corner + * pace is cornering, not in trouble. + */ +const SHEDDING_SHARE = 0.85; /** Back above this share of the speed before a drop, the car is racing again. */ const RECOVER_SHARE = 0.75; /** Seconds a car must stay slow before it is reported. */ @@ -90,7 +103,7 @@ export interface HazardFrame { * Nothing here depends on the track or on car classes. A stopped car, an * excursion and a rejoin are facts about the car. A sudden drop from the * car's own recent speed is a crash or a spin, and a car far slower than the - * focus car is slow; both only count while the focus car is closing in on + * focus car is slow, if it crawls or is still shedding speed; both only count while the focus car is closing in on * them fast enough to reach them soon, so a hairpin, a braking zone or a * slower class pulling away from nobody raises no alarm. */ @@ -99,6 +112,8 @@ export class RadarHazardTracker { private readonly states = new Map(); /** Session time the radar was last quiet. */ private lastQuiet = -Infinity; + /** Session time of the previous update, to notice a replay scrub back. */ + private lastTime = -Infinity; setTrackLength(metres: number): void { if (metres === this.trackLength) return; @@ -110,12 +125,17 @@ export class RadarHazardTracker { reset(): void { this.states.clear(); this.lastQuiet = -Infinity; + this.lastTime = -Infinity; } update(frame: HazardFrame): RadarHazard[] { const hazards: RadarHazard[] = []; - if (this.trackLength <= 0 || frame.settling) return hazards; const { time, pcts } = frame; + // Time running backwards is a replay scrub: the stamps kept per car would + // be from the future, so start over. + if (time < this.lastTime) this.reset(); + this.lastTime = time; + if (this.trackLength <= 0 || frame.settling) return hazards; // Nothing done under a caution or on the grid says anything afterwards. if (frame.quiet) { this.states.clear(); @@ -185,7 +205,7 @@ export class RadarHazardTracker { if ( state.droppedFrom < 0 && state.peak >= RACING_MS && - speed < state.peak * SUDDEN_SHARE + speed < Math.min(state.peak * SUDDEN_SHARE, SUDDEN_MAX_MS) ) { state.droppedFrom = state.peak; state.droppedAt = time; @@ -238,7 +258,10 @@ export class RadarHazardTracker { return 'crash'; } - const slow = reachable && speed < focusSpeed * SLOW_SHARE; + const slow = + reachable && + speed < focusSpeed * SLOW_SHARE && + (speed < CRAWL_MS || speed < state.peak * SHEDDING_SHARE); if (!slow) { state.slowSince = -1; return null; diff --git a/src/types/widgets/radar.ts b/src/types/widgets/radar.ts index 888cc167e..4f12cb3e2 100644 --- a/src/types/widgets/radar.ts +++ b/src/types/widgets/radar.ts @@ -299,7 +299,7 @@ export default defineWidgetManifest({ type: 'boolean', label: 'Slow Cars', description: - 'A car under 60% of the speed the field does at that spot of the lap. The radar learns those speeds as cars go round, so this needs a lap or so. Amber.', + 'A car under 60% of your speed that you are closing on fast, and that is crawling or still losing speed. A car steady at corner pace does not count. Amber.', }, hazardOff: { type: 'boolean', From 8535a46e2e706ea473adcebf66f87f5567422f95 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 10:44:59 +0300 Subject: [PATCH 06/32] feat: radar pole side per track from a data file The pole line of a pace field sits on the inside of the first corner. A generator reads it from the track drawings into radarPoleSides.json; sides confirmed by driving (Sebring, Interlagos left; Lime Rock, Watkins Glen right) and the Long Beach name exception are kept when it is regenerated. Tracks with no entry keep the old right-hand default, ovals stay on the left. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 1 + src/app/processors/RadarProcessor.spec.ts | 25 +- src/app/processors/RadarProcessor.ts | 1 + src/app/processors/radarFormation.spec.ts | 23 +- src/app/processors/radarFormation.ts | 23 +- src/types/index.ts | 1 + src/types/radarPoleSides.json | 1512 +++++++++++++++++++++ src/types/radarPoleSides.spec.ts | 84 ++ src/types/radarPoleSides.ts | 71 + tools/generate-radar-pole-sides.ts | 140 ++ 10 files changed, 1862 insertions(+), 19 deletions(-) create mode 100644 src/types/radarPoleSides.json create mode 100644 src/types/radarPoleSides.spec.ts create mode 100644 src/types/radarPoleSides.ts create mode 100644 tools/generate-radar-pole-sides.ts diff --git a/package.json b/package.json index fd02b4117..febc898d1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "fetch-lovely-data": "npx tsx ./tools/fetch-lovely-data.ts", "fetch-lovely-track-data": "npx tsx ./tools/fetch-lovely-track-data.ts", "generate-garage61-ids": "npx tsx ./tools/generate-garage61-ids.ts", + "generate-radar-pole-sides": "npx tsx ./tools/generate-radar-pole-sides.ts", "prepare": "husky" }, "devDependencies": { diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 1d525e8d2..837415391 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -378,7 +378,8 @@ describe('RadarProcessor', () => { expect(follow?.dist).toBeGreaterThan(0); expect(cars.every((car) => car.laneSource === 'pace')).toBe(true); - // The pole line runs on the right: cars in it sit right of us. + // This is Interlagos, where the pole line is on the left: cars in it sit + // left of us. const base = rollingTelemetry as unknown as Record< string, { value: unknown[] } @@ -387,6 +388,28 @@ describe('RadarProcessor', () => { const mine = lines[base.PlayerCarIdx.value[0] as number]; const towardsPole = cars.filter((car) => lines[car.carIdx] < mine); expect(towardsPole.length).toBeGreaterThan(0); + expect(towardsPole.every((car) => car.lane < 0)).toBe(true); + }); + + it('puts the pole line on the right where the track says so', () => { + // The same recording on Lime Rock, whose pole line is on the right. + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + session.WeekendInfo.TrackName = 'limerock 2019 gp'; + const processor = new RadarProcessor(); + processor.init(session); + processor.onFrame(rollingTelemetry as unknown as Telemetry); + + const base = rollingTelemetry as unknown as Record< + string, + { value: unknown[] } + >; + const lines = base.CarIdxPaceLine.value as number[]; + const mine = lines[base.PlayerCarIdx.value[0] as number]; + const towardsPole = processor + .snapshot() + .cars.filter((car) => lines[car.carIdx] < mine); + expect(towardsPole.length).toBeGreaterThan(0); expect(towardsPole.every((car) => car.lane > 0)).toBe(true); }); diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index f042465af..7e2aba878 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -136,6 +136,7 @@ export class RadarProcessor implements TelemetryProcessor { const trackType = session?.WeekendInfo?.TrackType ?? ''; this.lanes.setPacePoleLeft( paceFieldPoleLeft( + session?.WeekendInfo?.TrackID, session?.WeekendInfo?.TrackName, trackType !== '' && !/road|rally/i.test(trackType) ) diff --git a/src/app/processors/radarFormation.spec.ts b/src/app/processors/radarFormation.spec.ts index 03ae43c0f..8f980b172 100644 --- a/src/app/processors/radarFormation.spec.ts +++ b/src/app/processors/radarFormation.spec.ts @@ -85,17 +85,28 @@ describe('parseStartingGrid', () => { }); describe('paceFieldPoleLeft', () => { - it('puts the pole line on the left on Long Beach', () => { - expect(paceFieldPoleLeft('longbeach', false)).toBe(true); + it('puts the pole line on the left on Long Beach, found by name', () => { + expect(paceFieldPoleLeft(undefined, 'longbeach', false)).toBe(true); }); - it('keeps it on the right on other road courses', () => { - expect(paceFieldPoleLeft('interlagos gp', false)).toBe(false); - expect(paceFieldPoleLeft(undefined, false)).toBe(false); + it('follows the recorded side of a road course by track id', () => { + // Sebring and Interlagos turn left first; Lime Rock and Watkins Glen + // right. All four were checked by driving. + expect(paceFieldPoleLeft(95, 'sebring international', false)).toBe(true); + expect(paceFieldPoleLeft(212, 'interlagos gp', false)).toBe(true); + expect(paceFieldPoleLeft(353, 'limerock 2019 gp', false)).toBe(false); + expect(paceFieldPoleLeft(434, 'watkinsglen 2021 fullcourse', false)).toBe( + false + ); + }); + + it('keeps it on the right for a track nothing is known about', () => { + expect(paceFieldPoleLeft(999999, 'new track', false)).toBe(false); + expect(paceFieldPoleLeft(undefined, undefined, false)).toBe(false); }); it('puts it on the inside, the left, on ovals', () => { - expect(paceFieldPoleLeft('daytona 2011 oval', true)).toBe(true); + expect(paceFieldPoleLeft(undefined, 'daytona 2011 oval', true)).toBe(true); }); }); diff --git a/src/app/processors/radarFormation.ts b/src/app/processors/radarFormation.ts index b4e35cc9e..0e3281d72 100644 --- a/src/app/processors/radarFormation.ts +++ b/src/app/processors/radarFormation.ts @@ -1,4 +1,9 @@ -import { SessionState, TrackLocation, type Session } from '@irdashies/types'; +import { + SessionState, + TrackLocation, + poleLeftForTrack, + type Session, +} from '@irdashies/types'; /** iRacing's PaceMode telemetry values. */ export const PaceMode = { @@ -38,23 +43,17 @@ export const parseStartingGrid = ( return { columns }; }; -/** - * Road courses where the pole line of a pace field is on the left. Everywhere - * else on road it is on the right, as checked at Fuji, Watkins Glen and Mount - * Panorama. Found by driving: the Sporting Code names no side. Keys are - * WeekendInfo.TrackName; add a track here when its pace field shows the wrong - * way round. - */ -const POLE_LEFT_TRACKS: ReadonlySet = new Set(['longbeach']); - /** * Whether the pole line of a pace field is on the left: on ovals the inside, - * and on the road courses listed above. + * on road courses the side recorded in `radarPoleSides.json` (the inside of + * the first corner unless a driver found otherwise), and on the right for a + * track with no entry. */ export const paceFieldPoleLeft = ( + trackId: number | undefined, trackName: string | undefined, oval: boolean -): boolean => oval || POLE_LEFT_TRACKS.has(trackName ?? ''); +): boolean => oval || (poleLeftForTrack(trackId, trackName) ?? false); /** * Starting grid positions by car index, 0 for pole: the session's own grid diff --git a/src/types/index.ts b/src/types/index.ts index 812d81f52..53d7b1539 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,6 +32,7 @@ export * from './simWidgetSupport'; export * from './displayFallback'; export * from './lapHistoryBridge'; export * from './lmuTrackAliases'; +export * from './radarPoleSides'; export * from './widgets'; export * from './releaseNotes'; export * from './releaseNotesFixture'; diff --git a/src/types/radarPoleSides.json b/src/types/radarPoleSides.json new file mode 100644 index 000000000..4596b17c1 --- /dev/null +++ b/src/types/radarPoleSides.json @@ -0,0 +1,1512 @@ +{ + "version": 1, + "tracks": { + "1": { + "poleLeft": false, + "source": "geometry" + }, + "2": { + "poleLeft": false, + "source": "geometry" + }, + "3": { + "poleLeft": false, + "source": "geometry" + }, + "4": { + "poleLeft": false, + "source": "geometry" + }, + "5": { + "poleLeft": false, + "source": "geometry" + }, + "6": { + "poleLeft": false, + "source": "geometry" + }, + "7": { + "poleLeft": false, + "source": "geometry" + }, + "8": { + "poleLeft": false, + "source": "geometry" + }, + "9": { + "poleLeft": false, + "source": "geometry" + }, + "18": { + "poleLeft": false, + "source": "geometry" + }, + "21": { + "poleLeft": true, + "source": "geometry" + }, + "22": { + "poleLeft": true, + "source": "geometry" + }, + "24": { + "poleLeft": false, + "source": "geometry" + }, + "26": { + "poleLeft": true, + "source": "geometry" + }, + "28": { + "poleLeft": true, + "source": "geometry" + }, + "29": { + "poleLeft": false, + "source": "geometry" + }, + "34": { + "poleLeft": false, + "source": "geometry" + }, + "37": { + "poleLeft": true, + "source": "geometry" + }, + "38": { + "poleLeft": false, + "source": "geometry" + }, + "39": { + "poleLeft": true, + "source": "geometry" + }, + "41": { + "poleLeft": false, + "source": "geometry" + }, + "42": { + "poleLeft": false, + "source": "geometry" + }, + "43": { + "poleLeft": false, + "source": "geometry" + }, + "44": { + "poleLeft": false, + "source": "geometry" + }, + "45": { + "poleLeft": true, + "source": "geometry" + }, + "46": { + "poleLeft": true, + "source": "geometry" + }, + "47": { + "poleLeft": true, + "source": "geometry" + }, + "48": { + "poleLeft": true, + "source": "geometry" + }, + "49": { + "poleLeft": true, + "source": "geometry" + }, + "50": { + "poleLeft": false, + "source": "geometry" + }, + "51": { + "poleLeft": true, + "source": "geometry" + }, + "52": { + "poleLeft": true, + "source": "geometry" + }, + "95": { + "poleLeft": true, + "source": "verified" + }, + "96": { + "poleLeft": true, + "source": "geometry" + }, + "97": { + "poleLeft": false, + "source": "geometry" + }, + "98": { + "poleLeft": true, + "source": "geometry" + }, + "99": { + "poleLeft": true, + "source": "geometry" + }, + "100": { + "poleLeft": true, + "source": "geometry" + }, + "102": { + "poleLeft": true, + "source": "geometry" + }, + "105": { + "poleLeft": true, + "source": "geometry" + }, + "106": { + "poleLeft": false, + "source": "geometry" + }, + "107": { + "poleLeft": false, + "source": "geometry" + }, + "108": { + "poleLeft": false, + "source": "geometry" + }, + "109": { + "poleLeft": false, + "source": "geometry" + }, + "110": { + "poleLeft": true, + "source": "geometry" + }, + "111": { + "poleLeft": true, + "source": "geometry" + }, + "112": { + "poleLeft": false, + "source": "geometry" + }, + "113": { + "poleLeft": true, + "source": "geometry" + }, + "114": { + "poleLeft": false, + "source": "geometry" + }, + "117": { + "poleLeft": false, + "source": "geometry" + }, + "118": { + "poleLeft": false, + "source": "geometry" + }, + "119": { + "poleLeft": true, + "source": "geometry" + }, + "120": { + "poleLeft": true, + "source": "geometry" + }, + "122": { + "poleLeft": true, + "source": "geometry" + }, + "126": { + "poleLeft": false, + "source": "geometry" + }, + "127": { + "poleLeft": false, + "source": "geometry" + }, + "128": { + "poleLeft": false, + "source": "geometry" + }, + "129": { + "poleLeft": true, + "source": "geometry" + }, + "130": { + "poleLeft": true, + "source": "geometry" + }, + "132": { + "poleLeft": true, + "source": "geometry" + }, + "134": { + "poleLeft": false, + "source": "geometry" + }, + "135": { + "poleLeft": true, + "source": "geometry" + }, + "136": { + "poleLeft": true, + "source": "geometry" + }, + "137": { + "poleLeft": true, + "source": "geometry" + }, + "138": { + "poleLeft": true, + "source": "geometry" + }, + "139": { + "poleLeft": false, + "source": "geometry" + }, + "140": { + "poleLeft": false, + "source": "geometry" + }, + "141": { + "poleLeft": false, + "source": "geometry" + }, + "142": { + "poleLeft": false, + "source": "geometry" + }, + "144": { + "poleLeft": false, + "source": "geometry" + }, + "145": { + "poleLeft": false, + "source": "geometry" + }, + "146": { + "poleLeft": false, + "source": "geometry" + }, + "147": { + "poleLeft": false, + "source": "geometry" + }, + "148": { + "poleLeft": false, + "source": "geometry" + }, + "149": { + "poleLeft": false, + "source": "geometry" + }, + "150": { + "poleLeft": false, + "source": "geometry" + }, + "151": { + "poleLeft": false, + "source": "geometry" + }, + "152": { + "poleLeft": false, + "source": "geometry" + }, + "153": { + "poleLeft": true, + "source": "geometry" + }, + "154": { + "poleLeft": true, + "source": "geometry" + }, + "155": { + "poleLeft": true, + "source": "geometry" + }, + "156": { + "poleLeft": false, + "source": "geometry" + }, + "157": { + "poleLeft": true, + "source": "geometry" + }, + "158": { + "poleLeft": true, + "source": "geometry" + }, + "159": { + "poleLeft": false, + "source": "geometry" + }, + "160": { + "poleLeft": false, + "source": "geometry" + }, + "163": { + "poleLeft": false, + "source": "geometry" + }, + "164": { + "poleLeft": false, + "source": "geometry" + }, + "165": { + "poleLeft": false, + "source": "geometry" + }, + "166": { + "poleLeft": false, + "source": "geometry" + }, + "167": { + "poleLeft": false, + "source": "geometry" + }, + "168": { + "poleLeft": false, + "source": "geometry" + }, + "170": { + "poleLeft": true, + "source": "geometry" + }, + "171": { + "poleLeft": true, + "source": "geometry" + }, + "172": { + "poleLeft": true, + "source": "geometry" + }, + "173": { + "poleLeft": false, + "source": "geometry" + }, + "174": { + "poleLeft": false, + "source": "geometry" + }, + "175": { + "poleLeft": true, + "source": "geometry" + }, + "176": { + "poleLeft": true, + "source": "geometry" + }, + "179": { + "poleLeft": true, + "source": "geometry" + }, + "180": { + "poleLeft": false, + "source": "geometry" + }, + "181": { + "poleLeft": false, + "source": "geometry" + }, + "182": { + "poleLeft": false, + "source": "geometry" + }, + "183": { + "poleLeft": false, + "source": "geometry" + }, + "184": { + "poleLeft": false, + "source": "geometry" + }, + "185": { + "poleLeft": false, + "source": "geometry" + }, + "186": { + "poleLeft": false, + "source": "geometry" + }, + "187": { + "poleLeft": false, + "source": "geometry" + }, + "189": { + "poleLeft": true, + "source": "geometry" + }, + "192": { + "poleLeft": true, + "source": "geometry" + }, + "193": { + "poleLeft": true, + "source": "geometry" + }, + "194": { + "poleLeft": false, + "source": "geometry" + }, + "195": { + "poleLeft": false, + "source": "geometry" + }, + "196": { + "poleLeft": false, + "source": "geometry" + }, + "197": { + "poleLeft": false, + "source": "geometry" + }, + "199": { + "poleLeft": true, + "source": "geometry" + }, + "200": { + "poleLeft": true, + "source": "geometry" + }, + "202": { + "poleLeft": true, + "source": "geometry" + }, + "204": { + "poleLeft": true, + "source": "geometry" + }, + "205": { + "poleLeft": false, + "source": "geometry" + }, + "206": { + "poleLeft": false, + "source": "geometry" + }, + "207": { + "poleLeft": false, + "source": "geometry" + }, + "208": { + "poleLeft": true, + "source": "geometry" + }, + "209": { + "poleLeft": false, + "source": "geometry" + }, + "210": { + "poleLeft": false, + "source": "geometry" + }, + "211": { + "poleLeft": true, + "source": "geometry" + }, + "212": { + "poleLeft": true, + "source": "verified" + }, + "213": { + "poleLeft": true, + "source": "geometry" + }, + "215": { + "poleLeft": true, + "source": "geometry" + }, + "216": { + "poleLeft": false, + "source": "geometry" + }, + "217": { + "poleLeft": true, + "source": "geometry" + }, + "218": { + "poleLeft": true, + "source": "geometry" + }, + "219": { + "poleLeft": true, + "source": "geometry" + }, + "220": { + "poleLeft": false, + "source": "geometry" + }, + "221": { + "poleLeft": false, + "source": "geometry" + }, + "222": { + "poleLeft": true, + "source": "geometry" + }, + "223": { + "poleLeft": false, + "source": "geometry" + }, + "224": { + "poleLeft": false, + "source": "geometry" + }, + "226": { + "poleLeft": true, + "source": "geometry" + }, + "227": { + "poleLeft": true, + "source": "geometry" + }, + "228": { + "poleLeft": false, + "source": "geometry" + }, + "229": { + "poleLeft": true, + "source": "geometry" + }, + "230": { + "poleLeft": true, + "source": "geometry" + }, + "231": { + "poleLeft": true, + "source": "geometry" + }, + "233": { + "poleLeft": false, + "source": "geometry" + }, + "234": { + "poleLeft": false, + "source": "geometry" + }, + "238": { + "poleLeft": true, + "source": "geometry" + }, + "239": { + "poleLeft": false, + "source": "geometry" + }, + "240": { + "poleLeft": false, + "source": "geometry" + }, + "241": { + "poleLeft": false, + "source": "geometry" + }, + "242": { + "poleLeft": false, + "source": "geometry" + }, + "244": { + "poleLeft": true, + "source": "geometry" + }, + "246": { + "poleLeft": false, + "source": "geometry" + }, + "247": { + "poleLeft": false, + "source": "geometry" + }, + "248": { + "poleLeft": true, + "source": "geometry" + }, + "249": { + "poleLeft": true, + "source": "geometry" + }, + "250": { + "poleLeft": false, + "source": "geometry" + }, + "252": { + "poleLeft": true, + "source": "geometry" + }, + "253": { + "poleLeft": false, + "source": "geometry" + }, + "255": { + "poleLeft": false, + "source": "geometry" + }, + "257": { + "poleLeft": false, + "source": "geometry" + }, + "259": { + "poleLeft": false, + "source": "geometry" + }, + "260": { + "poleLeft": false, + "source": "geometry" + }, + "261": { + "poleLeft": true, + "source": "geometry" + }, + "262": { + "poleLeft": false, + "source": "geometry" + }, + "263": { + "poleLeft": true, + "source": "geometry" + }, + "264": { + "poleLeft": false, + "source": "geometry" + }, + "266": { + "poleLeft": true, + "source": "geometry" + }, + "267": { + "poleLeft": true, + "source": "geometry" + }, + "268": { + "poleLeft": false, + "source": "geometry" + }, + "269": { + "poleLeft": false, + "source": "geometry" + }, + "277": { + "poleLeft": true, + "source": "geometry" + }, + "290": { + "poleLeft": false, + "source": "geometry" + }, + "293": { + "poleLeft": false, + "source": "geometry" + }, + "295": { + "poleLeft": true, + "source": "geometry" + }, + "296": { + "poleLeft": false, + "source": "geometry" + }, + "297": { + "poleLeft": false, + "source": "geometry" + }, + "298": { + "poleLeft": false, + "source": "geometry" + }, + "299": { + "poleLeft": true, + "source": "geometry" + }, + "304": { + "poleLeft": true, + "source": "geometry" + }, + "306": { + "poleLeft": false, + "source": "geometry" + }, + "312": { + "poleLeft": false, + "source": "geometry" + }, + "319": { + "poleLeft": false, + "source": "geometry" + }, + "322": { + "poleLeft": false, + "source": "geometry" + }, + "323": { + "poleLeft": false, + "source": "geometry" + }, + "324": { + "poleLeft": false, + "source": "geometry" + }, + "325": { + "poleLeft": false, + "source": "geometry" + }, + "326": { + "poleLeft": false, + "source": "geometry" + }, + "327": { + "poleLeft": false, + "source": "geometry" + }, + "328": { + "poleLeft": false, + "source": "geometry" + }, + "329": { + "poleLeft": false, + "source": "geometry" + }, + "330": { + "poleLeft": true, + "source": "geometry" + }, + "332": { + "poleLeft": true, + "source": "geometry" + }, + "333": { + "poleLeft": true, + "source": "geometry" + }, + "334": { + "poleLeft": false, + "source": "geometry" + }, + "335": { + "poleLeft": true, + "source": "geometry" + }, + "336": { + "poleLeft": false, + "source": "geometry" + }, + "337": { + "poleLeft": false, + "source": "geometry" + }, + "338": { + "poleLeft": false, + "source": "geometry" + }, + "340": { + "poleLeft": true, + "source": "geometry" + }, + "341": { + "poleLeft": false, + "source": "geometry" + }, + "342": { + "poleLeft": false, + "source": "geometry" + }, + "343": { + "poleLeft": false, + "source": "geometry" + }, + "345": { + "poleLeft": false, + "source": "geometry" + }, + "346": { + "poleLeft": false, + "source": "geometry" + }, + "347": { + "poleLeft": false, + "source": "geometry" + }, + "348": { + "poleLeft": false, + "source": "geometry" + }, + "349": { + "poleLeft": false, + "source": "geometry" + }, + "350": { + "poleLeft": true, + "source": "geometry" + }, + "352": { + "poleLeft": false, + "source": "geometry" + }, + "353": { + "poleLeft": false, + "source": "verified" + }, + "354": { + "poleLeft": false, + "source": "geometry" + }, + "355": { + "poleLeft": false, + "source": "geometry" + }, + "358": { + "poleLeft": true, + "source": "geometry" + }, + "359": { + "poleLeft": false, + "source": "geometry" + }, + "360": { + "poleLeft": false, + "source": "geometry" + }, + "361": { + "poleLeft": false, + "source": "geometry" + }, + "363": { + "poleLeft": false, + "source": "geometry" + }, + "364": { + "poleLeft": true, + "source": "geometry" + }, + "381": { + "poleLeft": true, + "source": "geometry" + }, + "382": { + "poleLeft": true, + "source": "geometry" + }, + "383": { + "poleLeft": true, + "source": "geometry" + }, + "384": { + "poleLeft": true, + "source": "geometry" + }, + "385": { + "poleLeft": false, + "source": "geometry" + }, + "386": { + "poleLeft": true, + "source": "geometry" + }, + "388": { + "poleLeft": true, + "source": "geometry" + }, + "390": { + "poleLeft": false, + "source": "geometry" + }, + "391": { + "poleLeft": false, + "source": "geometry" + }, + "392": { + "poleLeft": false, + "source": "geometry" + }, + "393": { + "poleLeft": false, + "source": "geometry" + }, + "394": { + "poleLeft": false, + "source": "geometry" + }, + "395": { + "poleLeft": false, + "source": "geometry" + }, + "396": { + "poleLeft": false, + "source": "geometry" + }, + "397": { + "poleLeft": true, + "source": "geometry" + }, + "398": { + "poleLeft": false, + "source": "geometry" + }, + "399": { + "poleLeft": true, + "source": "geometry" + }, + "403": { + "poleLeft": false, + "source": "geometry" + }, + "404": { + "poleLeft": false, + "source": "geometry" + }, + "405": { + "poleLeft": true, + "source": "geometry" + }, + "407": { + "poleLeft": false, + "source": "geometry" + }, + "413": { + "poleLeft": false, + "source": "geometry" + }, + "423": { + "poleLeft": false, + "source": "geometry" + }, + "424": { + "poleLeft": true, + "source": "geometry" + }, + "425": { + "poleLeft": false, + "source": "geometry" + }, + "426": { + "poleLeft": true, + "source": "geometry" + }, + "427": { + "poleLeft": false, + "source": "geometry" + }, + "428": { + "poleLeft": false, + "source": "geometry" + }, + "429": { + "poleLeft": true, + "source": "geometry" + }, + "430": { + "poleLeft": true, + "source": "geometry" + }, + "431": { + "poleLeft": false, + "source": "geometry" + }, + "432": { + "poleLeft": false, + "source": "geometry" + }, + "433": { + "poleLeft": false, + "source": "geometry" + }, + "434": { + "poleLeft": false, + "source": "verified" + }, + "435": { + "poleLeft": false, + "source": "geometry" + }, + "436": { + "poleLeft": false, + "source": "geometry" + }, + "437": { + "poleLeft": true, + "source": "geometry" + }, + "439": { + "poleLeft": true, + "source": "geometry" + }, + "440": { + "poleLeft": false, + "source": "geometry" + }, + "443": { + "poleLeft": true, + "source": "geometry" + }, + "444": { + "poleLeft": false, + "source": "geometry" + }, + "445": { + "poleLeft": false, + "source": "geometry" + }, + "448": { + "poleLeft": false, + "source": "geometry" + }, + "449": { + "poleLeft": true, + "source": "geometry" + }, + "451": { + "poleLeft": true, + "source": "geometry" + }, + "452": { + "poleLeft": true, + "source": "geometry" + }, + "454": { + "poleLeft": true, + "source": "geometry" + }, + "455": { + "poleLeft": true, + "source": "geometry" + }, + "456": { + "poleLeft": true, + "source": "geometry" + }, + "463": { + "poleLeft": true, + "source": "geometry" + }, + "465": { + "poleLeft": false, + "source": "geometry" + }, + "466": { + "poleLeft": false, + "source": "geometry" + }, + "467": { + "poleLeft": false, + "source": "geometry" + }, + "468": { + "poleLeft": false, + "source": "geometry" + }, + "469": { + "poleLeft": false, + "source": "geometry" + }, + "470": { + "poleLeft": false, + "source": "geometry" + }, + "471": { + "poleLeft": false, + "source": "geometry" + }, + "472": { + "poleLeft": false, + "source": "geometry" + }, + "473": { + "poleLeft": false, + "source": "geometry" + }, + "474": { + "poleLeft": false, + "source": "geometry" + }, + "475": { + "poleLeft": true, + "source": "geometry" + }, + "476": { + "poleLeft": true, + "source": "geometry" + }, + "477": { + "poleLeft": true, + "source": "geometry" + }, + "478": { + "poleLeft": true, + "source": "geometry" + }, + "479": { + "poleLeft": true, + "source": "geometry" + }, + "480": { + "poleLeft": true, + "source": "geometry" + }, + "481": { + "poleLeft": true, + "source": "geometry" + }, + "482": { + "poleLeft": true, + "source": "geometry" + }, + "483": { + "poleLeft": true, + "source": "geometry" + }, + "485": { + "poleLeft": false, + "source": "geometry" + }, + "486": { + "poleLeft": false, + "source": "geometry" + }, + "487": { + "poleLeft": false, + "source": "geometry" + }, + "488": { + "poleLeft": false, + "source": "geometry" + }, + "489": { + "poleLeft": true, + "source": "geometry" + }, + "496": { + "poleLeft": true, + "source": "geometry" + }, + "497": { + "poleLeft": true, + "source": "geometry" + }, + "498": { + "poleLeft": false, + "source": "geometry" + }, + "499": { + "poleLeft": true, + "source": "geometry" + }, + "501": { + "poleLeft": false, + "source": "geometry" + }, + "502": { + "poleLeft": false, + "source": "geometry" + }, + "503": { + "poleLeft": false, + "source": "geometry" + }, + "504": { + "poleLeft": false, + "source": "geometry" + }, + "505": { + "poleLeft": false, + "source": "geometry" + }, + "506": { + "poleLeft": false, + "source": "geometry" + }, + "508": { + "poleLeft": false, + "source": "geometry" + }, + "509": { + "poleLeft": false, + "source": "geometry" + }, + "510": { + "poleLeft": false, + "source": "geometry" + }, + "511": { + "poleLeft": false, + "source": "geometry" + }, + "512": { + "poleLeft": false, + "source": "geometry" + }, + "515": { + "poleLeft": false, + "source": "geometry" + }, + "516": { + "poleLeft": false, + "source": "geometry" + }, + "517": { + "poleLeft": true, + "source": "geometry" + }, + "521": { + "poleLeft": false, + "source": "geometry" + }, + "523": { + "poleLeft": false, + "source": "geometry" + }, + "524": { + "poleLeft": false, + "source": "geometry" + }, + "525": { + "poleLeft": false, + "source": "geometry" + }, + "526": { + "poleLeft": false, + "source": "geometry" + }, + "527": { + "poleLeft": true, + "source": "geometry" + }, + "528": { + "poleLeft": false, + "source": "geometry" + }, + "529": { + "poleLeft": true, + "source": "geometry" + }, + "530": { + "poleLeft": false, + "source": "geometry" + }, + "532": { + "poleLeft": false, + "source": "geometry" + }, + "533": { + "poleLeft": false, + "source": "geometry" + }, + "534": { + "poleLeft": false, + "source": "geometry" + }, + "535": { + "poleLeft": false, + "source": "geometry" + }, + "536": { + "poleLeft": false, + "source": "geometry" + }, + "537": { + "poleLeft": false, + "source": "geometry" + }, + "538": { + "poleLeft": false, + "source": "geometry" + }, + "539": { + "poleLeft": false, + "source": "geometry" + }, + "540": { + "poleLeft": false, + "source": "geometry" + }, + "541": { + "poleLeft": false, + "source": "geometry" + }, + "542": { + "poleLeft": false, + "source": "geometry" + }, + "543": { + "poleLeft": false, + "source": "geometry" + }, + "544": { + "poleLeft": false, + "source": "geometry" + }, + "545": { + "poleLeft": false, + "source": "geometry" + }, + "546": { + "poleLeft": false, + "source": "geometry" + }, + "550": { + "poleLeft": false, + "source": "geometry" + }, + "551": { + "poleLeft": false, + "source": "geometry" + }, + "552": { + "poleLeft": false, + "source": "geometry" + }, + "553": { + "poleLeft": true, + "source": "geometry" + }, + "554": { + "poleLeft": true, + "source": "geometry" + }, + "555": { + "poleLeft": true, + "source": "geometry" + }, + "557": { + "poleLeft": true, + "source": "geometry" + }, + "558": { + "poleLeft": true, + "source": "geometry" + }, + "563": { + "poleLeft": true, + "source": "geometry" + }, + "564": { + "poleLeft": true, + "source": "geometry" + }, + "565": { + "poleLeft": true, + "source": "geometry" + }, + "566": { + "poleLeft": true, + "source": "geometry" + }, + "567": { + "poleLeft": true, + "source": "geometry" + }, + "568": { + "poleLeft": false, + "source": "geometry" + }, + "569": { + "poleLeft": true, + "source": "geometry" + }, + "570": { + "poleLeft": true, + "source": "geometry" + }, + "571": { + "poleLeft": true, + "source": "geometry" + }, + "572": { + "poleLeft": false, + "source": "geometry" + }, + "573": { + "poleLeft": false, + "source": "geometry" + }, + "574": { + "poleLeft": false, + "source": "geometry" + }, + "575": { + "poleLeft": false, + "source": "geometry" + }, + "576": { + "poleLeft": false, + "source": "geometry" + }, + "577": { + "poleLeft": true, + "source": "geometry" + }, + "579": { + "poleLeft": true, + "source": "geometry" + }, + "580": { + "poleLeft": true, + "source": "geometry" + }, + "582": { + "poleLeft": false, + "source": "geometry" + }, + "584": { + "poleLeft": false, + "source": "geometry" + }, + "585": { + "poleLeft": true, + "source": "geometry" + }, + "586": { + "poleLeft": true, + "source": "geometry" + }, + "587": { + "poleLeft": false, + "source": "geometry" + }, + "588": { + "poleLeft": true, + "source": "geometry" + }, + "589": { + "poleLeft": false, + "source": "geometry" + }, + "590": { + "poleLeft": true, + "source": "geometry" + }, + "591": { + "poleLeft": true, + "source": "geometry" + }, + "592": { + "poleLeft": false, + "source": "geometry" + }, + "593": { + "poleLeft": true, + "source": "geometry" + }, + "595": { + "poleLeft": false, + "source": "geometry" + }, + "596": { + "poleLeft": false, + "source": "geometry" + }, + "597": { + "poleLeft": false, + "source": "geometry" + }, + "598": { + "poleLeft": false, + "source": "geometry" + }, + "599": { + "poleLeft": false, + "source": "geometry" + } + }, + "trackNames": { + "longbeach": true + } +} diff --git a/src/types/radarPoleSides.spec.ts b/src/types/radarPoleSides.spec.ts new file mode 100644 index 000000000..b78adccc2 --- /dev/null +++ b/src/types/radarPoleSides.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + RADAR_POLE_SIDES, + parseRadarPoleSides, + poleLeftForTrack, +} from './radarPoleSides'; + +describe('radarPoleSides.json', () => { + it('is well formed: nothing is dropped when parsed', () => { + const raw = JSON.parse( + JSON.stringify(RADAR_POLE_SIDES) + ) as typeof RADAR_POLE_SIDES; + expect(parseRadarPoleSides(raw)).toEqual(RADAR_POLE_SIDES); + expect(Object.keys(RADAR_POLE_SIDES.tracks).length).toBeGreaterThan(100); + }); + + it('keeps the sides confirmed by driving', () => { + const verified = Object.entries(RADAR_POLE_SIDES.tracks) + .filter(([, entry]) => entry.source === 'verified') + .map(([id, entry]) => [id, entry.poleLeft]); + expect(verified).toEqual( + expect.arrayContaining([ + ['95', true], + ['212', true], + ['353', false], + ['434', false], + ]) + ); + expect(RADAR_POLE_SIDES.trackNames.longbeach).toBe(true); + }); +}); + +describe('parseRadarPoleSides', () => { + it.each([null, undefined, 3, 'x', { version: 2, tracks: {} }])( + 'gives an empty table for %j', + (raw) => { + expect(parseRadarPoleSides(raw)).toEqual({ + version: 1, + tracks: {}, + trackNames: {}, + }); + } + ); + + it('drops malformed entries and keeps the good ones', () => { + const parsed = parseRadarPoleSides({ + version: 1, + tracks: { + '1': { poleLeft: true, source: 'geometry' }, + abc: { poleLeft: true, source: 'geometry' }, + '2': { poleLeft: 'yes', source: 'geometry' }, + '3': { poleLeft: false, source: 'guess' }, + '4': null, + }, + trackNames: { a: true, b: 'left' }, + }); + expect(parsed.tracks).toEqual({ + '1': { poleLeft: true, source: 'geometry' }, + }); + expect(parsed.trackNames).toEqual({ a: true }); + }); +}); + +describe('poleLeftForTrack', () => { + const sides = parseRadarPoleSides({ + version: 1, + tracks: { '7': { poleLeft: false, source: 'geometry' } }, + trackNames: { oddtrack: true, toString: true }, + }); + + it('lets a track name override the id', () => { + expect(poleLeftForTrack(7, 'oddtrack', sides)).toBe(true); + }); + + it('falls back to the id, then to nothing', () => { + expect(poleLeftForTrack(7, 'other', sides)).toBe(false); + expect(poleLeftForTrack(8, 'other', sides)).toBeUndefined(); + expect(poleLeftForTrack(undefined, undefined, sides)).toBeUndefined(); + }); + + it('does not read inherited object keys as track names', () => { + expect(poleLeftForTrack(undefined, 'constructor', sides)).toBeUndefined(); + }); +}); diff --git a/src/types/radarPoleSides.ts b/src/types/radarPoleSides.ts new file mode 100644 index 000000000..a4b6c295f --- /dev/null +++ b/src/types/radarPoleSides.ts @@ -0,0 +1,71 @@ +import poleSidesData from './radarPoleSides.json'; + +/** + * Which side the pole line of a pace field is on, per track. + * + * iRacing does not say and the Sporting Code names no side, so this is data: + * `radarPoleSides.json`. Pole sits on the inside of the first corner, which + * `npm run generate-radar-pole-sides` reads from the track drawings + * (source "geometry"). A side confirmed by driving is "verified" and is kept + * when the file is regenerated. To correct a track, edit its entry to + * "verified" with the side you saw, or add a TrackName under `trackNames`. + */ +export type PoleSideSource = 'geometry' | 'verified'; + +export interface RadarPoleSides { + /** Bumped when the shape changes, so a newer file is never half-read. */ + version: 1; + /** By iRacing `WeekendInfo.TrackID`. */ + tracks: Readonly< + Record + >; + /** By `WeekendInfo.TrackName`, for tracks without a usable id entry. */ + trackNames: Readonly>; +} + +/** Keeps what is well formed and drops the rest; never throws. */ +export const parseRadarPoleSides = (raw: unknown): RadarPoleSides => { + const empty: RadarPoleSides = { version: 1, tracks: {}, trackNames: {} }; + if (!raw || typeof raw !== 'object') return empty; + const data = raw as Record; + if (data.version !== 1) return empty; + const tracks: Record = + {}; + for (const [id, entry] of Object.entries( + (data.tracks ?? {}) as Record + )) { + const value = entry as { poleLeft?: unknown; source?: unknown } | null; + if ( + /^\d+$/.test(id) && + typeof value?.poleLeft === 'boolean' && + (value.source === 'geometry' || value.source === 'verified') + ) { + tracks[id] = { poleLeft: value.poleLeft, source: value.source }; + } + } + const trackNames: Record = {}; + for (const [name, poleLeft] of Object.entries( + (data.trackNames ?? {}) as Record + )) { + if (typeof poleLeft === 'boolean') trackNames[name] = poleLeft; + } + return { version: 1, tracks, trackNames }; +}; + +export const RADAR_POLE_SIDES = parseRadarPoleSides(poleSidesData); + +/** + * The pole side for a road course, or undefined when nothing is known. A + * track name wins over the id: it is how a one-off correction is written. + */ +export const poleLeftForTrack = ( + trackId: number | undefined, + trackName: string | undefined, + sides: RadarPoleSides = RADAR_POLE_SIDES +): boolean | undefined => + (trackName !== undefined + ? Object.hasOwn(sides.trackNames, trackName) + ? sides.trackNames[trackName] + : undefined + : undefined) ?? + (trackId !== undefined ? sides.tracks[String(trackId)]?.poleLeft : undefined); diff --git a/tools/generate-radar-pole-sides.ts b/tools/generate-radar-pole-sides.ts new file mode 100644 index 000000000..f79d6dfa1 --- /dev/null +++ b/tools/generate-radar-pole-sides.ts @@ -0,0 +1,140 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; + +/** + * Builds src/types/radarPoleSides.json: for every track drawing, which side + * the pole line of a pace field is on, taken as the inside of the first + * corner after the start/finish line. + * + * A corner is the first stretch where the heading turns 35 degrees or more + * within 3% of a lap, so the rule does not need the track length. Entries a + * person confirmed by driving ("verified") and the `trackNames` exceptions are + * kept as they are; everything else is recomputed. + * + * Regenerate with: npm run generate-radar-pole-sides + */ + +const TRACKS_FILE = './src/frontend/domain/track/tracks/tracks.json'; +const OUTPUT_FILE = './src/types/radarPoleSides.json'; + +const SAMPLES = 1500; +/** Chord used to read the heading, as a share of the lap. */ +const CHORD = 0.004; +/** Window the turn is measured over, as a share of the lap. */ +const WINDOW = 0.03; +/** Heading change in the window that makes a corner, in degrees. */ +const CORNER_DEG = 35; +/** Only the first part of the lap is searched. */ +const SEARCH = 0.5; + +interface Drawing { + active: { + trackPathPoints: { x: number; y: number }[]; + totalLength: number; + }; + startFinish?: { + direction?: 'clockwise' | 'anticlockwise' | null; + point?: { length?: number }; + }; +} + +interface Entry { + poleLeft: boolean; + source: 'geometry' | 'verified'; +} + +/** Points along the lap from the start/finish line, in the direction of travel. */ +const lapPoints = (drawing: Drawing) => { + const { trackPathPoints: points, totalLength } = drawing.active; + const sf = drawing.startFinish?.point?.length; + if (sf === undefined || points.length < 2 || !(totalLength > 0)) return null; + const forward = drawing.startFinish?.direction === 'anticlockwise'; + return Array.from({ length: SAMPLES }, (_, k) => { + const along = (k / SAMPLES) * totalLength; + const length = forward + ? (sf + along) % totalLength + : (sf - along + totalLength) % totalLength; + const float = (length / totalLength) * (points.length - 1); + const i = Math.floor(float); + const j = Math.min(i + 1, points.length - 1); + const t = float - i; + return { + x: points[i].x + (points[j].x - points[i].x) * t, + y: points[i].y + (points[j].y - points[i].y) * t, + }; + }); +}; + +/** True when the first corner turns left. Drawings are y-down, so a positive heading change is a right turn. */ +export const firstCornerIsLeft = (drawing: Drawing): boolean | null => { + const pts = lapPoints(drawing); + if (!pts) return null; + const chord = Math.max(1, Math.round(SAMPLES * CHORD)); + const heading = pts.map((a, i) => { + const b = pts[(i + chord) % SAMPLES]; + return Math.atan2(b.y - a.y, b.x - a.x); + }); + const unwrapped = [heading[0]]; + for (let i = 1; i < SAMPLES; i += 1) { + const step = + ((heading[i] - heading[i - 1] + Math.PI) % (2 * Math.PI)) - Math.PI; + unwrapped.push( + unwrapped[i - 1] + (step < -Math.PI ? step + 2 * Math.PI : step) + ); + } + const window = Math.max(2, Math.round(SAMPLES * WINDOW)); + for (let i = window; i < SAMPLES * SEARCH; i += 1) { + const degrees = ((unwrapped[i] - unwrapped[i - window]) * 180) / Math.PI; + if (Math.abs(degrees) >= CORNER_DEG) return degrees < 0; + } + return null; +}; + +const main = () => { + const drawings = JSON.parse(readFileSync(TRACKS_FILE, 'utf8')) as Record< + string, + Drawing + >; + const previous = existsSync(OUTPUT_FILE) + ? JSON.parse(readFileSync(OUTPUT_FILE, 'utf8')) + : { tracks: {}, trackNames: {} }; + const tracks: Record = {}; + let skipped = 0; + for (const id of Object.keys(drawings).sort( + (a, b) => Number(a) - Number(b) + )) { + const kept = previous.tracks?.[id]; + if (kept?.source === 'verified') { + tracks[id] = kept; + continue; + } + let left: boolean | null; + try { + left = firstCornerIsLeft(drawings[id]); + } catch { + left = null; + } + if (left === null) { + skipped += 1; + continue; + } + tracks[id] = { poleLeft: left, source: 'geometry' }; + } + // Verified ids that have no drawing still stay. + for (const [id, kept] of Object.entries(previous.tracks ?? {})) { + if (kept.source === 'verified') tracks[id] = kept; + } + const sorted = Object.fromEntries( + Object.entries(tracks).sort(([a], [b]) => Number(a) - Number(b)) + ); + const output = { + version: 1, + tracks: sorted, + trackNames: previous.trackNames ?? {}, + }; + writeFileSync(OUTPUT_FILE, `${JSON.stringify(output, null, 2)}\n`, 'utf8'); + console.log( + `${Object.keys(sorted).length} tracks written, ${skipped} without a clear first corner` + ); +}; + +if (process.argv[1]?.includes('generate-radar-pole-sides')) main(); From 872aff710651bf8d987a5cd9e35889b19bf08e9a Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 10:52:17 +0300 Subject: [PATCH 07/32] data: Fuji GP pole side is verified (right) Co-Authored-By: Claude Sonnet 5.5 --- src/types/radarPoleSides.json | 2 +- src/types/radarPoleSides.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/radarPoleSides.json b/src/types/radarPoleSides.json index 4596b17c1..34bc9b89e 100644 --- a/src/types/radarPoleSides.json +++ b/src/types/radarPoleSides.json @@ -1039,7 +1039,7 @@ }, "444": { "poleLeft": false, - "source": "geometry" + "source": "verified" }, "445": { "poleLeft": false, diff --git a/src/types/radarPoleSides.spec.ts b/src/types/radarPoleSides.spec.ts index b78adccc2..ece0e972e 100644 --- a/src/types/radarPoleSides.spec.ts +++ b/src/types/radarPoleSides.spec.ts @@ -24,6 +24,7 @@ describe('radarPoleSides.json', () => { ['212', true], ['353', false], ['434', false], + ['444', false], ]) ); expect(RADAR_POLE_SIDES.trackNames.longbeach).toBe(true); From 6d59daf903b4afdf956f0c71ebe7ce13c2cfcefb Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 11:19:05 +0300 Subject: [PATCH 08/32] refactor: pole side table reads unknown sources as geometry and keeps reported ones Co-Authored-By: Claude Sonnet 5.5 --- src/types/radarPoleSides.spec.ts | 16 +++++++++++++++- src/types/radarPoleSides.ts | 24 +++++++++++++++++------- tools/generate-radar-pole-sides.ts | 11 +++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/types/radarPoleSides.spec.ts b/src/types/radarPoleSides.spec.ts index ece0e972e..ffc05cbe1 100644 --- a/src/types/radarPoleSides.spec.ts +++ b/src/types/radarPoleSides.spec.ts @@ -50,7 +50,7 @@ describe('parseRadarPoleSides', () => { '1': { poleLeft: true, source: 'geometry' }, abc: { poleLeft: true, source: 'geometry' }, '2': { poleLeft: 'yes', source: 'geometry' }, - '3': { poleLeft: false, source: 'guess' }, + '3': { poleLeft: false }, '4': null, }, trackNames: { a: true, b: 'left' }, @@ -60,6 +60,20 @@ describe('parseRadarPoleSides', () => { }); expect(parsed.trackNames).toEqual({ a: true }); }); + + it('reads a source it does not know as geometry, not as a missing entry', () => { + const parsed = parseRadarPoleSides({ + version: 1, + tracks: { + '5': { poleLeft: true, source: 'crowd' }, + '6': { poleLeft: false, source: 'reported' }, + }, + }); + expect(parsed.tracks).toEqual({ + '5': { poleLeft: true, source: 'geometry' }, + '6': { poleLeft: false, source: 'reported' }, + }); + }); }); describe('poleLeftForTrack', () => { diff --git a/src/types/radarPoleSides.ts b/src/types/radarPoleSides.ts index a4b6c295f..422c24fa7 100644 --- a/src/types/radarPoleSides.ts +++ b/src/types/radarPoleSides.ts @@ -4,13 +4,15 @@ import poleSidesData from './radarPoleSides.json'; * Which side the pole line of a pace field is on, per track. * * iRacing does not say and the Sporting Code names no side, so this is data: - * `radarPoleSides.json`. Pole sits on the inside of the first corner, which + * `radarPoleSides.json`, bundled at build time; the app never fetches it. + * Pole sits on the inside of the first corner, which * `npm run generate-radar-pole-sides` reads from the track drawings - * (source "geometry"). A side confirmed by driving is "verified" and is kept - * when the file is regenerated. To correct a track, edit its entry to - * "verified" with the side you saw, or add a TrackName under `trackNames`. + * (source "geometry"). A side confirmed by driving is "verified" and one + * gathered from users' own corrections is "reported"; both are kept when the + * file is regenerated. To correct a track, edit its entry to "verified" with + * the side you saw, or add a TrackName under `trackNames`. */ -export type PoleSideSource = 'geometry' | 'verified'; +export type PoleSideSource = 'geometry' | 'verified' | 'reported'; export interface RadarPoleSides { /** Bumped when the shape changes, so a newer file is never half-read. */ @@ -38,9 +40,17 @@ export const parseRadarPoleSides = (raw: unknown): RadarPoleSides => { if ( /^\d+$/.test(id) && typeof value?.poleLeft === 'boolean' && - (value.source === 'geometry' || value.source === 'verified') + value.source !== undefined ) { - tracks[id] = { poleLeft: value.poleLeft, source: value.source }; + // A source a newer file invents is still a usable side: read it as the + // weakest known one instead of dropping the entry. + tracks[id] = { + poleLeft: value.poleLeft, + source: + value.source === 'verified' || value.source === 'reported' + ? value.source + : 'geometry', + }; } } const trackNames: Record = {}; diff --git a/tools/generate-radar-pole-sides.ts b/tools/generate-radar-pole-sides.ts index f79d6dfa1..649c9f200 100644 --- a/tools/generate-radar-pole-sides.ts +++ b/tools/generate-radar-pole-sides.ts @@ -7,7 +7,8 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; * * A corner is the first stretch where the heading turns 35 degrees or more * within 3% of a lap, so the rule does not need the track length. Entries a - * person confirmed by driving ("verified") and the `trackNames` exceptions are + * person confirmed by driving ("verified"), users' corrections + * ("reported") and the `trackNames` exceptions are * kept as they are; everything else is recomputed. * * Regenerate with: npm run generate-radar-pole-sides @@ -39,7 +40,7 @@ interface Drawing { interface Entry { poleLeft: boolean; - source: 'geometry' | 'verified'; + source: 'geometry' | 'verified' | 'reported'; } /** Points along the lap from the start/finish line, in the direction of travel. */ @@ -103,7 +104,7 @@ const main = () => { (a, b) => Number(a) - Number(b) )) { const kept = previous.tracks?.[id]; - if (kept?.source === 'verified') { + if (kept?.source === 'verified' || kept?.source === 'reported') { tracks[id] = kept; continue; } @@ -121,7 +122,9 @@ const main = () => { } // Verified ids that have no drawing still stay. for (const [id, kept] of Object.entries(previous.tracks ?? {})) { - if (kept.source === 'verified') tracks[id] = kept; + if (kept.source === 'verified' || kept.source === 'reported') { + tracks[id] = kept; + } } const sorted = Object.fromEntries( Object.entries(tracks).sort(([a], [b]) => Number(a) - Number(b)) From 084dfb9af18c06037eb71ff10e158b7577901d2d Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 11:25:37 +0300 Subject: [PATCH 09/32] feat: radar keybinding to flip the pole side per track, remembered locally Radar: Flip Pole Side (unbound by default) swaps the pole line side for the current track, stores the player's choice by track id in the app config and reads it every frame, so it applies at once and on later runs. The radar names the side for three seconds. Flipping back to what the table says removes the entry, so only real differences are kept. Co-Authored-By: Claude Sonnet 5.5 --- src/app/bridge/iracingSdk/iracingSdkBridge.ts | 4 + src/app/bridge/keybindingsBridge.spec.ts | 6 ++ src/app/keybindingManager.spec.ts | 6 ++ src/app/keybindingManager.ts | 24 +++++- src/app/processors/RadarProcessor.spec.ts | 46 +++++++++- src/app/processors/RadarProcessor.ts | 48 +++++++++-- src/app/processors/processorRegistry.ts | 6 +- src/app/processors/radarPoleSideSettings.ts | 15 ++++ src/app/radarPoleSideOverrides.spec.ts | 84 +++++++++++++++++++ src/app/radarPoleSideOverrides.ts | 76 +++++++++++++++++ src/app/storage/radarPoleSideOverrides.ts | 10 +++ src/frontend/components/Radar/Radar.tsx | 2 + .../Radar/components/RadarDisplay.tsx | 3 + src/frontend/components/Radar/radarDraw.ts | 4 + .../components/Radar/radarDrawNotice.spec.ts | 37 ++++++++ .../components/Radar/radarDrawNotice.ts | 27 ++++++ .../components/Radar/radarDrawTypes.ts | 2 + src/types/channels/channel.ts | 5 ++ src/types/keybindings.ts | 10 ++- 19 files changed, 404 insertions(+), 11 deletions(-) create mode 100644 src/app/processors/radarPoleSideSettings.ts create mode 100644 src/app/radarPoleSideOverrides.spec.ts create mode 100644 src/app/radarPoleSideOverrides.ts create mode 100644 src/app/storage/radarPoleSideOverrides.ts create mode 100644 src/frontend/components/Radar/radarDrawNotice.spec.ts create mode 100644 src/frontend/components/Radar/radarDrawNotice.ts diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.ts index 50cf932ee..2c7dfb68b 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.ts @@ -155,6 +155,9 @@ export async function publishIRacingSDKEvents( const radarTuningStorage = channelBus ? await import('../../storage/radarTuning') : undefined; + const radarPoleSideStorage = channelBus + ? await import('../../storage/radarPoleSideOverrides') + : undefined; const processorHost = channelBus && referenceLapStorage ? createDefaultProcessorHost({ @@ -168,6 +171,7 @@ export async function publishIRacingSDKEvents( save: referenceLapStorage.saveReferenceLap, }, radarTuning: radarTuningStorage?.getRadarTuning, + radarPoleSides: radarPoleSideStorage?.radarPoleSideOverrides, }) : undefined; diff --git a/src/app/bridge/keybindingsBridge.spec.ts b/src/app/bridge/keybindingsBridge.spec.ts index 9ac9b1cb9..077371359 100644 --- a/src/app/bridge/keybindingsBridge.spec.ts +++ b/src/app/bridge/keybindingsBridge.spec.ts @@ -87,6 +87,12 @@ const sampleBindings = (): KeybindingsMap => ({ description: '', isDefault: true, }, + 'radar-flip-pole-side': { + accelerator: '', + label: '', + description: '', + isDefault: true, + }, }); const invokeUpdate = ( diff --git a/src/app/keybindingManager.spec.ts b/src/app/keybindingManager.spec.ts index c1a6541cc..df94f7774 100644 --- a/src/app/keybindingManager.spec.ts +++ b/src/app/keybindingManager.spec.ts @@ -73,6 +73,12 @@ const bindingsWithBadAccelerator = (): KeybindingsMap => ({ description: '', isDefault: true, }, + 'radar-flip-pole-side': { + accelerator: '', + label: 'Radar: Flip Pole Side', + description: '', + isDefault: true, + }, }); describe('KeybindingManager', () => { diff --git a/src/app/keybindingManager.ts b/src/app/keybindingManager.ts index 81b918ffd..aabf275e6 100644 --- a/src/app/keybindingManager.ts +++ b/src/app/keybindingManager.ts @@ -54,6 +54,10 @@ export class KeybindingManager { this.actionHandlers.set('next-profile', () => { this.cycleProfile(1); }); + + this.actionHandlers.set('radar-flip-pole-side', () => { + void this.flipRadarPoleSide(); + }); } // ponytail: dynamic import keeps storage/dashboards out of the constructor's @@ -91,6 +95,23 @@ export class KeybindingManager { } } + // Dynamic import for the same reason as cycleProfile: storage stays out of + // the constructor's module graph. + private async flipRadarPoleSide(): Promise { + try { + const { radarPoleSideOverrides } = + await import('./storage/radarPoleSideOverrides'); + const flipped = radarPoleSideOverrides.flip(); + if (flipped) { + logger.info( + `Radar pole side for track ${flipped.trackId} is now ${flipped.poleLeft ? 'left' : 'right'}` + ); + } + } catch (error) { + logger.error('Error flipping the radar pole side via keybind:', error); + } + } + private async saveTelemetry(): Promise { try { const { dumpCurrentTelemetry } = @@ -190,7 +211,8 @@ export class KeybindingManager { public unregisterAll(): void { for (const entry of Object.values(this.bindings)) { - if (isGamepadBinding(entry.accelerator)) continue; + // Unbound entries (empty accelerator) were never registered. + if (!entry.accelerator || isGamepadBinding(entry.accelerator)) continue; try { if (globalShortcut.isRegistered(entry.accelerator)) { globalShortcut.unregister(entry.accelerator); diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 837415391..3d6a094ff 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Session, Telemetry } from '@irdashies/types'; import { CarLeftRight, GlobalFlags, TrackLocation } from '@irdashies/types'; import { DEFAULT_RADAR_TUNING } from '../../types/widgetConfigs'; @@ -413,6 +413,50 @@ describe('RadarProcessor', () => { expect(towardsPole.every((car) => car.lane > 0)).toBe(true); }); + it('follows the pole side the player set, and says so for a moment', () => { + const player: { poleLeft?: boolean } = {}; + const poleSides = { + override: () => player.poleLeft, + setTrack: vi.fn(), + }; + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + session.WeekendInfo.TrackName = 'limerock 2019 gp'; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(session); + expect(poleSides.setTrack).toHaveBeenCalledWith(353, false); + + const base = rollingTelemetry as unknown as Record< + string, + { value: unknown[] } + >; + const lines = base.CarIdxPaceLine.value as number[]; + const mine = lines[base.PlayerCarIdx.value[0] as number]; + const sideOfPole = () => { + const towards = processor + .snapshot() + .cars.filter((car) => lines[car.carIdx] < mine); + return towards.every((car) => car.lane < 0) ? 'left' : 'right'; + }; + const at = (time: number) => + ({ + ...rollingTelemetry, + SessionTime: { value: [time] }, + }) as unknown as Telemetry; + + processor.onFrame(at(100)); + expect(sideOfPole()).toBe('right'); + expect(processor.snapshot().poleSideNotice).toBeNull(); + + player.poleLeft = true; + processor.onFrame(at(101)); + expect(sideOfPole()).toBe('left'); + expect(processor.snapshot().poleSideNotice).toBe('left'); + + processor.onFrame(at(105)); + expect(processor.snapshot().poleSideNotice).toBeNull(); + }); + it('puts the pole line on the left on a super speedway', () => { const processor = new RadarProcessor(); const session = rollingSession as unknown as Session; diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 7e2aba878..2b878d11a 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -24,6 +24,10 @@ import { type GridOptions, } from './radarFormation'; import { RadarHazardTracker } from './radarHazards'; +import { + NO_POLE_SIDE_SETTINGS, + type PoleSideSettings, +} from './radarPoleSideSettings'; import { wrapDelta } from './radarMath'; /** @@ -53,6 +57,8 @@ const MAX_FRAME_GAP_S = 0.5; */ const SPEED_SETTLE_S = 1; const RADAR_TICK_HZ = 25; +/** Seconds the radar names the pole side after it was flipped. */ +const POLE_NOTICE_S = 3; /** * Frame step `speedSmoothing` is the weight for: one tick of `tickRateHz`, * which the radar was tuned on. Scaling the weight by the real step keeps @@ -119,11 +125,19 @@ export class RadarProcessor implements TelemetryProcessor { private gridSessionNum = NaN; private latest: RadarSnapshot = emptySnapshot(0); + private trackId: number | undefined; + /** The side the table gives, and the side in use (the player's wins). */ + private tablePoleLeft = false; + private poleLeft = false; + /** Session time until which the radar says which side it flipped to. */ + private poleNoticeUntil = -Infinity; constructor( /** The dev view's tuning, read every frame so changes apply at once. */ private readonly tuning: () => RadarProcessorTuning = () => - DEFAULT_RADAR_TUNING + DEFAULT_RADAR_TUNING, + /** The player's own pole side per track, read every frame. */ + private readonly poleSides: PoleSideSettings = NO_POLE_SIDE_SETTINGS ) { this.lanes = new RadarLaneTracker(tuning); } @@ -134,13 +148,16 @@ export class RadarProcessor implements TelemetryProcessor { // TrackType is "road course", "short track", "super speedway", "dirt oval" // and so on; anything that is not road racing is an oval const trackType = session?.WeekendInfo?.TrackType ?? ''; - this.lanes.setPacePoleLeft( - paceFieldPoleLeft( - session?.WeekendInfo?.TrackID, - session?.WeekendInfo?.TrackName, - trackType !== '' && !/road|rally/i.test(trackType) - ) + this.trackId = session?.WeekendInfo?.TrackID; + this.tablePoleLeft = paceFieldPoleLeft( + this.trackId, + session?.WeekendInfo?.TrackName, + trackType !== '' && !/road|rally/i.test(trackType) ); + this.poleSides.setTrack(this.trackId, this.tablePoleLeft); + this.poleLeft = this.poleSides.override(this.trackId) ?? this.tablePoleLeft; + this.lanes.setPacePoleLeft(this.poleLeft); + this.poleNoticeUntil = -Infinity; const options = session?.WeekendInfo?.WeekendOptions; this.grid = { standingStart: options?.StandingStart === 1, @@ -161,6 +178,7 @@ export class RadarProcessor implements TelemetryProcessor { const pcts = valuesOf(frame, 'CarIdxLapDistPct'); const time = scalarNumber(frame, 'SessionTime'); this.updateSpeeds(pcts, time); + this.applyPoleSide(time); const camCarIdx = scalarNumber(frame, 'CamCarIdx'); const focus = @@ -289,6 +307,8 @@ export class RadarProcessor implements TelemetryProcessor { follow, focusBrake: drivingFocus && brake >= 0 ? brake : null, caution: (flags & CAUTION_FLAGS) !== 0, + poleSideNotice: + time < this.poleNoticeUntil ? (this.poleLeft ? 'left' : 'right') : null, cars, hazards, }; @@ -341,6 +361,18 @@ export class RadarProcessor implements TelemetryProcessor { return spotter !== null; } + /** + * Follows the player's own pole side for this track, which a keybinding can + * change at any time, and says so on the radar for a few seconds. + */ + private applyPoleSide(time: number): void { + const side = this.poleSides.override(this.trackId) ?? this.tablePoleLeft; + if (side === this.poleLeft) return; + this.poleLeft = side; + this.lanes.setPacePoleLeft(side); + this.poleNoticeUntil = time + POLE_NOTICE_S; + } + snapshot(): RadarSnapshot { return this.latest; } @@ -393,6 +425,7 @@ const emptySnapshot = (version: number): RadarSnapshot => ({ follow: null, focusBrake: null, caution: false, + poleSideNotice: null, cars: [], hazards: [], version, @@ -417,6 +450,7 @@ const sameSnapshot = ( previous.playerSpeed === next.playerSpeed && previous.focusBrake === next.focusBrake && previous.caution === next.caution && + previous.poleSideNotice === next.poleSideNotice && previous.trackLength === next.trackLength && previous.focusOnPitRoad === next.focusOnPitRoad && previous.focusInPitBox === next.focusInPitBox && diff --git a/src/app/processors/processorRegistry.ts b/src/app/processors/processorRegistry.ts index 3521d0411..7b0dfee1c 100644 --- a/src/app/processors/processorRegistry.ts +++ b/src/app/processors/processorRegistry.ts @@ -30,11 +30,14 @@ import { CarSystemsProcessor } from './CarSystemsProcessor'; import { SessionTimingProcessor } from './SessionTimingProcessor'; import { StandingsProcessor } from './StandingsProcessor'; import { TrackStateProcessor } from './TrackStateProcessor'; +import type { PoleSideSettings } from './radarPoleSideSettings'; interface ProcessorRegistryOptions { referenceLapPersistence: ReferenceLapPersistence; /** The radar's dev tuning from the current dashboard; defaults without. */ radarTuning?: () => RadarProcessorTuning; + /** The player's own pole side per track; the table's without. */ + radarPoleSides?: PoleSideSettings; } interface DefaultProcessorHostOptions extends ProcessorRegistryOptions { @@ -67,6 +70,7 @@ const defineProcessor = ( export const createProcessorDefinitions = ({ referenceLapPersistence, radarTuning, + radarPoleSides, }: ProcessorRegistryOptions): readonly AnyProcessorDefinition[] => [ defineProcessor({ channel: 'blind-spot.snapshot', @@ -125,7 +129,7 @@ export const createProcessorDefinitions = ({ defineProcessor({ channel: 'radar.snapshot', metricsPrefix: 'radar', - create: () => new RadarProcessor(radarTuning), + create: () => new RadarProcessor(radarTuning, radarPoleSides), }), defineProcessor({ channel: 'radio.snapshot', diff --git a/src/app/processors/radarPoleSideSettings.ts b/src/app/processors/radarPoleSideSettings.ts new file mode 100644 index 000000000..25aad5779 --- /dev/null +++ b/src/app/processors/radarPoleSideSettings.ts @@ -0,0 +1,15 @@ +/** + * What the radar needs to know about the player's own pole side choice. A + * processor does no I/O, so this is injected; the app backs it with a file. + */ +export interface PoleSideSettings { + /** The side the player set for this track (true: left), if they did. */ + override(trackId: number | undefined): boolean | undefined; + /** The radar reports the track it is on and the side the table gives. */ + setTrack(trackId: number | undefined, tableLeft: boolean): void; +} + +export const NO_POLE_SIDE_SETTINGS: PoleSideSettings = { + override: () => undefined, + setTrack: () => undefined, +}; diff --git a/src/app/radarPoleSideOverrides.spec.ts b/src/app/radarPoleSideOverrides.spec.ts new file mode 100644 index 000000000..0b1a4fb6b --- /dev/null +++ b/src/app/radarPoleSideOverrides.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + RadarPoleSideOverrides, + parsePoleSideOverrides, + type PoleSideOverrides, +} from './radarPoleSideOverrides'; + +const store = (initial: unknown = undefined) => { + let saved: unknown = initial; + const write = vi.fn((overrides: PoleSideOverrides) => { + saved = overrides; + }); + const overrides = new RadarPoleSideOverrides({ + read: () => saved, + write, + }); + return { overrides, write, saved: () => saved }; +}; + +describe('parsePoleSideOverrides', () => { + it.each([null, undefined, 3, 'x', [true]])('gives nothing for %j', (raw) => { + expect(parsePoleSideOverrides(raw)).toEqual({}); + }); + + it('keeps canonical ids with a boolean and drops the rest', () => { + expect( + parsePoleSideOverrides({ + '95': true, + '0': true, + '012': true, + abc: true, + '7': 'left', + '8': null, + '444': false, + }) + ).toEqual({ '95': true, '444': false }); + }); +}); + +describe('RadarPoleSideOverrides', () => { + it('has no override until one is flipped, or without a track id', () => { + const { overrides } = store(); + expect(overrides.override(95)).toBeUndefined(); + expect(overrides.override(undefined)).toBeUndefined(); + }); + + it('flips to the side opposite the table and remembers it', () => { + const { overrides, write, saved } = store(); + overrides.setTrack(95, true); + + expect(overrides.flip()).toEqual({ trackId: 95, poleLeft: false }); + expect(overrides.override(95)).toBe(false); + expect(write).toHaveBeenCalledTimes(1); + expect(saved()).toEqual({ '95': false }); + }); + + it('removes the entry when flipped back to what the table says', () => { + const { overrides, saved } = store(); + overrides.setTrack(95, true); + overrides.flip(); + + expect(overrides.flip()).toEqual({ trackId: 95, poleLeft: true }); + expect(overrides.override(95)).toBeUndefined(); + expect(saved()).toEqual({}); + }); + + it('starts from a saved override and keeps other tracks', () => { + const { overrides, saved } = store({ '95': false, '212': true }); + overrides.setTrack(212, false); + + // The table says right (false), the player has left; flipping gives right. + expect(overrides.flip()).toEqual({ trackId: 212, poleLeft: false }); + expect(saved()).toEqual({ '95': false }); + }); + + it('does nothing before a track is known', () => { + const { overrides, write } = store(); + expect(overrides.flip()).toBeUndefined(); + overrides.setTrack(undefined, true); + overrides.setTrack(-3, true); + expect(overrides.flip()).toBeUndefined(); + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts new file mode 100644 index 000000000..d24b2b901 --- /dev/null +++ b/src/app/radarPoleSideOverrides.ts @@ -0,0 +1,76 @@ +import type { PoleSideSettings } from './processors/radarPoleSideSettings'; + +/** iRacing track id (as a string) -> pole line on the left. */ +export type PoleSideOverrides = Record; + +/** The player can only correct so many tracks; a bigger file is not ours. */ +const MAX_OVERRIDES = 10000; + +export interface PoleSidePersistence { + read(): unknown; + write(overrides: PoleSideOverrides): void; +} + +/** The stored file is untrusted: keep canonical ids with a boolean side. */ +export const parsePoleSideOverrides = (raw: unknown): PoleSideOverrides => { + const overrides: PoleSideOverrides = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return overrides; + for (const [id, poleLeft] of Object.entries(raw).slice(0, MAX_OVERRIDES)) { + if (/^[1-9]\d{0,9}$/.test(id) && typeof poleLeft === 'boolean') { + overrides[id] = poleLeft; + } + } + return overrides; +}; + +/** + * The player's own pole side per track. Flipping the side the radar uses + * stores the opposite of it; flipping back to what the table says removes the + * entry, so only real differences are kept. + */ +export class RadarPoleSideOverrides implements PoleSideSettings { + private overrides?: PoleSideOverrides; + private track?: { trackId: number; tableLeft: boolean }; + + constructor(private readonly persistence: PoleSidePersistence) {} + + private load(): PoleSideOverrides { + this.overrides ??= parsePoleSideOverrides(this.persistence.read()); + return this.overrides; + } + + override(trackId: number | undefined): boolean | undefined { + if (trackId === undefined) return undefined; + const overrides = this.load(); + const key = String(trackId); + return Object.hasOwn(overrides, key) ? overrides[key] : undefined; + } + + setTrack(trackId: number | undefined, tableLeft: boolean): void { + this.track = + trackId !== undefined && Number.isInteger(trackId) && trackId > 0 + ? { trackId, tableLeft } + : undefined; + } + + /** + * Swaps the side in use on the current track. Returns the new side, or + * undefined when no track is known yet. + */ + flip(): { trackId: number; poleLeft: boolean } | undefined { + if (!this.track) return undefined; + const { trackId, tableLeft } = this.track; + const key = String(trackId); + const stored = this.load(); + const current = Object.hasOwn(stored, key) ? stored[key] : tableLeft; + const poleLeft = !current; + // Only a real difference from the table is kept. + const overrides: PoleSideOverrides = Object.fromEntries( + Object.entries(stored).filter(([id]) => id !== key) + ); + if (poleLeft !== tableLeft) overrides[key] = poleLeft; + this.persistence.write(overrides); + this.overrides = overrides; + return { trackId, poleLeft }; + } +} diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts new file mode 100644 index 000000000..83a59357c --- /dev/null +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -0,0 +1,10 @@ +import { readData, writeData } from './storage'; +import { RadarPoleSideOverrides } from '../radarPoleSideOverrides'; + +const STORAGE_KEY = 'radarPoleSideOverrides'; + +/** The player's pole side corrections, kept in the app's config file. */ +export const radarPoleSideOverrides = new RadarPoleSideOverrides({ + read: () => readData(STORAGE_KEY), + write: (overrides) => writeData(STORAGE_KEY, overrides), +}); diff --git a/src/frontend/components/Radar/Radar.tsx b/src/frontend/components/Radar/Radar.tsx index 0e00ee6f7..4f2aa9c91 100644 --- a/src/frontend/components/Radar/Radar.tsx +++ b/src/frontend/components/Radar/Radar.tsx @@ -43,6 +43,7 @@ const EMPTY_SNAPSHOT: RadarSnapshot = { follow: null, focusBrake: null, caution: false, + poleSideNotice: null, cars: [], hazards: [], version: 0, @@ -160,6 +161,7 @@ export const Radar = () => { snapshot.focusOnPitRoad, // From pit road the cars on track are none of our business. hazards: snapshot.focusOnPitRoad ? [] : snapshot.hazards, + poleSideNotice: snapshot.poleSideNotice, }, [demoFrame, snapshot, cars] ); diff --git a/src/frontend/components/Radar/components/RadarDisplay.tsx b/src/frontend/components/Radar/components/RadarDisplay.tsx index aeb854ef7..1ff34a954 100644 --- a/src/frontend/components/Radar/components/RadarDisplay.tsx +++ b/src/frontend/components/Radar/components/RadarDisplay.tsx @@ -35,6 +35,8 @@ export interface RadarFrame { quiet?: boolean; /** Cars in trouble ahead, nearest first, possibly far past the range. */ hazards?: readonly RadarHazard[]; + /** The pole side the player just flipped to, shown for a few seconds. */ + poleSideNotice?: 'left' | 'right' | null; } export interface RadarDiveOptions { @@ -269,6 +271,7 @@ export const RadarDisplay = memo( dives: diveHints, laneWidth, hazards, + poleSideNotice: snap.poleSideNotice, }); frameMs = performance.now() - started; if (active) handle = requestAnimationFrame(paint); diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index b8b6a18d3..404b4683a 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -1,3 +1,4 @@ +import { drawPoleSideNotice } from './radarDrawNotice'; import { overlapOf } from './radarHints'; import { formatDistance, speedIn } from './radarStyle'; import { warningLevel } from './radarDrawTypes'; @@ -569,6 +570,9 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { ); } } + if (scene.poleSideNotice) { + drawPoleSideNotice(ctx, size, scene.poleSideNotice); + } if (style.debugLabels || style.showFrameTime) { drawDebug(ctx, size, style, scene.frameMs); } diff --git a/src/frontend/components/Radar/radarDrawNotice.spec.ts b/src/frontend/components/Radar/radarDrawNotice.spec.ts new file mode 100644 index 000000000..7978c3238 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawNotice.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { drawPoleSideNotice } from './radarDrawNotice'; + +const fakeContext = () => { + const texts: string[] = []; + const ctx = { + save: vi.fn(), + restore: vi.fn(), + fillRect: vi.fn(), + fillText: vi.fn((text: string) => texts.push(text)), + measureText: () => ({ width: 80 }), + font: '', + fillStyle: '', + textAlign: '', + textBaseline: '', + } as unknown as CanvasRenderingContext2D; + return { ctx, texts }; +}; + +describe('drawPoleSideNotice', () => { + it.each([ + ['left', 'POLE LEFT'], + ['right', 'POLE RIGHT'], + ] as const)('names the %s side', (side, label) => { + const { ctx, texts } = fakeContext(); + drawPoleSideNotice(ctx, 300, side); + expect(texts).toEqual([label]); + expect(ctx.fillRect).toHaveBeenCalledTimes(1); + }); + + it('restores the canvas state it changed', () => { + const { ctx } = fakeContext(); + drawPoleSideNotice(ctx, 300, 'left'); + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/frontend/components/Radar/radarDrawNotice.ts b/src/frontend/components/Radar/radarDrawNotice.ts new file mode 100644 index 000000000..c7628abf2 --- /dev/null +++ b/src/frontend/components/Radar/radarDrawNotice.ts @@ -0,0 +1,27 @@ +import { MIN_TEXT_PX } from './radarDrawShared'; + +const NOTICE_COLOR = '#f8fafc'; +const NOTICE_BACKDROP = 'rgba(15, 23, 42, 0.8)'; + +/** Confirms a flipped pole side: a short label at the top of the radar. */ +export const drawPoleSideNotice = ( + ctx: CanvasRenderingContext2D, + size: number, + side: 'left' | 'right' +) => { + const fontSize = Math.max(MIN_TEXT_PX + 2, Math.round(size / 14)); + const text = side === 'left' ? 'POLE LEFT' : 'POLE RIGHT'; + ctx.save(); + ctx.font = `bold ${fontSize}px Lato, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const width = ctx.measureText(text).width + fontSize; + const height = fontSize * 1.6; + const x = size / 2; + const y = size * 0.1 + height / 2; + ctx.fillStyle = NOTICE_BACKDROP; + ctx.fillRect(x - width / 2, y - height / 2, width, height); + ctx.fillStyle = NOTICE_COLOR; + ctx.fillText(text, x, y); + ctx.restore(); +}; diff --git a/src/frontend/components/Radar/radarDrawTypes.ts b/src/frontend/components/Radar/radarDrawTypes.ts index 80acd936c..b505c1252 100644 --- a/src/frontend/components/Radar/radarDrawTypes.ts +++ b/src/frontend/components/Radar/radarDrawTypes.ts @@ -149,4 +149,6 @@ export interface RadarScene { laneWidth?: number; /** Cars in trouble ahead, nearest first. */ hazards?: readonly RadarDrawHazard[]; + /** Names the pole side the player just flipped to, for a few seconds. */ + poleSideNotice?: 'left' | 'right' | null; } diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index f60a9fee2..42f200645 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -213,6 +213,11 @@ export interface RadarSnapshot { focusBrake: number | null; /** A yellow or caution is out, so position fights are off. */ caution: boolean; + /** + * Set for a few seconds after the player flipped the pole side, naming the + * side now in use, so the radar can confirm the key press. + */ + poleSideNotice: 'left' | 'right' | null; /** Only cars within the processor's range, nearest first. */ cars: readonly RadarCar[]; /** diff --git a/src/types/keybindings.ts b/src/types/keybindings.ts index 07e6a897d..53d653221 100644 --- a/src/types/keybindings.ts +++ b/src/types/keybindings.ts @@ -4,7 +4,8 @@ export type StaticKeybindingActionId = | 'toggle-edit-mode' | 'save-telemetry' | 'prev-profile' - | 'next-profile'; + | 'next-profile' + | 'radar-flip-pole-side'; /** * Dynamic per-widget show/hide action, keyed by the widget instance id, e.g. @@ -107,4 +108,11 @@ export const DEFAULT_KEYBINDINGS: KeybindingsMap = { description: 'Switch to the next configuration profile', isDefault: true, }, + 'radar-flip-pole-side': { + accelerator: '', + label: 'Radar: Flip Pole Side', + description: + 'Swap the pole line side on the radar for the current track and remember it', + isDefault: true, + }, }; From 1dc9e4fb8ed10d4a424e32c5ffa6e5bdff53d86a Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 11:40:23 +0300 Subject: [PATCH 10/32] data: pole side table carries track names and iRacing's start/restart flags as hints Co-Authored-By: Claude Sonnet 5.5 --- src/types/radarPoleSides.json | 2632 ++++++++++++++++++++++++---- src/types/radarPoleSides.spec.ts | 33 + src/types/radarPoleSides.ts | 39 +- tools/generate-radar-pole-sides.ts | 52 +- 4 files changed, 2374 insertions(+), 382 deletions(-) diff --git a/src/types/radarPoleSides.json b/src/types/radarPoleSides.json index 34bc9b89e..67730be38 100644 --- a/src/types/radarPoleSides.json +++ b/src/types/radarPoleSides.json @@ -3,1507 +3,3387 @@ "tracks": { "1": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Lime Rock Park - 2008 - [Retired] Full Course", + "iracing": { + "start": false, + "restart": false + } }, "2": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] Full Course", + "iracing": { + "start": false, + "restart": false + } }, "3": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] Patriot", + "iracing": { + "start": false, + "restart": false + } }, "4": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] North", + "iracing": { + "start": false, + "restart": false + } }, "5": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] Grand West", + "iracing": { + "start": false, + "restart": false + } }, "6": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] Grand East", + "iracing": { + "start": false, + "restart": false + } }, "7": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] South", + "iracing": { + "start": false, + "restart": false + } }, "8": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Summit Point Raceway - Jefferson Circuit", + "iracing": { + "start": false, + "restart": false + } }, "9": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Summit Point Raceway - Summit Point Raceway", + "iracing": { + "start": false, + "restart": false + } }, "18": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Road America - [Retired] Full Course", + "iracing": { + "start": false, + "restart": false + } }, "21": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Homestead Miami Speedway - Road Course A", + "iracing": { + "start": true, + "restart": true + } }, "22": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Homestead Miami Speedway - Road Course B", + "iracing": { + "start": true, + "restart": true + } }, "24": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Summit Point Raceway - Short", + "iracing": { + "start": false, + "restart": false + } }, "26": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Road Course - 2008", + "iracing": { + "start": true, + "restart": false + } }, "28": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Moto - 2008", + "iracing": { + "start": true, + "restart": false + } }, "29": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Short - 2008", + "iracing": { + "start": true, + "restart": false + } }, "34": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Lime Rock Park - 2008 - [Retired] Chicane", + "iracing": { + "start": false, + "restart": false + } }, "37": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Charlotte Motor Speedway - 2008 - [Retired] Road Course", + "iracing": { + "start": true, + "restart": true + } }, "38": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Charlotte Motor Speedway - 2008 - [Retired] Infield Road Course", + "iracing": { + "start": true, + "restart": true + } }, "39": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Charlotte Motor Speedway - 2008 - [Retired] Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "41": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Silverstone Circuit - 2008 - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "42": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Silverstone Circuit - 2008 - Historical Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "43": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Silverstone Circuit - 2008 - International", + "iracing": { + "start": false, + "restart": false + } }, "44": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Silverstone Circuit - 2008 - National", + "iracing": { + "start": false, + "restart": false + } }, "45": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Silverstone Circuit - 2008 - Southern", + "iracing": { + "start": true, + "restart": true + } }, "46": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Barber Motorsports Park - [Retired] Full Course", + "iracing": { + "start": true, + "restart": true + } }, "47": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] WeatherTech Raceway at Laguna Seca - [Retired] Full Course", + "iracing": { + "start": true, + "restart": true + } }, "48": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - [Retired] NASCAR Short", + "iracing": { + "start": true, + "restart": true + } }, "49": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - [Retired] Open Wheel pre-2008", + "iracing": { + "start": true, + "restart": true + } }, "50": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Road America - [Retired] Bend", + "iracing": { + "start": false, + "restart": false + } }, "51": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "EchoPark Speedway (Atlanta) - Road - 2008", + "iracing": { + "start": true, + "restart": true + } }, "52": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "EchoPark Speedway (Atlanta) - Legends Oval - 2008", + "iracing": { + "start": true, + "restart": true + } }, "95": { "poleLeft": true, - "source": "verified" + "source": "verified", + "name": "Sebring International Raceway - International", + "iracing": { + "start": true, + "restart": true + } }, "96": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sebring International Raceway - Modified", + "iracing": { + "start": true, + "restart": true + } }, "97": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Sebring International Raceway - Club", + "iracing": { + "start": true, + "restart": true + } }, "98": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - [Retired] NASCAR Long", + "iracing": { + "start": true, + "restart": true + } }, "99": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Barber Motorsports Park - [Retired] Short A", + "iracing": { + "start": true, + "restart": true + } }, "100": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Barber Motorsports Park - [Retired] Short B", + "iracing": { + "start": true, + "restart": true + } }, "102": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - [Retired] Open Wheel 2008-2011", + "iracing": { + "start": true, + "restart": true + } }, "105": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Phoenix Raceway - 2008 - Road Course", + "iracing": { + "start": true, + "restart": true + } }, "106": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Watkins Glen International - [Retired] Cup", + "iracing": { + "start": false, + "restart": false + } }, "107": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Watkins Glen International - [Retired] Boot", + "iracing": { + "start": false, + "restart": false + } }, "108": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Watkins Glen International - [Retired] Classic Boot", + "iracing": { + "start": false, + "restart": false + } }, "109": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Watkins Glen International - [Retired] Classic", + "iracing": { + "start": false, + "restart": false + } }, "110": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Las Vegas Motor Speedway - Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "111": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Las Vegas Motor Speedway - Road Course Long", + "iracing": { + "start": false, + "restart": false + } }, "112": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Las Vegas Motor Speedway - Road Course Short", + "iracing": { + "start": false, + "restart": false + } }, "113": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Las Vegas Motor Speedway - Infield Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "114": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Las Vegas Motor Speedway - Road Course Combined", + "iracing": { + "start": false, + "restart": false + } }, "117": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Texas Motor Speedway - 2009 - Road Course Long", + "iracing": { + "start": false, + "restart": false + } }, "118": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Texas Motor Speedway - 2009 - Road Course Short A", + "iracing": { + "start": false, + "restart": false + } }, "119": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Texas Motor Speedway - 2009 - Road Course Short B", + "iracing": { + "start": false, + "restart": false + } }, "120": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Texas Motor Speedway - 2009 - Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "122": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Texas Motor Speedway - 2009 - Road Course Combined", + "iracing": { + "start": true, + "restart": true + } }, "126": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road Atlanta - Club", + "iracing": { + "start": false, + "restart": false + } }, "127": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road Atlanta - Full Course", + "iracing": { + "start": false, + "restart": false + } }, "128": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road Atlanta - Short", + "iracing": { + "start": false, + "restart": false + } }, "129": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "New Hampshire Motor Speedway - Road Course", + "iracing": { + "start": true, + "restart": true + } }, "130": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "New Hampshire Motor Speedway - Road Course with North Oval", + "iracing": { + "start": true, + "restart": true + } }, "132": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "New Hampshire Motor Speedway - Road Course with South Oval", + "iracing": { + "start": true, + "restart": true + } }, "134": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Indianapolis Motor Speedway - Road Course - 2009", + "iracing": { + "start": true, + "restart": true + } }, "135": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Indianapolis Motor Speedway - Bike - 2009", + "iracing": { + "start": true, + "restart": true + } }, "136": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Pocono Raceway - 2009 - Oval", + "iracing": { + "start": true, + "restart": true + } }, "137": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Pocono Raceway - 2009 - East", + "iracing": { + "start": true, + "restart": true + } }, "138": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Pocono Raceway - 2009 - South", + "iracing": { + "start": true, + "restart": true + } }, "139": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Pocono Raceway - 2009 - North", + "iracing": { + "start": false, + "restart": false + } }, "140": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Pocono Raceway - 2009 - International", + "iracing": { + "start": true, + "restart": true + } }, "141": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Virginia International Raceway - [Retired] Patriot Reverse", + "iracing": { + "start": false, + "restart": false + } }, "142": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Summit Point Raceway - Jefferson Reverse", + "iracing": { + "start": false, + "restart": false + } }, "144": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Canadian Tire Motorsports Park", + "iracing": { + "start": false, + "restart": false + } }, "145": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Brands Hatch Circuit - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "146": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Brands Hatch Circuit - Indy", + "iracing": { + "start": false, + "restart": false + } }, "147": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Park Zandvoort - Chicane - 2009", + "iracing": { + "start": false, + "restart": false + } }, "148": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Park Zandvoort - Club - 2009", + "iracing": { + "start": false, + "restart": false + } }, "149": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Park Zandvoort - Grand Prix - 2009", + "iracing": { + "start": false, + "restart": false + } }, "150": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Park Zandvoort - Nationaal - 2009", + "iracing": { + "start": false, + "restart": false + } }, "151": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Park Zandvoort - Oostelijk - 2009", + "iracing": { + "start": false, + "restart": false + } }, "152": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Phillip Island Circuit", + "iracing": { + "start": true, + "restart": false + } }, "153": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Mid-Ohio Sports Car Course - [Retired] Full Course", + "iracing": { + "start": false, + "restart": false + } }, "154": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Mid-Ohio Sports Car Course - [Retired] Chicane", + "iracing": { + "start": false, + "restart": false + } }, "155": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Mid-Ohio Sports Car Course - [Retired] Short", + "iracing": { + "start": false, + "restart": false + } }, "156": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Mid-Ohio Sports Car Course - [Retired] Oval", + "iracing": { + "start": false, + "restart": false + } }, "157": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Mid-Ohio Sports Car Course - [Retired] Alt Oval", + "iracing": { + "start": false, + "restart": false + } }, "158": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] WeatherTech Raceway at Laguna Seca - [Retired] School", + "iracing": { + "start": true, + "restart": true + } }, "159": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Summit Point Raceway - School", + "iracing": { + "start": false, + "restart": false + } }, "160": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Lime Rock Park - 2008 - [Retired] School", + "iracing": { + "start": false, + "restart": false + } }, "163": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Grand Prix Pits - 2010", + "iracing": { + "start": false, + "restart": false + } }, "164": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Classic Pits - 2010", + "iracing": { + "start": false, + "restart": false + } }, "165": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Endurance - 2010", + "iracing": { + "start": false, + "restart": false + } }, "166": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Okayama International Circuit - Full Course", + "iracing": { + "start": false, + "restart": false + } }, "167": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Okayama International Circuit - Short", + "iracing": { + "start": false, + "restart": false + } }, "168": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Suzuka International Racing Course - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "170": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Iowa Speedway - Road Course - 2011", + "iracing": { + "start": false, + "restart": false + } }, "171": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Iowa Speedway - Legends - 2011", + "iracing": { + "start": true, + "restart": true + } }, "172": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Iowa Speedway - Infield Legends - 2011", + "iracing": { + "start": true, + "restart": true + } }, "173": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Suzuka International Racing Course - Moto", + "iracing": { + "start": false, + "restart": false + } }, "174": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Suzuka International Racing Course - East", + "iracing": { + "start": false, + "restart": false + } }, "175": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Suzuka International Racing Course - West", + "iracing": { + "start": true, + "restart": true + } }, "176": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Suzuka International Racing Course - West w/chicane", + "iracing": { + "start": true, + "restart": true + } }, "179": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Long Beach Street Circuit", + "iracing": { + "start": true, + "restart": true + } }, "180": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - International", + "iracing": { + "start": false, + "restart": true + } }, "181": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Fosters", + "iracing": { + "start": false, + "restart": true + } }, "182": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Island", + "iracing": { + "start": false, + "restart": true + } }, "183": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Intl w/out Hislop", + "iracing": { + "start": false, + "restart": true + } }, "184": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Intl w/out Brittens", + "iracing": { + "start": false, + "restart": true + } }, "185": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Intl w/no Chicanes", + "iracing": { + "start": false, + "restart": true + } }, "186": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Fosters w/Hislop", + "iracing": { + "start": false, + "restart": false + } }, "187": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oulton Park Circuit - Island Historic", + "iracing": { + "start": false, + "restart": false + } }, "189": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Kentucky Speedway - 2011 - Legends", + "iracing": { + "start": true, + "restart": true + } }, "192": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Road Course", + "iracing": { + "start": true, + "restart": false + } }, "193": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Moto", + "iracing": { + "start": true, + "restart": false + } }, "194": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - Short", + "iracing": { + "start": true, + "restart": false + } }, "195": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Mobility Resort Motegi - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "196": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Mobility Resort Motegi - East", + "iracing": { + "start": true, + "restart": true + } }, "197": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Mobility Resort Motegi - West", + "iracing": { + "start": true, + "restart": true + } }, "199": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zolder - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "200": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zolder - Alternate", + "iracing": { + "start": true, + "restart": true + } }, "202": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "204": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Rockingham Speedway - Road Course", + "iracing": { + "start": true, + "restart": true + } }, "205": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Rockingham Speedway - Infield Road Course", + "iracing": { + "start": false, + "restart": false + } }, "206": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Rockingham Speedway - Short Road Course", + "iracing": { + "start": false, + "restart": false + } }, "207": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - North", + "iracing": { + "start": false, + "restart": false + } }, "208": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - South", + "iracing": { + "start": false, + "restart": false + } }, "209": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - North A", + "iracing": { + "start": false, + "restart": false + } }, "210": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - North B", + "iracing": { + "start": false, + "restart": false + } }, "211": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Oran Park Raceway - Moto", + "iracing": { + "start": false, + "restart": false + } }, "212": { "poleLeft": true, - "source": "verified" + "source": "verified", + "name": "Autódromo José Carlos Pace - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "213": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autódromo José Carlos Pace - Moto", + "iracing": { + "start": false, + "restart": false + } }, "215": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Kansas Speedway - Road Course", + "iracing": { + "start": false, + "restart": false + } }, "216": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Kansas Speedway - Infield Road Course", + "iracing": { + "start": false, + "restart": false + } }, "217": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Irwindale Speedway - Figure Eight", + "iracing": { + "start": true, + "restart": true + } }, "218": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit Gilles Villeneuve", + "iracing": { + "start": true, + "restart": true + } }, "219": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mount Panorama Circuit", + "iracing": { + "start": true, + "restart": true + } }, "220": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "New Jersey Motorsports Park - Thunderbolt", + "iracing": { + "start": false, + "restart": false + } }, "221": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "New Jersey Motorsports Park - Thunderbolt w/both chicanes", + "iracing": { + "start": false, + "restart": false + } }, "222": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "New Hampshire Motor Speedway - Legends", + "iracing": { + "start": true, + "restart": true + } }, "223": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "New Jersey Motorsports Park - Thunderbolt w/first chicane", + "iracing": { + "start": false, + "restart": false + } }, "224": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "New Jersey Motorsports Park - Thunderbolt w/second chicane", + "iracing": { + "start": false, + "restart": false + } }, "226": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Auto Club Speedway - Competition", + "iracing": { + "start": true, + "restart": true + } }, "227": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Auto Club Speedway - Moto", + "iracing": { + "start": true, + "restart": true + } }, "228": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Auto Club Speedway - Interior", + "iracing": { + "start": true, + "restart": true + } }, "229": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit of the Americas - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "230": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit of the Americas - East", + "iracing": { + "start": true, + "restart": true + } }, "231": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit of the Americas - West", + "iracing": { + "start": true, + "restart": true + } }, "233": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Donington Park Racing Circuit - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "234": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Donington Park Racing Circuit - National", + "iracing": { + "start": true, + "restart": true + } }, "238": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "World Wide Technology Raceway (Gateway) - Road Course", + "iracing": { + "start": true, + "restart": true + } }, "239": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "240": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - Combined without chicanes", + "iracing": { + "start": true, + "restart": true + } }, "241": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - Junior", + "iracing": { + "start": true, + "restart": true + } }, "242": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - GP without chicanes", + "iracing": { + "start": true, + "restart": true + } }, "244": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - GP without first chicane", + "iracing": { + "start": true, + "restart": true + } }, "246": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - Combined without first chicane", + "iracing": { + "start": true, + "restart": true + } }, "247": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Nazionale Monza - Combined", + "iracing": { + "start": true, + "restart": true + } }, "248": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Five Flags Speedway", + "iracing": { + "start": true, + "restart": true + } }, "249": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Nordschleife - Industriefahrten", + "iracing": { + "start": false, + "restart": false + } }, "250": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "252": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Combined - Gesamtstrecke 24h", + "iracing": { + "start": false, + "restart": false + } }, "253": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Nordschleife - Touristenfahrten", + "iracing": { + "start": false, + "restart": false + } }, "255": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - BES/WEC", + "iracing": { + "start": false, + "restart": false + } }, "257": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - Grand Prix w/out Arena", + "iracing": { + "start": false, + "restart": false + } }, "259": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - Sprintstrecke", + "iracing": { + "start": false, + "restart": false + } }, "260": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - Kurzanbindung w/out Arena", + "iracing": { + "start": false, + "restart": false + } }, "261": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Grand-Prix-Strecke - Müllenbachschleife", + "iracing": { + "start": false, + "restart": false + } }, "262": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Combined - Gesamtstrecke VLN", + "iracing": { + "start": false, + "restart": false + } }, "263": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Combined - Gesamtstrecke Short w/out Arena", + "iracing": { + "start": false, + "restart": false + } }, "264": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Nürburgring Combined - Gesamtstrecke Long", + "iracing": { + "start": false, + "restart": false + } }, "266": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Internazionale Enzo e Dino Ferrari - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "267": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Internazionale Enzo e Dino Ferrari - Moto", + "iracing": { + "start": true, + "restart": true + } }, "268": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit des 24 Heures du Mans - 24 Heures du Mans", + "iracing": { + "start": true, + "restart": true + } }, "269": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit des 24 Heures du Mans - Historic", + "iracing": { + "start": true, + "restart": true + } }, "277": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Pocono Raceway", + "iracing": { + "start": true, + "restart": true + } }, "290": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Brands Hatch Circuit - Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "293": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona Rallycross and Dirt Road - Rallycross Long", + "iracing": { + "start": true, + "restart": true + } }, "295": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Iowa Speedway - Rallycross - 2017", + "iracing": { + "start": true, + "restart": true + } }, "296": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona Rallycross and Dirt Road - Rallycross Short", + "iracing": { + "start": true, + "restart": true + } }, "297": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Snetterton Circuit - 300", + "iracing": { + "start": true, + "restart": true + } }, "298": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Snetterton Circuit - 200", + "iracing": { + "start": true, + "restart": true + } }, "299": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Snetterton Circuit - 100", + "iracing": { + "start": true, + "restart": true + } }, "304": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Lucas Oil Indianapolis Raceway Park - Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "306": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Phoenix Raceway - 2008 - Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "312": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "319": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Detroit Grand Prix at Belle Isle - Belle Isle", + "iracing": { + "start": true, + "restart": true + } }, "322": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "EchoPark Speedway (Atlanta) - Rallycross Short", + "iracing": { + "start": false, + "restart": false + } }, "323": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "EchoPark Speedway (Atlanta) - Rallycross Long", + "iracing": { + "start": false, + "restart": false + } }, "324": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 2000 Full", + "iracing": { + "start": true, + "restart": true + } }, "325": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 2000 Moto", + "iracing": { + "start": true, + "restart": true + } }, "326": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 2000 Short", + "iracing": { + "start": true, + "restart": true + } }, "327": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 1000 Full", + "iracing": { + "start": false, + "restart": false + } }, "328": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 1000 Outer", + "iracing": { + "start": false, + "restart": false + } }, "329": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 1000 Chicane", + "iracing": { + "start": false, + "restart": false + } }, "330": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Roval - 2018", + "iracing": { + "start": true, + "restart": true + } }, "332": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Wild West Motorsports Park", + "iracing": { + "start": true, + "restart": true + } }, "333": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Tsukuba Circuit - 1000 Full Reverse", + "iracing": { + "start": true, + "restart": true + } }, "334": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Firebird Motorsports Park", + "iracing": { + "start": true, + "restart": true + } }, "335": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Legends Oval - 2018", + "iracing": { + "start": true, + "restart": true + } }, "336": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Legends RC Long - 2018", + "iracing": { + "start": false, + "restart": false + } }, "337": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Legends RC Medium - 2018", + "iracing": { + "start": false, + "restart": false + } }, "338": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Legends RC Short - 2018", + "iracing": { + "start": false, + "restart": false + } }, "340": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Roval Long - 2018", + "iracing": { + "start": true, + "restart": true + } }, "341": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Silverstone Circuit - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "342": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Silverstone Circuit - International", + "iracing": { + "start": false, + "restart": false + } }, "343": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Silverstone Circuit - National", + "iracing": { + "start": false, + "restart": false + } }, "345": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "346": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - National", + "iracing": { + "start": true, + "restart": true + } }, "347": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - Club", + "iracing": { + "start": true, + "restart": true + } }, "348": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - Moto", + "iracing": { + "start": true, + "restart": true + } }, "349": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - Historic", + "iracing": { + "start": true, + "restart": true + } }, "350": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Roval", + "iracing": { + "start": true, + "restart": true + } }, "352": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lime Rock Park - Classic", + "iracing": { + "start": false, + "restart": false + } }, "353": { "poleLeft": false, - "source": "verified" + "source": "verified", + "name": "Lime Rock Park - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "354": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lime Rock Park - Chicanes", + "iracing": { + "start": false, + "restart": false + } }, "355": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lime Rock Park - West Bend Chicane", + "iracing": { + "start": false, + "restart": false + } }, "358": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Lånkebanen (Hell RX) - Hell Rallycross", + "iracing": { + "start": false, + "restart": false + } }, "359": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lånkebanen (Hell RX) - Club", + "iracing": { + "start": false, + "restart": false + } }, "360": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lånkebanen (Hell RX) - Rallycross Short", + "iracing": { + "start": false, + "restart": false + } }, "361": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lånkebanen (Hell RX) - Road Short", + "iracing": { + "start": false, + "restart": false + } }, "363": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lånkebanen (Hell RX) - Road Long", + "iracing": { + "start": false, + "restart": false + } }, "364": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Texas Motor Speedway - Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "381": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Daytona International Speedway - NASCAR Road", + "iracing": { + "start": true, + "restart": false + } }, "382": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Crandon International Raceway - Full", + "iracing": { + "start": false, + "restart": false + } }, "383": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Crandon International Raceway - Short", + "iracing": { + "start": false, + "restart": false + } }, "384": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "iRacing Superspeedway", + "iracing": { + "start": true, + "restart": true + } }, "385": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Retired] Charlotte Motor Speedway - [Retired] Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "386": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Barcelona Catalunya - Rallycross", + "iracing": { + "start": true, + "restart": true + } }, "388": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Irwindale Speedway - Figure Eight Jump", + "iracing": { + "start": true, + "restart": true + } }, "390": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "391": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - National A", + "iracing": { + "start": true, + "restart": true + } }, "392": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - National B", + "iracing": { + "start": true, + "restart": true + } }, "393": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Short A", + "iracing": { + "start": true, + "restart": true + } }, "394": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Short B", + "iracing": { + "start": true, + "restart": true + } }, "395": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Outer", + "iracing": { + "start": true, + "restart": true + } }, "396": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Bark River International Raceway", + "iracing": { + "start": true, + "restart": true + } }, "397": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - [Retired] Open Wheel 2012-2018", + "iracing": { + "start": true, + "restart": true + } }, "398": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Mount Washington Hillclimb - Hillclimb", + "iracing": { + "start": false, + "restart": false + } }, "399": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mount Washington Hillclimb - Descent", + "iracing": { + "start": false, + "restart": false + } }, "403": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Red Bull Ring - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "404": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Red Bull Ring - National", + "iracing": { + "start": true, + "restart": true + } }, "405": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Chicago Street Course - Prototype", + "iracing": { + "start": false, + "restart": false + } }, "407": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Red Bull Ring - North", + "iracing": { + "start": true, + "restart": true + } }, "413": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hungaroring", + "iracing": { + "start": false, + "restart": false + } }, "423": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - International", + "iracing": { + "start": false, + "restart": false + } }, "424": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - International Reverse", + "iracing": { + "start": false, + "restart": false + } }, "425": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - National", + "iracing": { + "start": false, + "restart": false + } }, "426": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - National Reverse", + "iracing": { + "start": false, + "restart": false + } }, "427": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - Rallycross", + "iracing": { + "start": false, + "restart": false + } }, "428": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Porsche Experience Center - East", + "iracing": { + "start": true, + "restart": true + } }, "429": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Porsche Experience Center - Outer", + "iracing": { + "start": true, + "restart": true + } }, "430": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Porsche Experience Center - West", + "iracing": { + "start": true, + "restart": true + } }, "431": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Hockenheimring Baden-Württemberg - Porsche Experience Center - Handling", + "iracing": { + "start": true, + "restart": true + } }, "432": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Watkins Glen International - Classic", + "iracing": { + "start": false, + "restart": false + } }, "433": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Watkins Glen International - Cup", + "iracing": { + "start": false, + "restart": false + } }, "434": { "poleLeft": false, - "source": "verified" + "source": "verified", + "name": "Watkins Glen International - Boot", + "iracing": { + "start": false, + "restart": false + } }, "435": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Watkins Glen International - Classic Boot", + "iracing": { + "start": false, + "restart": false + } }, "436": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Knockhill Racing Circuit - Tri-Oval", + "iracing": { + "start": false, + "restart": false + } }, "437": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "LA Coliseum Raceway", + "iracing": { + "start": true, + "restart": true + } }, "439": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Winton Motor Raceway - National Circuit", + "iracing": { + "start": false, + "restart": false + } }, "440": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Winton Motor Raceway - Club Circuit", + "iracing": { + "start": false, + "restart": false + } }, "443": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sandown International Motor Raceway", + "iracing": { + "start": true, + "restart": true + } }, "444": { "poleLeft": false, - "source": "verified" + "source": "verified", + "name": "Fuji International Speedway - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "445": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Fuji International Speedway - No Chicane", + "iracing": { + "start": true, + "restart": true + } }, "448": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Indianapolis Motor Speedway - Road Course", + "iracing": { + "start": false, + "restart": false + } }, "449": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Motorsport Arena Oschersleben - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "451": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Rudskogen Motorsenter", + "iracing": { + "start": true, + "restart": true + } }, "452": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Lucas Oil Speedway - Dirt Oval", + "iracing": { + "start": true, + "restart": true + } }, "454": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Motorsport Arena Oschersleben - Alternate", + "iracing": { + "start": false, + "restart": false + } }, "455": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Motorsport Arena Oschersleben - B Course", + "iracing": { + "start": false, + "restart": false + } }, "456": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Motorsport Arena Oschersleben - C Course", + "iracing": { + "start": false, + "restart": false + } }, "463": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Nevers Magny-Cours - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "465": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Virginia International Raceway - Full Course", + "iracing": { + "start": false, + "restart": false + } }, "466": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Virginia International Raceway - Grand Course", + "iracing": { + "start": false, + "restart": false + } }, "467": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Virginia International Raceway - North Course", + "iracing": { + "start": false, + "restart": false + } }, "468": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Virginia International Raceway - South Course", + "iracing": { + "start": false, + "restart": false + } }, "469": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Virginia International Raceway - Patriot Course", + "iracing": { + "start": false, + "restart": false + } }, "470": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona Rallycross and Dirt Road - Dirt Road Long", + "iracing": { + "start": true, + "restart": true + } }, "471": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Daytona Rallycross and Dirt Road - Dirt Road Short", + "iracing": { + "start": true, + "restart": true + } }, "472": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "[Legacy] Phoenix Raceway - 2008 - Dirt Road", + "iracing": { + "start": true, + "restart": true + } }, "473": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuito de Jerez - Ángel Nieto - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "474": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuito de Jerez - Ángel Nieto - Moto", + "iracing": { + "start": false, + "restart": false + } }, "475": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "476": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - National", + "iracing": { + "start": false, + "restart": false + } }, "477": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - West", + "iracing": { + "start": false, + "restart": false + } }, "478": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - Motorcycle Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "479": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - Motorcycle National", + "iracing": { + "start": false, + "restart": false + } }, "480": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - Outer", + "iracing": { + "start": false, + "restart": false + } }, "481": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Willow Springs International Raceway - Big Willow", + "iracing": { + "start": false, + "restart": false + } }, "482": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "MotorLand Aragón - Touring Car", + "iracing": { + "start": false, + "restart": false + } }, "483": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Chicago Street Course - 2023 Cup", + "iracing": { + "start": false, + "restart": false + } }, "485": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zandvoort - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "486": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zandvoort - Grand Prix w/chicane", + "iracing": { + "start": false, + "restart": false + } }, "487": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zandvoort - Nationaal", + "iracing": { + "start": false, + "restart": false + } }, "488": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit Zandvoort - Oostelijk", + "iracing": { + "start": false, + "restart": false + } }, "489": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Lédenon", + "iracing": { + "start": false, + "restart": false + } }, "496": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Kevin Harvick's Kern Raceway - Legends", + "iracing": { + "start": true, + "restart": true + } }, "497": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Kevin Harvick's Kern Raceway - Dirt Mini Oval", + "iracing": { + "start": true, + "restart": true + } }, "498": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Internazionale del Mugello - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "499": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autodromo Internazionale del Mugello - Short", + "iracing": { + "start": false, + "restart": false + } }, "501": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Misano World Circuit Marco Simoncelli - Grand Prix", + "iracing": { + "start": false, + "restart": false + } }, "502": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Misano World Circuit Marco Simoncelli - Grand Prix - 2007", + "iracing": { + "start": false, + "restart": false + } }, "503": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Misano World Circuit Marco Simoncelli - Grand Prix - Historic Reverse", + "iracing": { + "start": false, + "restart": false + } }, "504": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Misano World Circuit Marco Simoncelli - Club", + "iracing": { + "start": true, + "restart": true + } }, "505": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Misano World Circuit Marco Simoncelli - Truck", + "iracing": { + "start": false, + "restart": false + } }, "506": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Slinger Speedway - Figure 8", + "iracing": { + "start": true, + "restart": true + } }, "508": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Lime Rock Park - School", + "iracing": { + "start": false, + "restart": false + } }, "509": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Algarve International Circuit - Grand Prix", + "iracing": { + "start": true, + "restart": true + } }, "510": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Algarve International Circuit - Grand Prix - Chicanes", + "iracing": { + "start": true, + "restart": true + } }, "511": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Algarve International Circuit - Moto", + "iracing": { + "start": true, + "restart": true + } }, "512": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Algarve International Circuit - Moto - Chicanes", + "iracing": { + "start": true, + "restart": true + } }, "515": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuito de Navarra - Speed Circuit", + "iracing": { + "start": false, + "restart": true + } }, "516": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuito de Navarra - Speed Circuit - Medium", + "iracing": { + "start": false, + "restart": true + } }, "517": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuito de Navarra - Speed Circuit - Short", + "iracing": { + "start": true, + "restart": true + } }, "521": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Sachsenring", + "iracing": { + "start": false, + "restart": false + } }, "523": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Grand Prix Pits", + "iracing": { + "start": false, + "restart": false + } }, "524": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Classic Pits", + "iracing": { + "start": false, + "restart": false + } }, "525": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Endurance", + "iracing": { + "start": false, + "restart": false + } }, "526": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Circuit de Spa-Francorchamps - Bike", + "iracing": { + "start": false, + "restart": false + } }, "527": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Cadwell Park Circuit - Full", + "iracing": { + "start": true, + "restart": true + } }, "528": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Cadwell Park Circuit - Club", + "iracing": { + "start": false, + "restart": false + } }, "529": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Cadwell Park Circuit - Moto", + "iracing": { + "start": true, + "restart": true + } }, "530": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Cadwell Park Circuit - Woodlands", + "iracing": { + "start": true, + "restart": true + } }, "532": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Thruxton Circuit", + "iracing": { + "start": false, + "restart": false + } }, "533": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Miami International Autodrome - Marina Loop", + "iracing": { + "start": true, + "restart": false + } }, "534": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Miami International Autodrome - MIA Loop", + "iracing": { + "start": true, + "restart": false + } }, "535": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Miami International Autodrome - Extended MIA Loop", + "iracing": { + "start": true, + "restart": false + } }, "536": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Portland International Raceway - Full Circuit", + "iracing": { + "start": true, + "restart": false + } }, "537": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Portland International Raceway - No Chicane", + "iracing": { + "start": true, + "restart": false + } }, "538": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Miami International Autodrome - Extended Marina Loop", + "iracing": { + "start": true, + "restart": false + } }, "539": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Miami International Autodrome - Grand Prix", + "iracing": { + "start": true, + "restart": false + } }, "540": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - GT Circuit", + "iracing": { + "start": true, + "restart": false + } }, "541": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - International Circuit", + "iracing": { + "start": true, + "restart": false + } }, "542": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - East Circuit", + "iracing": { + "start": true, + "restart": false + } }, "543": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - West Circuit", + "iracing": { + "start": true, + "restart": false + } }, "544": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - West Plus Circuit", + "iracing": { + "start": true, + "restart": false + } }, "545": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - Sprint Circuit", + "iracing": { + "start": true, + "restart": false + } }, "546": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Shell V-Power Motorsport Park at The Bend - South West Circuit", + "iracing": { + "start": true, + "restart": false + } }, "550": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Legends Road Course 2025", + "iracing": { + "start": false, + "restart": false + } }, "551": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Infield Road Course Long", + "iracing": { + "start": false, + "restart": false + } }, "552": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Infield Road Course", + "iracing": { + "start": false, + "restart": false + } }, "553": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Roval 2019", + "iracing": { + "start": false, + "restart": false + } }, "554": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Roval 2025", + "iracing": { + "start": false, + "restart": true + } }, "555": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Roval No Chicanes", + "iracing": { + "start": false, + "restart": false + } }, "557": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Legends Oval", + "iracing": { + "start": true, + "restart": true + } }, "558": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Rallyx", + "iracing": { + "start": true, + "restart": true + } }, "563": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Indycar 2005-2007", + "iracing": { + "start": true, + "restart": true + } }, "564": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Indycar 2008-2011", + "iracing": { + "start": true, + "restart": true + } }, "565": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Indycar 2012-2018", + "iracing": { + "start": true, + "restart": true + } }, "566": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Cup Long", + "iracing": { + "start": true, + "restart": true + } }, "567": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Cup Short", + "iracing": { + "start": true, + "restart": true + } }, "568": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Oval", + "iracing": { + "start": true, + "restart": true + } }, "569": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Short", + "iracing": { + "start": true, + "restart": true + } }, "570": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Sportscar", + "iracing": { + "start": true, + "restart": true + } }, "571": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Sonoma Raceway - Sportscar Alt", + "iracing": { + "start": true, + "restart": true + } }, "572": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - Grand Prix", + "iracing": { + "start": true, + "restart": false + } }, "573": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - Grand Prix Historic", + "iracing": { + "start": true, + "restart": false + } }, "574": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - National", + "iracing": { + "start": true, + "restart": false + } }, "575": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - National Historic", + "iracing": { + "start": true, + "restart": false + } }, "576": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - EV Circuit", + "iracing": { + "start": true, + "restart": false + } }, "577": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Autódromo Hermanos Rodríguez - Oval", + "iracing": { + "start": true, + "restart": true + } }, "579": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Circuit of the Americas - NASCAR West", + "iracing": { + "start": true, + "restart": true + } }, "580": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Adelaide Street Circuit", + "iracing": { + "start": true, + "restart": true + } }, "582": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Winton Motor Raceway - Rallycross", + "iracing": { + "start": false, + "restart": false + } }, "584": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "St. Petersburg Grand Prix - Grand Prix", + "iracing": { + "start": true, + "restart": false + } }, "585": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Barber Motorsports Park", + "iracing": { + "start": true, + "restart": true + } }, "586": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "WeatherTech Raceway at Laguna Seca - 2026", + "iracing": { + "start": true, + "restart": true + } }, "587": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Charlotte Motor Speedway - Dirt Road", + "iracing": { + "start": true, + "restart": true + } }, "588": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mid-Ohio Sports Car Course - Full Course", + "iracing": { + "start": true, + "restart": true + } }, "589": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Qualcomm Circuit (Naval Base Coronado)", + "iracing": { + "start": true, + "restart": false + } }, "590": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mid-Ohio Sports Car Course - Chicane", + "iracing": { + "start": true, + "restart": true + } }, "591": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mid-Ohio Sports Car Course - Short", + "iracing": { + "start": true, + "restart": true + } }, "592": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Mid-Ohio Sports Car Course - Oval", + "iracing": { + "start": false, + "restart": false + } }, "593": { "poleLeft": true, - "source": "geometry" + "source": "geometry", + "name": "Mid-Ohio Sports Car Course - Alt Oval", + "iracing": { + "start": false, + "restart": false + } }, "595": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "The Milwaukee Mile - Road", + "iracing": { + "start": false, + "restart": false + } }, "596": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road America - Full Course", + "iracing": { + "start": true, + "restart": false + } }, "597": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road America - Bend", + "iracing": { + "start": true, + "restart": false + } }, "598": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road America - Short East", + "iracing": { + "start": true, + "restart": false + } }, "599": { "poleLeft": false, - "source": "geometry" + "source": "geometry", + "name": "Road America - Short West", + "iracing": { + "start": true, + "restart": false + } } }, "trackNames": { diff --git a/src/types/radarPoleSides.spec.ts b/src/types/radarPoleSides.spec.ts index ffc05cbe1..0c240091b 100644 --- a/src/types/radarPoleSides.spec.ts +++ b/src/types/radarPoleSides.spec.ts @@ -61,6 +61,39 @@ describe('parseRadarPoleSides', () => { expect(parsed.trackNames).toEqual({ a: true }); }); + it('keeps the name and iRacing hints when well formed', () => { + const parsed = parseRadarPoleSides({ + version: 1, + tracks: { + '1': { + poleLeft: true, + source: 'geometry', + name: 'Sebring - International', + iracing: { start: true, restart: false }, + }, + '2': { + poleLeft: false, + source: 'geometry', + name: 42, + iracing: { start: true }, + }, + '3': { + poleLeft: false, + source: 'geometry', + name: 'x'.repeat(500), + }, + }, + }); + expect(parsed.tracks['1']).toEqual({ + poleLeft: true, + source: 'geometry', + name: 'Sebring - International', + iracing: { start: true, restart: false }, + }); + expect(parsed.tracks['2']).toEqual({ poleLeft: false, source: 'geometry' }); + expect(parsed.tracks['3']).toEqual({ poleLeft: false, source: 'geometry' }); + }); + it('reads a source it does not know as geometry, not as a missing entry', () => { const parsed = parseRadarPoleSides({ version: 1, diff --git a/src/types/radarPoleSides.ts b/src/types/radarPoleSides.ts index 422c24fa7..0c5a1175e 100644 --- a/src/types/radarPoleSides.ts +++ b/src/types/radarPoleSides.ts @@ -14,29 +14,51 @@ import poleSidesData from './radarPoleSides.json'; */ export type PoleSideSource = 'geometry' | 'verified' | 'reported'; +/** iRacing's own start and restart flags for a track: a hint, never the answer. */ +export interface IracingPoleHint { + start: boolean; + restart: boolean; +} + export interface RadarPoleSides { /** Bumped when the shape changes, so a newer file is never half-read. */ version: 1; /** By iRacing `WeekendInfo.TrackID`. */ tracks: Readonly< - Record + Record< + string, + { + poleLeft: boolean; + source: PoleSideSource; + /** Track and layout, for people reading the file. */ + name?: string; + iracing?: IracingPoleHint; + } + > >; /** By `WeekendInfo.TrackName`, for tracks without a usable id entry. */ trackNames: Readonly>; } +/** Longer names are not a track's; they are dropped. */ +const MAX_NAME = 120; + /** Keeps what is well formed and drops the rest; never throws. */ export const parseRadarPoleSides = (raw: unknown): RadarPoleSides => { const empty: RadarPoleSides = { version: 1, tracks: {}, trackNames: {} }; if (!raw || typeof raw !== 'object') return empty; const data = raw as Record; if (data.version !== 1) return empty; - const tracks: Record = - {}; + const tracks: Record = {}; for (const [id, entry] of Object.entries( (data.tracks ?? {}) as Record )) { - const value = entry as { poleLeft?: unknown; source?: unknown } | null; + const value = entry as { + poleLeft?: unknown; + source?: unknown; + name?: unknown; + iracing?: { start?: unknown; restart?: unknown } | null; + } | null; if ( /^\d+$/.test(id) && typeof value?.poleLeft === 'boolean' && @@ -50,6 +72,15 @@ export const parseRadarPoleSides = (raw: unknown): RadarPoleSides => { value.source === 'verified' || value.source === 'reported' ? value.source : 'geometry', + ...(typeof value.name === 'string' && + value.name.length <= MAX_NAME && { name: value.name }), + ...(typeof value.iracing?.start === 'boolean' && + typeof value.iracing.restart === 'boolean' && { + iracing: { + start: value.iracing.start, + restart: value.iracing.restart, + }, + }), }; } } diff --git a/tools/generate-radar-pole-sides.ts b/tools/generate-radar-pole-sides.ts index 649c9f200..e15e62411 100644 --- a/tools/generate-radar-pole-sides.ts +++ b/tools/generate-radar-pole-sides.ts @@ -11,7 +11,13 @@ import { readFileSync, writeFileSync, existsSync } from 'fs'; * ("reported") and the `trackNames` exceptions are * kept as they are; everything else is recomputed. * - * Regenerate with: npm run generate-radar-pole-sides + * Pass iRacing's track catalogue (the /data/track/get response saved as JSON) + * to add each track's name and iRacing's own start_on_left / restart_on_left + * flags. They are only hints: they matched what drivers saw on 4 of 7 + * tracks, so they never decide the side. Without the file, hints already in + * radarPoleSides.json are kept. + * + * Regenerate with: npm run generate-radar-pole-sides [-- ] */ const TRACKS_FILE = './src/frontend/domain/track/tracks/tracks.json'; @@ -41,8 +47,41 @@ interface Drawing { interface Entry { poleLeft: boolean; source: 'geometry' | 'verified' | 'reported'; + /** Track and layout, for people reading the file. */ + name?: string; + /** iRacing's own flags for this track: a hint, not the answer. */ + iracing?: { start: boolean; restart: boolean }; +} + +interface CatalogueTrack { + track_id?: number; + track_name?: string; + config_name?: string; + start_on_left?: boolean; + restart_on_left?: boolean; } +/** Name and flags per track id from iRacing's track catalogue. */ +const readCatalogue = (file: string | undefined) => { + const hints = new Map>(); + if (!file) return hints; + const rows = JSON.parse(readFileSync(file, 'utf8')) as CatalogueTrack[]; + for (const row of rows) { + if (typeof row.track_id !== 'number') continue; + const name = [row.track_name, row.config_name] + .filter((part): part is string => typeof part === 'string' && !!part) + .join(' - '); + hints.set(String(row.track_id), { + ...(name && { name }), + ...(typeof row.start_on_left === 'boolean' && + typeof row.restart_on_left === 'boolean' && { + iracing: { start: row.start_on_left, restart: row.restart_on_left }, + }), + }); + } + return hints; +}; + /** Points along the lap from the start/finish line, in the direction of travel. */ const lapPoints = (drawing: Drawing) => { const { trackPathPoints: points, totalLength } = drawing.active; @@ -91,6 +130,7 @@ export const firstCornerIsLeft = (drawing: Drawing): boolean | null => { }; const main = () => { + const catalogue = readCatalogue(process.argv[2]); const drawings = JSON.parse(readFileSync(TRACKS_FILE, 'utf8')) as Record< string, Drawing @@ -127,7 +167,15 @@ const main = () => { } } const sorted = Object.fromEntries( - Object.entries(tracks).sort(([a], [b]) => Number(a) - Number(b)) + Object.entries(tracks) + .map(([id, entry]): [string, Entry] => { + const { name, iracing } = catalogue.get(id) ?? entry; + return [ + id, + { ...entry, ...(name && { name }), ...(iracing && { iracing }) }, + ]; + }) + .sort(([a], [b]) => Number(a) - Number(b)) ); const output = { version: 1, From 79f5112166db759030b849d5df2b2e1e39a7ea3a Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 11:50:21 +0300 Subject: [PATCH 11/32] feat: report the player's radar pole side corrections, per start and restart Corrections are now kept per situation (race start or restart), with the side the table gave at the time. At startup, after the pit lane report, a changed set is sent once as a radar_pole_side PostHog event (track ids and booleans only), acknowledged before the hash is recorded, honouring the analytics opt-out. The About page says what is sent. Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.radarPoleSide.spec.ts | 145 ++++++++++++++++++ src/app/analytics.ts | 59 +++++++ src/app/analytics/radarPoleSideReport.spec.ts | 57 +++++++ src/app/analytics/radarPoleSideReport.ts | 63 ++++++++ src/app/processors/RadarProcessor.spec.ts | 38 ++++- src/app/processors/RadarProcessor.ts | 33 +++- src/app/processors/radarPoleSideSettings.ts | 25 ++- src/app/radarPoleSideOverrides.spec.ts | 98 ++++++++---- src/app/radarPoleSideOverrides.ts | 104 ++++++++++--- .../Settings/sections/AboutSettings.tsx | 10 ++ 10 files changed, 571 insertions(+), 61 deletions(-) create mode 100644 src/app/analytics.radarPoleSide.spec.ts create mode 100644 src/app/analytics/radarPoleSideReport.spec.ts create mode 100644 src/app/analytics/radarPoleSideReport.ts diff --git a/src/app/analytics.radarPoleSide.spec.ts b/src/app/analytics.radarPoleSide.spec.ts new file mode 100644 index 000000000..099e2d827 --- /dev/null +++ b/src/app/analytics.radarPoleSide.spec.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EventMessage, IdentifyMessage } from 'posthog-node'; +import type { DashboardLayout } from '@irdashies/types'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync } from 'node:fs'; + +const { userDataPath, store } = vi.hoisted(() => ({ + userDataPath: { value: '' }, + store: { data: new Map() }, +})); + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => userDataPath.value) }, + screen: { + getAllDisplays: vi.fn().mockReturnValue([]), + getPrimaryDisplay: vi + .fn() + .mockReturnValue({ size: { width: 0, height: 0 } }), + }, +})); + +vi.mock('electron-log/main', () => ({ + default: { + transports: { file: {}, console: {} }, + initialize: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('./storage/storage', () => ({ + readData: vi.fn((key: string) => store.data.get(key)), + writeData: vi.fn((key: string, value: unknown) => store.data.set(key, value)), +})); + +vi.mock('./storage/analytics', () => ({ + getAnalyticsOptOut: vi.fn().mockReturnValue(false), +})); + +const { corrections } = vi.hoisted(() => ({ + corrections: { value: {} as Record }, +})); +vi.mock('./storage/radarPoleSideOverrides', () => ({ + radarPoleSideOverrides: { snapshot: () => corrections.value }, +})); + +import { Analytics } from './analytics'; +import { getAnalyticsOptOut } from './storage/analytics'; + +const HASH_KEY = 'radarPoleSideReportHash'; + +const makeProvider = () => ({ + capture: vi.fn<(event: EventMessage) => void>(), + captureConfirmed: vi + .fn<(event: EventMessage) => Promise>() + .mockResolvedValue(undefined), + identify: vi.fn<(message: IdentifyMessage) => void>(), + shutdown: vi.fn<(shutdownTimeoutMs?: number) => void>(), +}); + +describe('Analytics radar pole side report', () => { + let provider: ReturnType; + + const start = async (version = '2.0.0') => { + const analytics = new Analytics(provider); + await analytics.init(version, {} as DashboardLayout); + await analytics.waitForRadarPoleSideUpload(); + }; + const reports = () => + provider.captureConfirmed.mock.calls + .map(([event]) => event) + .filter((event) => event.event === 'radar_pole_side'); + + beforeEach(() => { + userDataPath.value = mkdtempSync(path.join(os.tmpdir(), 'irdashies-')); + store.data.clear(); + corrections.value = {}; + provider = makeProvider(); + vi.mocked(getAnalyticsOptOut).mockReturnValue(false); + }); + + it('sends nothing for an installation that never corrected a side', async () => { + await start(); + expect(reports()).toEqual([]); + }); + + it('reports the corrections once, then remembers what was acknowledged', async () => { + corrections.value = { '95': { start: false, table: true } }; + await start(); + + expect(reports()).toHaveLength(1); + expect(reports()[0]).toMatchObject({ + event: 'radar_pole_side', + properties: { + version: '2.0.0', + track_count: 1, + tracks: { '95': { start_left: false, table_left: true } }, + }, + }); + expect(store.data.get(HASH_KEY)).toBe(reports()[0].properties?.data_hash); + + await start('3.0.0'); + expect(reports()).toHaveLength(1); + }); + + it('reports a change, and a revert to nothing', async () => { + corrections.value = { '95': { start: false, table: true } }; + await start(); + corrections.value = { + '95': { start: false, restart: false, table: true }, + }; + await start(); + corrections.value = {}; + await start(); + + expect(reports()).toHaveLength(3); + expect(reports()[2].properties?.track_count).toBe(0); + }); + + it('does not record a hash when delivery fails, so it is retried', async () => { + corrections.value = { '95': { start: false, table: true } }; + provider.captureConfirmed.mockRejectedValueOnce(new Error('down')); + await expect(start()).resolves.toBeUndefined(); + expect(store.data.get(HASH_KEY)).toBeUndefined(); + + await start(); + expect(store.data.get(HASH_KEY)).toBeDefined(); + }); + + it('respects the analytics opt-out', async () => { + corrections.value = { '95': { start: false, table: true } }; + vi.mocked(getAnalyticsOptOut).mockReturnValue(true); + await start(); + expect(reports()).toEqual([]); + }); + + it('ignores a stored value that is not a hash', async () => { + store.data.set(HASH_KEY, 'not-a-hash'); + await start(); + expect(reports()).toEqual([]); + }); +}); diff --git a/src/app/analytics.ts b/src/app/analytics.ts index 29253d109..62ae94669 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -10,6 +10,11 @@ import logger from './logger'; import type ElectronLog from 'electron-log'; import type { LogMessage } from 'electron-log'; import { readPitLaneCalibrationForAnalytics } from './analytics/pitLaneCalibration'; +import { + MAX_POLE_SIDE_PROPERTIES_BYTES, + buildRadarPoleSideReport, +} from './analytics/radarPoleSideReport'; +import { radarPoleSideOverrides } from './storage/radarPoleSideOverrides'; import { readPitLaneAnalyticsCheckpoint, writePitLaneAnalyticsCheckpoint, @@ -17,6 +22,9 @@ import { declare const POSTHOG_KEY: string; +/** Hash of the last radar pole side report PostHog acknowledged. */ +const POLE_SIDE_HASH_KEY = 'radarPoleSideReportHash'; + const DEDUP_WINDOW_MS = 60 * 60 * 1000; const DEDUP_KEY_LENGTH = 30; const DEDUP_MAX_ENTRIES = 5000; @@ -80,6 +88,7 @@ export class Analytics { private provider: AnalyticsProvider | null = null; private pitLaneCalibrationAttempted = false; private pitLaneCalibrationTask: Promise | undefined; + private radarPoleSideTask: Promise | undefined; constructor(provider?: AnalyticsProvider) { if (provider) { @@ -214,6 +223,56 @@ export class Analytics { ).catch(() => { logger.warn('[Analytics] Could not publish pit lane calibration'); }); + // After the pit lane report, so startup does one thing at a time. + this.radarPoleSideTask ??= this.pitLaneCalibrationTask.then(() => + this.captureRadarPoleSides(userId, version) + ); + } + + waitForRadarPoleSideUpload(): Promise { + return this.radarPoleSideTask ?? Promise.resolve(); + } + + /** + * Reports the pole sides the player corrected with the radar key, once per + * change, in the background. A revert to the table is reported too, but + * nothing is sent for an installation that never corrected anything. + */ + private async captureRadarPoleSides( + userId: string, + version: string + ): Promise { + try { + if (!this.provider || getAnalyticsOptOut()) return; + const report = buildRadarPoleSideReport( + radarPoleSideOverrides.snapshot(), + version + ); + const stored = readData(POLE_SIDE_HASH_KEY); + const last = + typeof stored === 'string' && /^[0-9a-f]{64}$/.test(stored) + ? stored + : undefined; + if (report.properties.track_count === 0 && last === undefined) return; + if (last === report.hash) return; + if ( + Buffer.byteLength(JSON.stringify(report.properties), 'utf8') > + MAX_POLE_SIDE_PROPERTIES_BYTES + ) { + logger.warn('[Analytics] Skipping oversized radar pole side report'); + return; + } + await this.provider.captureConfirmed({ + distinctId: userId, + event: 'radar_pole_side', + uuid: crypto.randomUUID(), + properties: report.properties, + }); + // Recorded only after PostHog acknowledged it, so a failure retries. + if (!getAnalyticsOptOut()) writeData(POLE_SIDE_HASH_KEY, report.hash); + } catch { + logger.warn('[Analytics] Could not publish radar pole side report'); + } } waitForCalibrationUpload(): Promise { diff --git a/src/app/analytics/radarPoleSideReport.spec.ts b/src/app/analytics/radarPoleSideReport.spec.ts new file mode 100644 index 000000000..7161e744e --- /dev/null +++ b/src/app/analytics/radarPoleSideReport.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { buildRadarPoleSideReport } from './radarPoleSideReport'; + +describe('buildRadarPoleSideReport', () => { + const overrides = { + '444': { restart: true, table: false }, + '95': { start: false, table: true }, + }; + + it('sends only ids and sides, by track', () => { + const report = buildRadarPoleSideReport(overrides, '2.0.0'); + expect(report.properties).toEqual({ + version: '2.0.0', + data_hash: report.hash, + track_count: 2, + tracks: { + '95': { start_left: false, table_left: true }, + '444': { restart_left: true, table_left: false }, + }, + }); + expect(report.hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('hashes the same corrections the same way, whatever the order or version', () => { + const reordered = { + '95': { table: true, start: false }, + '444': { table: false, restart: true }, + }; + expect(buildRadarPoleSideReport(reordered, '3.1.0').hash).toBe( + buildRadarPoleSideReport(overrides, '2.0.0').hash + ); + }); + + it('hashes a changed side, a changed table side and a different situation apart', () => { + const base = buildRadarPoleSideReport(overrides, '2.0.0').hash; + const flipped = { ...overrides, '95': { start: true, table: false } }; + const otherTable = { ...overrides, '95': { start: false, table: false } }; + const otherSituation = { + ...overrides, + '95': { restart: false, table: true }, + }; + expect(buildRadarPoleSideReport(flipped, '2.0.0').hash).not.toBe(base); + expect(buildRadarPoleSideReport(otherTable, '2.0.0').hash).not.toBe(base); + expect(buildRadarPoleSideReport(otherSituation, '2.0.0').hash).not.toBe( + base + ); + }); + + it('reports no corrections as an empty set, with a hash of its own', () => { + const empty = buildRadarPoleSideReport({}, '2.0.0'); + expect(empty.properties.track_count).toBe(0); + expect(empty.properties.tracks).toEqual({}); + expect(empty.hash).not.toBe( + buildRadarPoleSideReport(overrides, '2.0.0').hash + ); + }); +}); diff --git a/src/app/analytics/radarPoleSideReport.ts b/src/app/analytics/radarPoleSideReport.ts new file mode 100644 index 000000000..eebd3da88 --- /dev/null +++ b/src/app/analytics/radarPoleSideReport.ts @@ -0,0 +1,63 @@ +import crypto from 'node:crypto'; +import type { PoleSideOverrides } from '../radarPoleSideOverrides'; + +/** Leave headroom for SDK metadata below PostHog's 1 MB event limit. */ +export const MAX_POLE_SIDE_PROPERTIES_BYTES = 512 * 1024; + +export interface RadarPoleSideReport { + /** Identifies the corrections, whatever order or formatting they came in. */ + hash: string; + properties: { + version: string; + data_hash: string; + track_count: number; + /** + * By iRacing track id: the side (true: left) the player chose for a race + * start and a restart, and what the bundled table said at the time. + */ + tracks: Record< + string, + { start_left?: boolean; restart_left?: boolean; table_left: boolean } + >; + }; +} + +/** + * Only track ids and booleans leave the installation: no names, paths or + * profile data. The hash ignores key order and the app version, so the same + * corrections are never reported twice and a later revert still is. + */ +export const buildRadarPoleSideReport = ( + overrides: PoleSideOverrides, + version: string +): RadarPoleSideReport => { + const ids = Object.keys(overrides).sort((a, b) => Number(a) - Number(b)); + const canonical = ids.map((id) => [ + id, + overrides[id].start ?? null, + overrides[id].restart ?? null, + overrides[id].table, + ]); + const hash = crypto + .createHash('sha256') + .update(JSON.stringify(canonical)) + .digest('hex'); + const tracks: RadarPoleSideReport['properties']['tracks'] = {}; + for (const id of ids) { + const { start, restart, table } = overrides[id]; + tracks[id] = { + ...(start !== undefined && { start_left: start }), + ...(restart !== undefined && { restart_left: restart }), + table_left: table, + }; + } + return { + hash, + properties: { + version, + data_hash: hash, + track_count: ids.length, + tracks, + }, + }; +}; diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 3d6a094ff..c56eaec98 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -416,7 +416,8 @@ describe('RadarProcessor', () => { it('follows the pole side the player set, and says so for a moment', () => { const player: { poleLeft?: boolean } = {}; const poleSides = { - override: () => player.poleLeft, + override: (_trackId: number | undefined, situation: string) => + situation === 'start' ? player.poleLeft : undefined, setTrack: vi.fn(), }; const session = structuredClone(rollingSession) as unknown as Session; @@ -424,7 +425,7 @@ describe('RadarProcessor', () => { session.WeekendInfo.TrackName = 'limerock 2019 gp'; const processor = new RadarProcessor(undefined, poleSides); processor.init(session); - expect(poleSides.setTrack).toHaveBeenCalledWith(353, false); + expect(poleSides.setTrack).toHaveBeenCalledWith(353, false, 'start'); const base = rollingTelemetry as unknown as Record< string, @@ -457,6 +458,39 @@ describe('RadarProcessor', () => { expect(processor.snapshot().poleSideNotice).toBeNull(); }); + it('keeps the start and the restart apart, saying nothing when only the situation changes', () => { + const player: { restartLeft?: boolean } = { restartLeft: true }; + const poleSides = { + override: (_trackId: number | undefined, situation: string) => + situation === 'restart' ? player.restartLeft : undefined, + setTrack: vi.fn(), + }; + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + session.WeekendInfo.TrackName = 'limerock 2019 gp'; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(session); + + const frame = (time: number, paceMode: number) => + ({ + ...rollingTelemetry, + SessionTime: { value: [time] }, + PaceMode: { value: [paceMode] }, + }) as unknown as Telemetry; + + // A rolling start: no correction, the table's right-hand pole. + processor.onFrame(frame(100, 1)); + expect(poleSides.setTrack).toHaveBeenLastCalledWith(353, false, 'start'); + + // The restart has its own correction; nobody pressed anything. + processor.onFrame(frame(101, 3)); + expect(poleSides.setTrack).toHaveBeenLastCalledWith(353, false, 'restart'); + expect(processor.snapshot().poleSideNotice).toBeNull(); + expect( + processor.snapshot().cars.some((car) => car.laneSource === 'pace') + ).toBe(true); + }); + it('puts the pole line on the left on a super speedway', () => { const processor = new RadarProcessor(); const session = rollingSession as unknown as Session; diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 2b878d11a..070d59783 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -27,6 +27,7 @@ import { RadarHazardTracker } from './radarHazards'; import { NO_POLE_SIDE_SETTINGS, type PoleSideSettings, + type PoleSituation, } from './radarPoleSideSettings'; import { wrapDelta } from './radarMath'; @@ -129,6 +130,8 @@ export class RadarProcessor implements TelemetryProcessor { /** The side the table gives, and the side in use (the player's wins). */ private tablePoleLeft = false; private poleLeft = false; + /** A race start or a restart: the player corrects each on its own. */ + private situation: PoleSituation = 'start'; /** Session time until which the radar says which side it flipped to. */ private poleNoticeUntil = -Infinity; @@ -154,8 +157,11 @@ export class RadarProcessor implements TelemetryProcessor { session?.WeekendInfo?.TrackName, trackType !== '' && !/road|rally/i.test(trackType) ); - this.poleSides.setTrack(this.trackId, this.tablePoleLeft); - this.poleLeft = this.poleSides.override(this.trackId) ?? this.tablePoleLeft; + this.situation = 'start'; + this.poleSides.setTrack(this.trackId, this.tablePoleLeft, this.situation); + this.poleLeft = + this.poleSides.override(this.trackId, this.situation) ?? + this.tablePoleLeft; this.lanes.setPacePoleLeft(this.poleLeft); this.poleNoticeUntil = -Infinity; const options = session?.WeekendInfo?.WeekendOptions; @@ -178,7 +184,10 @@ export class RadarProcessor implements TelemetryProcessor { const pcts = valuesOf(frame, 'CarIdxLapDistPct'); const time = scalarNumber(frame, 'SessionTime'); this.updateSpeeds(pcts, time); - this.applyPoleSide(time); + this.applyPoleSide( + time, + scalarNumber(frame, 'PaceMode', PaceMode.NotPacing) + ); const camCarIdx = scalarNumber(frame, 'CamCarIdx'); const focus = @@ -365,12 +374,24 @@ export class RadarProcessor implements TelemetryProcessor { * Follows the player's own pole side for this track, which a keybinding can * change at any time, and says so on the radar for a few seconds. */ - private applyPoleSide(time: number): void { - const side = this.poleSides.override(this.trackId) ?? this.tablePoleLeft; + private applyPoleSide(time: number, paceMode: number): void { + const situation: PoleSituation = + paceMode === PaceMode.SingleFileRestart || + paceMode === PaceMode.DoubleFileRestart + ? 'restart' + : 'start'; + const changedSituation = situation !== this.situation; + if (changedSituation) { + this.situation = situation; + this.poleSides.setTrack(this.trackId, this.tablePoleLeft, situation); + } + const side = + this.poleSides.override(this.trackId, situation) ?? this.tablePoleLeft; if (side === this.poleLeft) return; this.poleLeft = side; this.lanes.setPacePoleLeft(side); - this.poleNoticeUntil = time + POLE_NOTICE_S; + // Only a key press is worth a message, not the situation changing. + if (!changedSituation) this.poleNoticeUntil = time + POLE_NOTICE_S; } snapshot(): RadarSnapshot { diff --git a/src/app/processors/radarPoleSideSettings.ts b/src/app/processors/radarPoleSideSettings.ts index 25aad5779..3e1052677 100644 --- a/src/app/processors/radarPoleSideSettings.ts +++ b/src/app/processors/radarPoleSideSettings.ts @@ -1,12 +1,29 @@ +/** + * Which kind of pace field the radar is looking at: the race start, or a + * restart after a caution. iRacing keeps a separate pole side flag for each, + * so a player's correction is kept per situation too. + */ +export type PoleSituation = 'start' | 'restart'; + /** * What the radar needs to know about the player's own pole side choice. A * processor does no I/O, so this is injected; the app backs it with a file. */ export interface PoleSideSettings { - /** The side the player set for this track (true: left), if they did. */ - override(trackId: number | undefined): boolean | undefined; - /** The radar reports the track it is on and the side the table gives. */ - setTrack(trackId: number | undefined, tableLeft: boolean): void; + /** The side the player set for this track and situation (true: left). */ + override( + trackId: number | undefined, + situation: PoleSituation + ): boolean | undefined; + /** + * The radar reports the track and situation it is on, and the side the + * table gives, whenever either changes. + */ + setTrack( + trackId: number | undefined, + tableLeft: boolean, + situation: PoleSituation + ): void; } export const NO_POLE_SIDE_SETTINGS: PoleSideSettings = { diff --git a/src/app/radarPoleSideOverrides.spec.ts b/src/app/radarPoleSideOverrides.spec.ts index 0b1a4fb6b..4e15abdc7 100644 --- a/src/app/radarPoleSideOverrides.spec.ts +++ b/src/app/radarPoleSideOverrides.spec.ts @@ -22,62 +22,106 @@ describe('parsePoleSideOverrides', () => { expect(parsePoleSideOverrides(raw)).toEqual({}); }); - it('keeps canonical ids with a boolean and drops the rest', () => { + it('keeps canonical ids with a table side and a correction', () => { expect( parsePoleSideOverrides({ - '95': true, - '0': true, - '012': true, - abc: true, - '7': 'left', - '8': null, - '444': false, + '95': { start: false, table: true }, + '212': { restart: true, table: false }, + '0': { start: true, table: true }, + '012': { start: true, table: true }, + abc: { start: true, table: true }, + '7': { start: 'left', table: true }, + '8': { table: true }, + '9': { start: true }, + '10': null, }) - ).toEqual({ '95': true, '444': false }); + ).toEqual({ + '95': { start: false, table: true }, + '212': { restart: true, table: false }, + }); }); }); describe('RadarPoleSideOverrides', () => { it('has no override until one is flipped, or without a track id', () => { const { overrides } = store(); - expect(overrides.override(95)).toBeUndefined(); - expect(overrides.override(undefined)).toBeUndefined(); + expect(overrides.override(95, 'start')).toBeUndefined(); + expect(overrides.override(undefined, 'start')).toBeUndefined(); }); - it('flips to the side opposite the table and remembers it', () => { + it('flips to the side opposite the table, for the situation, and remembers it', () => { const { overrides, write, saved } = store(); - overrides.setTrack(95, true); + overrides.setTrack(95, true, 'start'); - expect(overrides.flip()).toEqual({ trackId: 95, poleLeft: false }); - expect(overrides.override(95)).toBe(false); + expect(overrides.flip()).toEqual({ + trackId: 95, + situation: 'start', + poleLeft: false, + }); + expect(overrides.override(95, 'start')).toBe(false); + expect(overrides.override(95, 'restart')).toBeUndefined(); expect(write).toHaveBeenCalledTimes(1); - expect(saved()).toEqual({ '95': false }); + expect(saved()).toEqual({ '95': { start: false, table: true } }); + }); + + it('keeps a start and a restart correction apart', () => { + const { overrides, saved } = store(); + overrides.setTrack(95, true, 'start'); + overrides.flip(); + overrides.setTrack(95, true, 'restart'); + overrides.flip(); + + expect(saved()).toEqual({ + '95': { start: false, restart: false, table: true }, + }); + + // Back to the table for the restart only. + overrides.flip(); + expect(saved()).toEqual({ '95': { start: false, table: true } }); }); it('removes the entry when flipped back to what the table says', () => { const { overrides, saved } = store(); - overrides.setTrack(95, true); + overrides.setTrack(95, true, 'start'); overrides.flip(); - expect(overrides.flip()).toEqual({ trackId: 95, poleLeft: true }); - expect(overrides.override(95)).toBeUndefined(); + expect(overrides.flip()).toEqual({ + trackId: 95, + situation: 'start', + poleLeft: true, + }); + expect(overrides.override(95, 'start')).toBeUndefined(); expect(saved()).toEqual({}); }); - it('starts from a saved override and keeps other tracks', () => { - const { overrides, saved } = store({ '95': false, '212': true }); - overrides.setTrack(212, false); + it('starts from a saved correction and keeps other tracks', () => { + const { overrides, saved } = store({ + '95': { start: false, table: true }, + '212': { start: true, table: false }, + }); + overrides.setTrack(212, false, 'start'); + + // The table says right, the player has left; flipping gives right again. + expect(overrides.flip()).toEqual({ + trackId: 212, + situation: 'start', + poleLeft: false, + }); + expect(saved()).toEqual({ '95': { start: false, table: true } }); + }); - // The table says right (false), the player has left; flipping gives right. - expect(overrides.flip()).toEqual({ trackId: 212, poleLeft: false }); - expect(saved()).toEqual({ '95': false }); + it('gives a copy of the corrections for the report', () => { + const { overrides } = store({ '95': { start: false, table: true } }); + const copy = overrides.snapshot(); + copy['95'].start = true; + expect(overrides.override(95, 'start')).toBe(false); }); it('does nothing before a track is known', () => { const { overrides, write } = store(); expect(overrides.flip()).toBeUndefined(); - overrides.setTrack(undefined, true); - overrides.setTrack(-3, true); + overrides.setTrack(undefined, true, 'start'); + overrides.setTrack(-3, true, 'start'); expect(overrides.flip()).toBeUndefined(); expect(write).not.toHaveBeenCalled(); }); diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts index d24b2b901..69c59e2b3 100644 --- a/src/app/radarPoleSideOverrides.ts +++ b/src/app/radarPoleSideOverrides.ts @@ -1,7 +1,20 @@ -import type { PoleSideSettings } from './processors/radarPoleSideSettings'; +import type { + PoleSideSettings, + PoleSituation, +} from './processors/radarPoleSideSettings'; -/** iRacing track id (as a string) -> pole line on the left. */ -export type PoleSideOverrides = Record; +/** + * One track's corrections: the side (true: left) the player chose for a race + * start and for a restart, and the side the table gave when they chose it. + */ +export interface PoleSideCorrection { + start?: boolean; + restart?: boolean; + table: boolean; +} + +/** iRacing track id (as a string) -> the player's corrections there. */ +export type PoleSideOverrides = Record; /** The player can only correct so many tracks; a bigger file is not ours. */ const MAX_OVERRIDES = 10000; @@ -11,26 +24,42 @@ export interface PoleSidePersistence { write(overrides: PoleSideOverrides): void; } -/** The stored file is untrusted: keep canonical ids with a boolean side. */ +/** The stored file is untrusted: keep canonical ids with well formed sides. */ export const parsePoleSideOverrides = (raw: unknown): PoleSideOverrides => { const overrides: PoleSideOverrides = {}; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return overrides; - for (const [id, poleLeft] of Object.entries(raw).slice(0, MAX_OVERRIDES)) { - if (/^[1-9]\d{0,9}$/.test(id) && typeof poleLeft === 'boolean') { - overrides[id] = poleLeft; + for (const [id, entry] of Object.entries(raw).slice(0, MAX_OVERRIDES)) { + const value = entry as Partial>; + if ( + !/^[1-9]\d{0,9}$/.test(id) || + !value || + typeof value !== 'object' || + typeof value.table !== 'boolean' + ) { + continue; + } + const correction: PoleSideCorrection = { table: value.table }; + if (typeof value.start === 'boolean') correction.start = value.start; + if (typeof value.restart === 'boolean') correction.restart = value.restart; + if (correction.start !== undefined || correction.restart !== undefined) { + overrides[id] = correction; } } return overrides; }; /** - * The player's own pole side per track. Flipping the side the radar uses - * stores the opposite of it; flipping back to what the table says removes the - * entry, so only real differences are kept. + * The player's own pole side per track and situation. Flipping the side the + * radar uses stores the opposite of it; flipping back to what the table says + * removes it, so only real differences are kept. */ export class RadarPoleSideOverrides implements PoleSideSettings { private overrides?: PoleSideOverrides; - private track?: { trackId: number; tableLeft: boolean }; + private track?: { + trackId: number; + tableLeft: boolean; + situation: PoleSituation; + }; constructor(private readonly persistence: PoleSidePersistence) {} @@ -39,38 +68,69 @@ export class RadarPoleSideOverrides implements PoleSideSettings { return this.overrides; } - override(trackId: number | undefined): boolean | undefined { + override( + trackId: number | undefined, + situation: PoleSituation + ): boolean | undefined { if (trackId === undefined) return undefined; const overrides = this.load(); const key = String(trackId); - return Object.hasOwn(overrides, key) ? overrides[key] : undefined; + return Object.hasOwn(overrides, key) + ? overrides[key][situation] + : undefined; } - setTrack(trackId: number | undefined, tableLeft: boolean): void { + setTrack( + trackId: number | undefined, + tableLeft: boolean, + situation: PoleSituation + ): void { this.track = trackId !== undefined && Number.isInteger(trackId) && trackId > 0 - ? { trackId, tableLeft } + ? { trackId, tableLeft, situation } : undefined; } + /** What the player has corrected so far, for the analytics report. */ + snapshot(): PoleSideOverrides { + return structuredClone(this.load()); + } + /** - * Swaps the side in use on the current track. Returns the new side, or - * undefined when no track is known yet. + * Swaps the side in use on the current track and situation. Returns the new + * side, or undefined when no track is known yet. */ - flip(): { trackId: number; poleLeft: boolean } | undefined { + flip(): + | { trackId: number; situation: PoleSituation; poleLeft: boolean } + | undefined { if (!this.track) return undefined; - const { trackId, tableLeft } = this.track; + const { trackId, tableLeft, situation } = this.track; const key = String(trackId); const stored = this.load(); - const current = Object.hasOwn(stored, key) ? stored[key] : tableLeft; + const current = this.override(trackId, situation) ?? tableLeft; const poleLeft = !current; // Only a real difference from the table is kept. + const before = Object.hasOwn(stored, key) ? stored[key] : undefined; + const sideOf = (which: PoleSituation) => + which === situation + ? poleLeft === tableLeft + ? undefined + : poleLeft + : before?.[which]; + const start = sideOf('start'); + const restart = sideOf('restart'); const overrides: PoleSideOverrides = Object.fromEntries( Object.entries(stored).filter(([id]) => id !== key) ); - if (poleLeft !== tableLeft) overrides[key] = poleLeft; + if (start !== undefined || restart !== undefined) { + overrides[key] = { + ...(start !== undefined && { start }), + ...(restart !== undefined && { restart }), + table: tableLeft, + }; + } this.persistence.write(overrides); this.overrides = overrides; - return { trackId, poleLeft }; + return { trackId, situation, poleLeft }; } } diff --git a/src/frontend/components/Settings/sections/AboutSettings.tsx b/src/frontend/components/Settings/sections/AboutSettings.tsx index 2480ff12a..ed5efeb8c 100644 --- a/src/frontend/components/Settings/sections/AboutSettings.tsx +++ b/src/frontend/components/Settings/sections/AboutSettings.tsx @@ -108,6 +108,16 @@ export const AboutSettings = () => { startup when calibration changes to help improve pit-lane detection. +
  • + + Radar pole-side corrections + +
    + Track IDs and the pole side you chose for a race start or a + restart with the radar key, together with the side the + built-in table gave, are sent at startup when your corrections + change, to improve the table for everyone. +
  • System information
    From ff1b6afc78594546f7b88d577ab434474393eeb5 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 11:53:46 +0300 Subject: [PATCH 12/32] test: mock the pole side storage in the paused tape bridge spec Co-Authored-By: Claude Sonnet 5.5 --- src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts index 7e0d8d966..9e8fed26c 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts @@ -38,6 +38,9 @@ vi.mock('../../storage/referenceLaps', () => ({ saveReferenceLap: vi.fn(), })); vi.mock('../../storage/radarTuning', () => ({ getRadarTuning: vi.fn() })); +vi.mock('../../storage/radarPoleSideOverrides', () => ({ + radarPoleSideOverrides: {}, +})); vi.mock('../../perfMetrics', () => ({ TelemetryPerfMetrics: class { startReporting = vi.fn(); From 2616a5a6d335bd74ac6f20982b4cc864d1ac0dee Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 12:10:54 +0300 Subject: [PATCH 13/32] fix: the road shifts to the middle of a two-wide formation, and follows a pole side flip In a formation we sit in one column, so the middle of the road lies half a lane to one side. The snapshot now carries that offset (roadLane) from the formation's column count, our column and the pole side in use, and the radar draws the road there instead of always under our car. Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 40 +++++++++++++++++ src/app/processors/RadarProcessor.ts | 8 +++- src/app/processors/radarFormation.spec.ts | 10 +++++ src/app/processors/radarFormation.ts | 44 ++++++++++++++++--- src/app/processors/radarLanes.ts | 2 +- src/frontend/components/Radar/Radar.tsx | 2 + .../Radar/components/RadarDisplay.tsx | 3 ++ src/frontend/components/Radar/radarDraw.ts | 22 ++++++++-- .../components/Radar/radarDrawRoad.spec.ts | 14 ++++++ .../components/Radar/radarDrawTypes.ts | 2 + src/types/channels/channel.ts | 6 +++ 11 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 src/frontend/components/Radar/radarDrawRoad.spec.ts diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index c56eaec98..e22874b1c 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -491,6 +491,46 @@ describe('RadarProcessor', () => { ).toBe(true); }); + it('puts the middle of the road half a lane over, on the other side after a flip', () => { + const player: { poleLeft?: boolean } = {}; + const poleSides = { + override: () => player.poleLeft, + setTrack: vi.fn(), + }; + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + session.WeekendInfo.TrackName = 'limerock 2019 gp'; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(session); + const base = rollingTelemetry as unknown as Record< + string, + { value: unknown[] } + >; + const lines = base.CarIdxPaceLine.value as number[]; + const myLine = lines[base.PlayerCarIdx.value[0] as number]; + const at = (time: number) => + ({ + ...rollingTelemetry, + SessionTime: { value: [time] }, + }) as unknown as Telemetry; + + // Pole line on the right (the table's side here): in the pole line we sit + // right of the middle, so the road lies to our left; in the other, right. + processor.onFrame(at(100)); + const before = processor.snapshot().roadLane; + expect(before).toBe(myLine === 0 ? -0.5 : 0.5); + + player.poleLeft = true; + processor.onFrame(at(101)); + expect(processor.snapshot().roadLane).toBe(-before); + }); + + it('has no road offset outside a formation', () => { + const processor = new RadarProcessor(); + processor.init(rollingSession as unknown as Session); + expect(processor.snapshot().roadLane).toBe(0); + }); + it('puts the pole line on the left on a super speedway', () => { const processor = new RadarProcessor(); const session = rollingSession as unknown as Session; diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 070d59783..1d752e299 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -13,7 +13,7 @@ import { type RadarProcessorTuning, } from '@irdashies/types'; import type { TelemetryProcessor } from './TelemetryProcessor'; -import { RadarLaneTracker, separateInLaneCars } from './radarLanes'; +import { RadarLaneTracker, poleSign, separateInLaneCars } from './radarLanes'; import { computeFormation, paceFieldPoleLeft, @@ -316,6 +316,10 @@ export class RadarProcessor implements TelemetryProcessor { follow, focusBrake: drivingFocus && brake >= 0 ? brake : null, caution: (flags & CAUTION_FLAGS) !== 0, + roadLane: formation + ? poleSign(formation.kind, this.poleLeft) * + ((formation.columns - 1) / 2 - formation.ownColumn) + : 0, poleSideNotice: time < this.poleNoticeUntil ? (this.poleLeft ? 'left' : 'right') : null, cars, @@ -447,6 +451,7 @@ const emptySnapshot = (version: number): RadarSnapshot => ({ focusBrake: null, caution: false, poleSideNotice: null, + roadLane: 0, cars: [], hazards: [], version, @@ -472,6 +477,7 @@ const sameSnapshot = ( previous.focusBrake === next.focusBrake && previous.caution === next.caution && previous.poleSideNotice === next.poleSideNotice && + previous.roadLane === next.roadLane && previous.trackLength === next.trackLength && previous.focusOnPitRoad === next.focusOnPitRoad && previous.focusInPitBox === next.focusInPitBox && diff --git a/src/app/processors/radarFormation.spec.ts b/src/app/processors/radarFormation.spec.ts index 8f980b172..57e6300a4 100644 --- a/src/app/processors/radarFormation.spec.ts +++ b/src/app/processors/radarFormation.spec.ts @@ -179,6 +179,11 @@ describe('computeFormation: rolling start (recorded parade lap)', () => { expect(fromFront?.followCarIdx).toBe(input.paceCarIdx); }); + it('knows how many columns there are and which one is ours', () => { + expect(formation?.columns).toBe(2); + expect(formation?.ownColumn).toBe(input.paceLines[input.focus]); + }); + it('puts everyone in our lane when pacing single file', () => { const single = computeFormation({ ...input, @@ -187,6 +192,8 @@ describe('computeFormation: rolling start (recorded parade lap)', () => { expect([...(single?.slots.values() ?? [])].every((s) => s === 0)).toBe( true ); + expect(single?.columns).toBe(1); + expect(single?.ownColumn).toBe(0); }); it('ignores a pace mode with no lines assigned', () => { @@ -213,6 +220,9 @@ describe('computeFormation: standing start (recorded grid)', () => { expect(formation?.slots.get(10)).toBe(-1); expect(formation?.slots.get(7)).toBe(0); expect(formation?.followCarIdx).toBeNull(); + // Two columns, and we are in the second. + expect(formation?.columns).toBe(2); + expect(formation?.ownColumn).toBe(1); }); it('counts grid boxes, not cars, past an empty box', () => { diff --git a/src/app/processors/radarFormation.ts b/src/app/processors/radarFormation.ts index 0e3281d72..23d9c17b8 100644 --- a/src/app/processors/radarFormation.ts +++ b/src/app/processors/radarFormation.ts @@ -106,6 +106,9 @@ export interface Formation { * the pole side. Multiply by the pole side's sign to get a lane. */ slots: Map; + /** How many columns the formation has, and which is ours (0: pole side). */ + columns: number; + ownColumn: number; /** The car to line up behind while pacing; null when not pacing. */ followCarIdx: number | null; } @@ -188,7 +191,13 @@ const pacingFormation = (input: FormationInput): Formation | null => { } } if (myRow === 0 && input.paceCarIdx >= 0) followCarIdx = input.paceCarIdx; - return { kind: 'pace', slots, followCarIdx }; + return { + kind: 'pace', + slots, + columns: double ? 2 : 1, + ownColumn: double ? Math.min(Math.max(myLine, 0), 1) : 0, + followCarIdx, + }; }; /** @@ -220,7 +229,7 @@ const positionSlots = ( focus: number, columns: number, gridPositions: ReadonlyMap | undefined -): Map | null => { +): { slots: Map; ownColumn: number } | null => { if (!gridPositions) return null; const positions = gridded.map((carIdx) => gridPositions.get(carIdx)); // Every car placed, front to back in position order; else the boxes decide. @@ -236,7 +245,7 @@ const positionSlots = ( gridded.forEach((carIdx, rank) => { if (carIdx !== focus) slots.set(carIdx, columnOf(rank) - myColumn); }); - return slots; + return { slots, ownColumn: myColumn }; }; const gridFormation = (input: FormationInput): Formation | null => { @@ -275,8 +284,15 @@ const gridFormation = (input: FormationInput): Formation | null => { columns, input.gridPositions ); - if (byPosition) - return { kind: 'grid', slots: byPosition, followCarIdx: null }; + if (byPosition) { + return { + kind: 'grid', + slots: byPosition.slots, + columns, + ownColumn: byPosition.ownColumn, + followCarIdx: null, + }; + } const gaps = gridded .slice(1) @@ -284,7 +300,15 @@ const gridFormation = (input: FormationInput): Formation | null => { const steps = boxSteps(gaps); // Columns we cannot trust are worse than none: the field still counts as // on the grid, but lanes come from the spotter alone. - if (!steps) return { kind: 'grid', slots: new Map(), followCarIdx: null }; + if (!steps) { + return { + kind: 'grid', + slots: new Map(), + columns: 1, + ownColumn: 0, + followCarIdx: null, + }; + } const boxes: number[] = [0]; steps.forEach((step, index) => boxes.push(boxes[index] + step)); @@ -294,5 +318,11 @@ const gridFormation = (input: FormationInput): Formation | null => { gridded.forEach((carIdx, rank) => { if (carIdx !== focus) slots.set(carIdx, columnOf(rank) - myColumn); }); - return { kind: 'grid', slots, followCarIdx: null }; + return { + kind: 'grid', + slots, + columns, + ownColumn: myColumn, + followCarIdx: null, + }; }; diff --git a/src/app/processors/radarLanes.ts b/src/app/processors/radarLanes.ts index 7c5ba84a8..d6a044dd5 100644 --- a/src/app/processors/radarLanes.ts +++ b/src/app/processors/radarLanes.ts @@ -71,7 +71,7 @@ export type FormationKind = 'grid' | 'pace'; * what its grids do. +1 means the pole is on the left, so higher columns lie * to the right. */ -const poleSign = (kind: FormationKind, pacePoleLeft: boolean): number => +export const poleSign = (kind: FormationKind, pacePoleLeft: boolean): number => kind === 'grid' || pacePoleLeft ? 1 : -1; export interface LaneFormation { diff --git a/src/frontend/components/Radar/Radar.tsx b/src/frontend/components/Radar/Radar.tsx index 4f2aa9c91..904046b4d 100644 --- a/src/frontend/components/Radar/Radar.tsx +++ b/src/frontend/components/Radar/Radar.tsx @@ -44,6 +44,7 @@ const EMPTY_SNAPSHOT: RadarSnapshot = { focusBrake: null, caution: false, poleSideNotice: null, + roadLane: 0, cars: [], hazards: [], version: 0, @@ -162,6 +163,7 @@ export const Radar = () => { // From pit road the cars on track are none of our business. hazards: snapshot.focusOnPitRoad ? [] : snapshot.hazards, poleSideNotice: snapshot.poleSideNotice, + roadLane: snapshot.roadLane, }, [demoFrame, snapshot, cars] ); diff --git a/src/frontend/components/Radar/components/RadarDisplay.tsx b/src/frontend/components/Radar/components/RadarDisplay.tsx index 1ff34a954..1f4c1edaa 100644 --- a/src/frontend/components/Radar/components/RadarDisplay.tsx +++ b/src/frontend/components/Radar/components/RadarDisplay.tsx @@ -37,6 +37,8 @@ export interface RadarFrame { hazards?: readonly RadarHazard[]; /** The pole side the player just flipped to, shown for a few seconds. */ poleSideNotice?: 'left' | 'right' | null; + /** Middle of the road from our car, in lanes, positive to the right. */ + roadLane?: number; } export interface RadarDiveOptions { @@ -272,6 +274,7 @@ export const RadarDisplay = memo( laneWidth, hazards, poleSideNotice: snap.poleSideNotice, + roadLane: snap.roadLane, }); frameMs = performance.now() - started; if (active) handle = requestAnimationFrame(paint); diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index 404b4683a..91fc1107b 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -43,15 +43,24 @@ export { hazardWanted } from './radarDrawHazards'; const ROAD_OVERSCAN = 1.5; const ROAD_STEP_M = 1; +/** + * Metres from the road's centreline to the middle of the road as seen from + * our car (positive to the left): in a two-wide formation we sit in one + * column, so the road lies half a lane over. + */ +export const roadLateral = (roadLane: number, laneWidth: number): number => + -roadLane * laneWidth; + const drawRoad = ( ctx: CanvasRenderingContext2D, style: RadarStyle, - pixelsPerMetre: number + pixelsPerMetre: number, + lateral: number ) => { const reach = style.range * ROAD_OVERSCAN; ctx.beginPath(); for (let dist = -reach; dist <= reach; dist += ROAD_STEP_M) { - projector.project(dist, 0, pose); + projector.project(dist, lateral, pose); if (dist === -reach) ctx.moveTo(pose.x, pose.y); else ctx.lineTo(pose.x, pose.y); } @@ -245,7 +254,14 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { resetRimWarnings(); resetDebugLabels(); - if (style.showTrackMap) drawRoad(ctx, style, pixelsPerMetre); + if (style.showTrackMap) { + drawRoad( + ctx, + style, + pixelsPerMetre, + roadLateral(scene.roadLane ?? 0, scene.laneWidth ?? style.carWidth + 0.7) + ); + } if (style.showCrosshair) { drawCrosshair(ctx, centre, radius, style, pixelsPerMetre, scene.travelled); } diff --git a/src/frontend/components/Radar/radarDrawRoad.spec.ts b/src/frontend/components/Radar/radarDrawRoad.spec.ts new file mode 100644 index 000000000..4a695e51a --- /dev/null +++ b/src/frontend/components/Radar/radarDrawRoad.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { roadLateral } from './radarDraw'; + +describe('roadLateral', () => { + it('is centred on us outside a formation', () => { + expect(roadLateral(0, 2.6)).toBe(-0); + }); + + it('puts a road lying to our right on the negative (right) side', () => { + // Lateral is positive to the left, a lane count positive to the right. + expect(roadLateral(0.5, 2.6)).toBeCloseTo(-1.3); + expect(roadLateral(-0.5, 2.6)).toBeCloseTo(1.3); + }); +}); diff --git a/src/frontend/components/Radar/radarDrawTypes.ts b/src/frontend/components/Radar/radarDrawTypes.ts index b505c1252..653763d8b 100644 --- a/src/frontend/components/Radar/radarDrawTypes.ts +++ b/src/frontend/components/Radar/radarDrawTypes.ts @@ -151,4 +151,6 @@ export interface RadarScene { hazards?: readonly RadarDrawHazard[]; /** Names the pole side the player just flipped to, for a few seconds. */ poleSideNotice?: 'left' | 'right' | null; + /** Middle of the road from our car, in lanes, positive to the right. */ + roadLane?: number; } diff --git a/src/types/channels/channel.ts b/src/types/channels/channel.ts index 42f200645..4323043ed 100644 --- a/src/types/channels/channel.ts +++ b/src/types/channels/channel.ts @@ -218,6 +218,12 @@ export interface RadarSnapshot { * side now in use, so the radar can confirm the key press. */ poleSideNotice: 'left' | 'right' | null; + /** + * Where the middle of the road lies from the focus car, in lanes, positive + * to the right: in a two-wide formation we sit in one column, so the road + * is half a lane over. 0 outside formations. + */ + roadLane: number; /** Only cars within the processor's range, nearest first. */ cars: readonly RadarCar[]; /** From 55b922411ac5330301b1675e9263bf402114b3d2 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 12:26:28 +0300 Subject: [PATCH 14/32] feat: report radar pole side corrections a minute after the last flip The report no longer waits for the next launch: a flip schedules it after a minute of quiet, so a burst of key presses is one report. If the app closes first, the next launch still sends it, since the acknowledged hash is only recorded after delivery. Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.radarPoleSide.spec.ts | 60 +++++++++++++++++++++++-- src/app/analytics.ts | 32 +++++++++++++ src/app/radarPoleSideOverrides.spec.ts | 15 +++++++ src/app/radarPoleSideOverrides.ts | 11 +++++ 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/app/analytics.radarPoleSide.spec.ts b/src/app/analytics.radarPoleSide.spec.ts index 099e2d827..14da1514a 100644 --- a/src/app/analytics.radarPoleSide.spec.ts +++ b/src/app/analytics.radarPoleSide.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EventMessage, IdentifyMessage } from 'posthog-node'; import type { DashboardLayout } from '@irdashies/types'; import os from 'node:os'; @@ -40,11 +40,18 @@ vi.mock('./storage/analytics', () => ({ getAnalyticsOptOut: vi.fn().mockReturnValue(false), })); -const { corrections } = vi.hoisted(() => ({ +const { corrections, flipped } = vi.hoisted(() => ({ corrections: { value: {} as Record }, + flipped: { listeners: new Set<() => void>() }, })); vi.mock('./storage/radarPoleSideOverrides', () => ({ - radarPoleSideOverrides: { snapshot: () => corrections.value }, + radarPoleSideOverrides: { + snapshot: () => corrections.value, + onChange: (listener: () => void) => { + flipped.listeners.add(listener); + return () => flipped.listeners.delete(listener); + }, + }, })); import { Analytics } from './analytics'; @@ -78,6 +85,7 @@ describe('Analytics radar pole side report', () => { userDataPath.value = mkdtempSync(path.join(os.tmpdir(), 'irdashies-')); store.data.clear(); corrections.value = {}; + flipped.listeners.clear(); provider = makeProvider(); vi.mocked(getAnalyticsOptOut).mockReturnValue(false); }); @@ -142,4 +150,50 @@ describe('Analytics radar pole side report', () => { await start(); expect(reports()).toEqual([]); }); + + describe('after a flip', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const flip = () => flipped.listeners.forEach((listener) => listener()); + + it('reports a minute after the last flip, not at once and not once per press', async () => { + await start(); + expect(reports()).toEqual([]); + + corrections.value = { '95': { start: false, table: true } }; + flip(); + await vi.advanceTimersByTimeAsync(30_000); + flip(); + await vi.advanceTimersByTimeAsync(59_000); + expect(reports()).toEqual([]); + + await vi.advanceTimersByTimeAsync(2_000); + expect(reports()).toHaveLength(1); + expect(reports()[0].properties?.track_count).toBe(1); + expect(store.data.get(HASH_KEY)).toBeDefined(); + }); + + it('sends nothing when the flips cancelled out and nothing was ever sent', async () => { + await start(); + flip(); + await vi.advanceTimersByTimeAsync(61_000); + expect(reports()).toEqual([]); + }); + + it('stops its timer when the app shuts down', async () => { + const analytics = new Analytics(provider); + await analytics.init('2.0.0', {} as DashboardLayout); + await analytics.waitForRadarPoleSideUpload(); + corrections.value = { '95': { start: false, table: true } }; + flip(); + analytics.shutdown(); + await vi.advanceTimersByTimeAsync(120_000); + expect(reports()).toEqual([]); + }); + }); }); diff --git a/src/app/analytics.ts b/src/app/analytics.ts index 62ae94669..2d239dfa4 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -22,6 +22,12 @@ import { declare const POSTHOG_KEY: string; +/** + * How long after the last flip the radar pole side report goes out, so a + * burst of key presses is one report and nothing waits for the next launch. + */ +const POLE_SIDE_REPORT_DELAY_MS = 60 * 1000; + /** Hash of the last radar pole side report PostHog acknowledged. */ const POLE_SIDE_HASH_KEY = 'radarPoleSideReportHash'; @@ -89,6 +95,8 @@ export class Analytics { private pitLaneCalibrationAttempted = false; private pitLaneCalibrationTask: Promise | undefined; private radarPoleSideTask: Promise | undefined; + private radarPoleSideTimer: ReturnType | undefined; + private watchingRadarPoleSides = false; constructor(provider?: AnalyticsProvider) { if (provider) { @@ -131,6 +139,7 @@ export class Analytics { } shutdown(shutdownTimeoutMs?: number): void { + clearTimeout(this.radarPoleSideTimer); if (this.provider) { this.provider.shutdown(shutdownTimeoutMs); } @@ -227,6 +236,29 @@ export class Analytics { this.radarPoleSideTask ??= this.pitLaneCalibrationTask.then(() => this.captureRadarPoleSides(userId, version) ); + this.reportRadarPoleSidesAfterFlips(userId, version); + } + + /** + * A flip is reported a minute after the last one rather than at the next + * launch. If the app closes first, the launch still sends it, since the + * acknowledged hash was never recorded. + */ + private reportRadarPoleSidesAfterFlips( + userId: string, + version: string + ): void { + if (this.watchingRadarPoleSides) return; + this.watchingRadarPoleSides = true; + radarPoleSideOverrides.onChange(() => { + clearTimeout(this.radarPoleSideTimer); + this.radarPoleSideTimer = setTimeout(() => { + this.radarPoleSideTask = ( + this.radarPoleSideTask ?? Promise.resolve() + ).then(() => this.captureRadarPoleSides(userId, version)); + }, POLE_SIDE_REPORT_DELAY_MS); + this.radarPoleSideTimer.unref?.(); + }); } waitForRadarPoleSideUpload(): Promise { diff --git a/src/app/radarPoleSideOverrides.spec.ts b/src/app/radarPoleSideOverrides.spec.ts index 4e15abdc7..24b64b790 100644 --- a/src/app/radarPoleSideOverrides.spec.ts +++ b/src/app/radarPoleSideOverrides.spec.ts @@ -117,6 +117,21 @@ describe('RadarPoleSideOverrides', () => { expect(overrides.override(95, 'start')).toBe(false); }); + it('tells listeners once per saved flip, until they stop listening', () => { + const { overrides } = store(); + const listener = vi.fn(); + const stop = overrides.onChange(listener); + overrides.setTrack(95, true, 'start'); + + overrides.flip(); + overrides.flip(); + expect(listener).toHaveBeenCalledTimes(2); + + stop(); + overrides.flip(); + expect(listener).toHaveBeenCalledTimes(2); + }); + it('does nothing before a track is known', () => { const { overrides, write } = store(); expect(overrides.flip()).toBeUndefined(); diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts index 69c59e2b3..67eee9c32 100644 --- a/src/app/radarPoleSideOverrides.ts +++ b/src/app/radarPoleSideOverrides.ts @@ -61,8 +61,18 @@ export class RadarPoleSideOverrides implements PoleSideSettings { situation: PoleSituation; }; + private readonly listeners = new Set<() => void>(); + constructor(private readonly persistence: PoleSidePersistence) {} + /** Called after a flip was saved; returns the way to stop listening. */ + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private load(): PoleSideOverrides { this.overrides ??= parsePoleSideOverrides(this.persistence.read()); return this.overrides; @@ -131,6 +141,7 @@ export class RadarPoleSideOverrides implements PoleSideSettings { } this.persistence.write(overrides); this.overrides = overrides; + this.listeners.forEach((listener) => listener()); return { trackId, situation, poleLeft }; } } From 6b219449bc68ca9777e80e3a217c75e769ff5634 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 12:30:41 +0300 Subject: [PATCH 15/32] feat: the pole side key and a note on it in the Radar settings Right under the page description: the radar may draw the lanes on the wrong side at a start or restart; the key flips the side on this track and remembers it for future races. The same binding is on the Key Bindings page. Co-Authored-By: Claude Sonnet 5.5 --- .../Settings/sections/KeybindingsSettings.tsx | 2 +- .../Settings/sections/RadarSettings.tsx | 4 ++ .../RadarSettings/RadarPoleSideKey.spec.tsx | 60 +++++++++++++++++++ .../RadarSettings/RadarPoleSideKey.tsx | 47 +++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.spec.tsx create mode 100644 src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx diff --git a/src/frontend/components/Settings/sections/KeybindingsSettings.tsx b/src/frontend/components/Settings/sections/KeybindingsSettings.tsx index 3b155e6c0..cbb964b2d 100644 --- a/src/frontend/components/Settings/sections/KeybindingsSettings.tsx +++ b/src/frontend/components/Settings/sections/KeybindingsSettings.tsx @@ -322,7 +322,7 @@ interface BindingRowProps { onUpdated: (bindings: KeybindingsMap) => void; } -const BindingRow = ({ actionId, entry, onUpdated }: BindingRowProps) => ( +export const BindingRow = ({ actionId, entry, onUpdated }: BindingRowProps) => (
    {entry.label} diff --git a/src/frontend/components/Settings/sections/RadarSettings.tsx b/src/frontend/components/Settings/sections/RadarSettings.tsx index 09e11db31..de6eb28e6 100644 --- a/src/frontend/components/Settings/sections/RadarSettings.tsx +++ b/src/frontend/components/Settings/sections/RadarSettings.tsx @@ -19,6 +19,7 @@ import { SettingToggleRow } from '../components/SettingToggleRow'; import { ToggleSwitch } from '../components/ToggleSwitch'; import { ARC_STYLES } from './RadarSettings/ArcControls'; import { RadarPreview } from './RadarSettings/RadarPreview'; +import { RadarPoleSideKey } from './RadarSettings/RadarPoleSideKey'; import { RADAR_PRESETS, RADAR_SECTIONS, @@ -365,6 +366,9 @@ export const RadarSettings = () => { return (
    +
    + +
    diff --git a/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.spec.tsx b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.spec.tsx new file mode 100644 index 000000000..d533f563e --- /dev/null +++ b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.spec.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { KeybindingsMap } from '@irdashies/types'; +import { DEFAULT_KEYBINDINGS } from '@irdashies/types'; +import { RadarPoleSideKey } from './RadarPoleSideKey'; + +const withBridge = (getKeybindings: () => Promise) => { + Object.defineProperty(window, 'keybindingsBridge', { + configurable: true, + writable: true, + value: { + getKeybindings, + updateKeybinding: vi.fn(), + resetKeybinding: vi.fn(), + startRecording: vi.fn(), + stopRecording: vi.fn(), + onGamepadCaptured: () => () => undefined, + }, + }); +}; + +describe('RadarPoleSideKey', () => { + afterEach(() => { + Object.defineProperty(window, 'keybindingsBridge', { + configurable: true, + writable: true, + value: undefined, + }); + }); + + it('explains the key and shows its binding row', async () => { + withBridge(() => Promise.resolve({ ...DEFAULT_KEYBINDINGS })); + render(); + + expect( + screen.getByText(/may draw the lanes on the wrong side/) + ).toBeInTheDocument(); + expect( + screen.getByText(/remembers it for future races/) + ).toBeInTheDocument(); + expect( + await screen.findByText('Radar: Flip Pole Side') + ).toBeInTheDocument(); + }); + + it('still explains it when the bridge is missing', () => { + render(); + expect( + screen.getByText(/may draw the lanes on the wrong side/) + ).toBeInTheDocument(); + expect(screen.queryByText('Radar: Flip Pole Side')).not.toBeInTheDocument(); + }); + + it('shows no row when loading the bindings fails', async () => { + withBridge(() => Promise.reject(new Error('no bridge'))); + render(); + await Promise.resolve(); + expect(screen.queryByText('Radar: Flip Pole Side')).not.toBeInTheDocument(); + }); +}); diff --git a/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx new file mode 100644 index 000000000..a8c8e85f5 --- /dev/null +++ b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; +import type { KeybindingsMap } from '@irdashies/types'; +import logger from '@irdashies/utils/logger'; +import { BindingRow } from '../KeybindingsSettings'; + +const ACTION_ID = 'radar-flip-pole-side'; + +/** + * The pole side key, also on the Key Bindings page. The radar guesses which + * side the pole line of a start or restart is on, and some tracks differ, so + * the player can flip it and have it remembered. + */ +export const RadarPoleSideKey = () => { + const [bindings, setBindings] = useState(null); + + useEffect(() => { + window.keybindingsBridge + ?.getKeybindings() + .then(setBindings) + .catch((error: unknown) => + logger.error('Failed to load keybindings:', error) + ); + }, []); + + const entry = bindings?.[ACTION_ID]; + + return ( +
    +
    + Pole side key +
    +

    + In some cases the radar may draw the lanes on the wrong side at a start + or restart, because the sim does not say which side the pole line is on. + The key flips the side on the current track and remembers it for future + races. +

    + {entry && ( + + )} +
    + ); +}; From b37afd88db8bc630461aa778ce308e6824d22ee7 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 12:36:31 +0300 Subject: [PATCH 16/32] refactor: keep the pole side table out of the renderer bundles, simplify the generator Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/radarFormation.ts | 9 +++------ src/types/index.ts | 1 - tools/generate-radar-pole-sides.ts | 24 +++++++----------------- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/app/processors/radarFormation.ts b/src/app/processors/radarFormation.ts index 23d9c17b8..f20cb49d9 100644 --- a/src/app/processors/radarFormation.ts +++ b/src/app/processors/radarFormation.ts @@ -1,9 +1,6 @@ -import { - SessionState, - TrackLocation, - poleLeftForTrack, - type Session, -} from '@irdashies/types'; +import { SessionState, TrackLocation, type Session } from '@irdashies/types'; +// Not from the types barrel: the table is main-process only and large. +import { poleLeftForTrack } from '../../types/radarPoleSides'; /** iRacing's PaceMode telemetry values. */ export const PaceMode = { diff --git a/src/types/index.ts b/src/types/index.ts index 53d7b1539..812d81f52 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,7 +32,6 @@ export * from './simWidgetSupport'; export * from './displayFallback'; export * from './lapHistoryBridge'; export * from './lmuTrackAliases'; -export * from './radarPoleSides'; export * from './widgets'; export * from './releaseNotes'; export * from './releaseNotesFixture'; diff --git a/tools/generate-radar-pole-sides.ts b/tools/generate-radar-pole-sides.ts index e15e62411..030c0635f 100644 --- a/tools/generate-radar-pole-sides.ts +++ b/tools/generate-radar-pole-sides.ts @@ -105,7 +105,7 @@ const lapPoints = (drawing: Drawing) => { }; /** True when the first corner turns left. Drawings are y-down, so a positive heading change is a right turn. */ -export const firstCornerIsLeft = (drawing: Drawing): boolean | null => { +const firstCornerIsLeft = (drawing: Drawing): boolean | null => { const pts = lapPoints(drawing); if (!pts) return null; const chord = Math.max(1, Math.round(SAMPLES * CHORD)); @@ -140,27 +140,17 @@ const main = () => { : { tracks: {}, trackNames: {} }; const tracks: Record = {}; let skipped = 0; - for (const id of Object.keys(drawings).sort( - (a, b) => Number(a) - Number(b) - )) { - const kept = previous.tracks?.[id]; - if (kept?.source === 'verified' || kept?.source === 'reported') { - tracks[id] = kept; - continue; - } + for (const [id, drawing] of Object.entries(drawings)) { let left: boolean | null; try { - left = firstCornerIsLeft(drawings[id]); + left = firstCornerIsLeft(drawing); } catch { left = null; } - if (left === null) { - skipped += 1; - continue; - } - tracks[id] = { poleLeft: left, source: 'geometry' }; + if (left === null) skipped += 1; + else tracks[id] = { poleLeft: left, source: 'geometry' }; } - // Verified ids that have no drawing still stay. + // What people confirmed or reported wins over geometry, drawing or not. for (const [id, kept] of Object.entries(previous.tracks ?? {})) { if (kept.source === 'verified' || kept.source === 'reported') { tracks[id] = kept; @@ -188,4 +178,4 @@ const main = () => { ); }; -if (process.argv[1]?.includes('generate-radar-pole-sides')) main(); +main(); From 8f3a66256c3c22162627589e09ed8756482c0cad Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 12:44:53 +0300 Subject: [PATCH 17/32] feat: the pole side flip also works on a standing grid A standing grid is its own situation next to the start and the restart: it has no table, so it is on the left unless the player flipped it, and the correction is stored and reported on its own (grid_left). One pole side now drives every formation's lanes and the road offset. Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics/radarPoleSideReport.spec.ts | 15 +++++ src/app/analytics/radarPoleSideReport.ts | 18 ++++-- src/app/processors/RadarProcessor.spec.ts | 41 ++++++++++++ src/app/processors/RadarProcessor.ts | 41 +++++++----- src/app/processors/radarLanes.spec.ts | 18 +++++- src/app/processors/radarLanes.ts | 22 +++---- src/app/processors/radarPoleSideSettings.ts | 15 +++-- src/app/radarPoleSideOverrides.spec.ts | 32 +++++++++- src/app/radarPoleSideOverrides.ts | 64 +++++++++++-------- .../Settings/sections/AboutSettings.tsx | 9 +-- .../RadarSettings/RadarPoleSideKey.tsx | 8 +-- 11 files changed, 206 insertions(+), 77 deletions(-) diff --git a/src/app/analytics/radarPoleSideReport.spec.ts b/src/app/analytics/radarPoleSideReport.spec.ts index 7161e744e..59e7bbf83 100644 --- a/src/app/analytics/radarPoleSideReport.spec.ts +++ b/src/app/analytics/radarPoleSideReport.spec.ts @@ -46,6 +46,21 @@ describe('buildRadarPoleSideReport', () => { ); }); + it('reports a standing grid correction, with no table side', () => { + const report = buildRadarPoleSideReport({ '95': { grid: false } }, '2.0.0'); + expect(report.properties.tracks).toEqual({ '95': { grid_left: false } }); + const withStart = buildRadarPoleSideReport( + { '95': { grid: false, start: true, table: false } }, + '2.0.0' + ); + expect(withStart.properties.tracks['95']).toEqual({ + grid_left: false, + start_left: true, + table_left: false, + }); + expect(withStart.hash).not.toBe(report.hash); + }); + it('reports no corrections as an empty set, with a hash of its own', () => { const empty = buildRadarPoleSideReport({}, '2.0.0'); expect(empty.properties.track_count).toBe(0); diff --git a/src/app/analytics/radarPoleSideReport.ts b/src/app/analytics/radarPoleSideReport.ts index eebd3da88..70edb19e3 100644 --- a/src/app/analytics/radarPoleSideReport.ts +++ b/src/app/analytics/radarPoleSideReport.ts @@ -13,11 +13,17 @@ export interface RadarPoleSideReport { track_count: number; /** * By iRacing track id: the side (true: left) the player chose for a race - * start and a restart, and what the bundled table said at the time. + * start, a restart and a standing grid, and what the bundled table said + * for a start or restart at the time. */ tracks: Record< string, - { start_left?: boolean; restart_left?: boolean; table_left: boolean } + { + start_left?: boolean; + restart_left?: boolean; + grid_left?: boolean; + table_left?: boolean; + } >; }; } @@ -36,7 +42,8 @@ export const buildRadarPoleSideReport = ( id, overrides[id].start ?? null, overrides[id].restart ?? null, - overrides[id].table, + overrides[id].grid ?? null, + overrides[id].table ?? null, ]); const hash = crypto .createHash('sha256') @@ -44,11 +51,12 @@ export const buildRadarPoleSideReport = ( .digest('hex'); const tracks: RadarPoleSideReport['properties']['tracks'] = {}; for (const id of ids) { - const { start, restart, table } = overrides[id]; + const { start, restart, grid, table } = overrides[id]; tracks[id] = { ...(start !== undefined && { start_left: start }), ...(restart !== undefined && { restart_left: restart }), - table_left: table, + ...(grid !== undefined && { grid_left: grid }), + ...(table !== undefined && { table_left: table }), }; } return { diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index e22874b1c..1712aa09a 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -5,6 +5,8 @@ import { DEFAULT_RADAR_TUNING } from '../../types/widgetConfigs'; import radarManifest from '../../types/widgets/radar'; import rollingTelemetry from '../../../test-data/1752616787255/telemetry.json'; import rollingSession from '../../../test-data/1752616787255/session.json'; +import standingTelemetry from '../../../test-data/1731663749009/telemetry.json'; +import standingSession from '../../../test-data/1731663749009/session.json'; import { parseTrackLength, RADAR_MAX_RANGE_M, @@ -531,6 +533,45 @@ describe('RadarProcessor', () => { expect(processor.snapshot().roadLane).toBe(0); }); + it('puts the pole column of a standing grid on the left, and a flip moves it', () => { + const player: { poleLeft?: boolean } = {}; + const poleSides = { + override: (_trackId: number | undefined, situation: string) => + situation === 'grid' ? player.poleLeft : undefined, + setTrack: vi.fn(), + }; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(standingSession as unknown as Session); + const at = (time: number) => + ({ + ...standingTelemetry, + SessionTime: { value: [time] }, + }) as unknown as Telemetry; + + processor.onFrame(at(100)); + expect(processor.snapshot().formation).toBe('grid'); + expect(poleSides.setTrack).toHaveBeenLastCalledWith( + expect.anything(), + true, + 'grid' + ); + const lanes = () => + processor + .snapshot() + .cars.filter((car) => car.laneSource === 'grid') + .map((car) => car.lane); + const before = lanes(); + expect(before.length).toBeGreaterThan(0); + const roadBefore = processor.snapshot().roadLane; + + player.poleLeft = false; + processor.onFrame(at(101)); + // Everything mirrors: the cars beside us, and the road under the grid. + expect(lanes()).toEqual(before.map((lane) => (lane === 0 ? 0 : -lane))); + expect(processor.snapshot().roadLane).toBe(-roadBefore); + expect(processor.snapshot().poleSideNotice).toBe('right'); + }); + it('puts the pole line on the left on a super speedway', () => { const processor = new RadarProcessor(); const session = rollingSession as unknown as Session; diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 1d752e299..0944390f4 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -58,6 +58,8 @@ const MAX_FRAME_GAP_S = 0.5; */ const SPEED_SETTLE_S = 1; const RADAR_TICK_HZ = 25; +/** A standing grid has no table: its pole is on the left unless flipped. */ +const GRID_POLE_LEFT = true; /** Seconds the radar names the pole side after it was flipped. */ const POLE_NOTICE_S = 3; /** @@ -127,7 +129,7 @@ export class RadarProcessor implements TelemetryProcessor { private latest: RadarSnapshot = emptySnapshot(0); private trackId: number | undefined; - /** The side the table gives, and the side in use (the player's wins). */ + /** The side the table gives for a pace field, and the side in use. */ private tablePoleLeft = false; private poleLeft = false; /** A race start or a restart: the player corrects each on its own. */ @@ -162,7 +164,7 @@ export class RadarProcessor implements TelemetryProcessor { this.poleLeft = this.poleSides.override(this.trackId, this.situation) ?? this.tablePoleLeft; - this.lanes.setPacePoleLeft(this.poleLeft); + this.lanes.setPoleLeft(this.poleLeft); this.poleNoticeUntil = -Infinity; const options = session?.WeekendInfo?.WeekendOptions; this.grid = { @@ -184,10 +186,6 @@ export class RadarProcessor implements TelemetryProcessor { const pcts = valuesOf(frame, 'CarIdxLapDistPct'); const time = scalarNumber(frame, 'SessionTime'); this.updateSpeeds(pcts, time); - this.applyPoleSide( - time, - scalarNumber(frame, 'PaceMode', PaceMode.NotPacing) - ); const camCarIdx = scalarNumber(frame, 'CamCarIdx'); const focus = @@ -216,6 +214,7 @@ export class RadarProcessor implements TelemetryProcessor { this.gridSessionNum = sessionNum; this.gridPositions = parseGridPositions(this.session, sessionNum); } + const paceMode = scalarNumber(frame, 'PaceMode', PaceMode.NotPacing); formation = computeFormation({ focus, dists, @@ -223,7 +222,7 @@ export class RadarProcessor implements TelemetryProcessor { surfaces: surfaces as readonly number[], onPitRoad: onPitRoad as readonly boolean[], excluded: this.excluded, - paceMode: scalarNumber(frame, 'PaceMode', PaceMode.NotPacing), + paceMode, paceLines: valuesOf(frame, 'CarIdxPaceLine') as readonly number[], paceRows: valuesOf(frame, 'CarIdxPaceRow') as readonly number[], sessionState: scalarNumber(frame, 'SessionState'), @@ -237,6 +236,7 @@ export class RadarProcessor implements TelemetryProcessor { grid: this.grid, gridPositions: this.gridPositions, }); + this.applyPoleSide(time, formation, paceMode); const followCarIdx = formation?.followCarIdx ?? null; if (followCarIdx !== null && Number.isFinite(dists[followCarIdx])) { follow = { @@ -317,7 +317,7 @@ export class RadarProcessor implements TelemetryProcessor { focusBrake: drivingFocus && brake >= 0 ? brake : null, caution: (flags & CAUTION_FLAGS) !== 0, roadLane: formation - ? poleSign(formation.kind, this.poleLeft) * + ? poleSign(this.poleLeft) * ((formation.columns - 1) / 2 - formation.ownColumn) : 0, poleSideNotice: @@ -378,22 +378,29 @@ export class RadarProcessor implements TelemetryProcessor { * Follows the player's own pole side for this track, which a keybinding can * change at any time, and says so on the radar for a few seconds. */ - private applyPoleSide(time: number, paceMode: number): void { + private applyPoleSide( + time: number, + formation: Formation | null, + paceMode: number + ): void { const situation: PoleSituation = - paceMode === PaceMode.SingleFileRestart || - paceMode === PaceMode.DoubleFileRestart - ? 'restart' - : 'start'; + formation?.kind === 'grid' + ? 'grid' + : paceMode === PaceMode.SingleFileRestart || + paceMode === PaceMode.DoubleFileRestart + ? 'restart' + : 'start'; + const tableLeft = + situation === 'grid' ? GRID_POLE_LEFT : this.tablePoleLeft; const changedSituation = situation !== this.situation; if (changedSituation) { this.situation = situation; - this.poleSides.setTrack(this.trackId, this.tablePoleLeft, situation); + this.poleSides.setTrack(this.trackId, tableLeft, situation); } - const side = - this.poleSides.override(this.trackId, situation) ?? this.tablePoleLeft; + const side = this.poleSides.override(this.trackId, situation) ?? tableLeft; if (side === this.poleLeft) return; this.poleLeft = side; - this.lanes.setPacePoleLeft(side); + this.lanes.setPoleLeft(side); // Only a key press is worth a message, not the situation changing. if (!changedSituation) this.poleNoticeUntil = time + POLE_NOTICE_S; } diff --git a/src/app/processors/radarLanes.spec.ts b/src/app/processors/radarLanes.spec.ts index b5e3dc276..e310437fa 100644 --- a/src/app/processors/radarLanes.spec.ts +++ b/src/app/processors/radarLanes.spec.ts @@ -310,7 +310,7 @@ describe('RadarLaneTracker', () => { it('puts the pole line on the left of the pace field on ovals', () => { const tracker = new RadarLaneTracker(); - tracker.setPacePoleLeft(true); + tracker.setPoleLeft(true); const { lanes } = settle( tracker, 0, @@ -323,8 +323,9 @@ describe('RadarLaneTracker', () => { expect(lanes.get(2)).toEqual({ lane: -1, source: 'pace' }); }); - it('puts the pole column left of a standing grid', () => { + it('puts the pole column on the side it is told, left of a standing grid by default', () => { const tracker = new RadarLaneTracker(); + tracker.setPoleLeft(true); const { lanes } = settle( tracker, 0, @@ -335,10 +336,23 @@ describe('RadarLaneTracker', () => { ); expect(lanes.get(2)).toEqual({ lane: -1, source: 'grid' }); + + // Flipped to the right: the same car sits on the other side. + tracker.setPoleLeft(false); + const { lanes: flipped } = settle( + tracker, + 10, + [{ carIdx: 2, dist: 4 }], + CarLeftRight.Clear, + 1, + formation([[2, -1]], 'grid') + ); + expect(flipped.get(2)).toEqual({ lane: 1, source: 'grid' }); }); it('keeps formation lanes for a while after the green', () => { const tracker = new RadarLaneTracker(); + tracker.setPoleLeft(true); const cars = [{ carIdx: 2, dist: 12 }]; const { time } = settle( tracker, diff --git a/src/app/processors/radarLanes.ts b/src/app/processors/radarLanes.ts index d6a044dd5..6d9299681 100644 --- a/src/app/processors/radarLanes.ts +++ b/src/app/processors/radarLanes.ts @@ -66,13 +66,11 @@ const demandFor = (carLeftRight: number): SpotterDemand | null => { export type FormationKind = 'grid' | 'pace'; /** - * Pole column side: on the left of a standing grid; for a pace line it - * depends on the track, see paceFieldPoleLeft. iRacing does not say; this is - * what its grids do. +1 means the pole is on the left, so higher columns lie - * to the right. + * Pole column side: iRacing does not say, so it comes from the table, or from + * the player's own correction, see paceFieldPoleLeft. +1 means the pole is on + * the left, so higher columns lie to the right. */ -export const poleSign = (kind: FormationKind, pacePoleLeft: boolean): number => - kind === 'grid' || pacePoleLeft ? 1 : -1; +export const poleSign = (poleLeft: boolean): number => (poleLeft ? 1 : -1); export interface LaneFormation { kind: FormationKind; @@ -114,15 +112,15 @@ export class RadarLaneTracker { private readonly rememberedAt = new Map(); private readonly drawnLane = new Map(); private previousTime = -1; - private pacePoleLeft = false; + private poleLeft = false; constructor( private readonly tuning: () => RadarProcessorTuning = () => DEFAULT_RADAR_TUNING ) {} - /** Which side the pole line of a pace field is on, see paceFieldPoleLeft. */ - setPacePoleLeft(left: boolean): void { - this.pacePoleLeft = left; + /** Which side the pole column or line is on, see paceFieldPoleLeft. */ + setPoleLeft(left: boolean): void { + this.poleLeft = left; } reset(): void { @@ -274,9 +272,7 @@ export class RadarLaneTracker { for (const car of cars) { const slot = formation.slots.get(car.carIdx); if (slot === undefined) continue; - const lane = clampLane( - slot * poleSign(formation.kind, this.pacePoleLeft) - ); + const lane = clampLane(slot * poleSign(this.poleLeft)); targets.set(car.carIdx, { lane, source: formation.kind }); // Remembered too, so the formation fades out gently after the green. this.rememberedLane.set(car.carIdx, lane); diff --git a/src/app/processors/radarPoleSideSettings.ts b/src/app/processors/radarPoleSideSettings.ts index 3e1052677..52989a23a 100644 --- a/src/app/processors/radarPoleSideSettings.ts +++ b/src/app/processors/radarPoleSideSettings.ts @@ -1,9 +1,16 @@ /** - * Which kind of pace field the radar is looking at: the race start, or a - * restart after a caution. iRacing keeps a separate pole side flag for each, - * so a player's correction is kept per situation too. + * Which kind of formation the radar is looking at: the rolling start, a + * restart after a caution, or a standing grid. iRacing keeps a separate pole + * side flag for the start and the restart, and a grid is its own thing, so a + * player's correction is kept per situation too. */ -export type PoleSituation = 'start' | 'restart'; +export type PoleSituation = 'start' | 'restart' | 'grid'; + +export const POLE_SITUATIONS: readonly PoleSituation[] = [ + 'start', + 'restart', + 'grid', +]; /** * What the radar needs to know about the player's own pole side choice. A diff --git a/src/app/radarPoleSideOverrides.spec.ts b/src/app/radarPoleSideOverrides.spec.ts index 24b64b790..c8e8f72fd 100644 --- a/src/app/radarPoleSideOverrides.spec.ts +++ b/src/app/radarPoleSideOverrides.spec.ts @@ -22,22 +22,24 @@ describe('parsePoleSideOverrides', () => { expect(parsePoleSideOverrides(raw)).toEqual({}); }); - it('keeps canonical ids with a table side and a correction', () => { + it('keeps canonical ids with a well formed correction', () => { expect( parsePoleSideOverrides({ '95': { start: false, table: true }, '212': { restart: true, table: false }, + '444': { grid: false }, '0': { start: true, table: true }, '012': { start: true, table: true }, abc: { start: true, table: true }, '7': { start: 'left', table: true }, '8': { table: true }, - '9': { start: true }, + '9': { start: true, table: 'left' }, '10': null, }) ).toEqual({ '95': { start: false, table: true }, '212': { restart: true, table: false }, + '444': { grid: false }, }); }); }); @@ -80,6 +82,32 @@ describe('RadarPoleSideOverrides', () => { expect(saved()).toEqual({ '95': { start: false, table: true } }); }); + it('keeps a standing grid correction on its own, with no table side', () => { + const { overrides, saved } = store(); + // A grid has no table; the radar says it is left unless flipped. + overrides.setTrack(95, true, 'grid'); + + expect(overrides.flip()).toEqual({ + trackId: 95, + situation: 'grid', + poleLeft: false, + }); + expect(overrides.override(95, 'grid')).toBe(false); + expect(overrides.override(95, 'start')).toBeUndefined(); + expect(saved()).toEqual({ '95': { grid: false } }); + + // A start flip later records its table side and keeps the grid one. + overrides.setTrack(95, true, 'start'); + overrides.flip(); + expect(saved()).toEqual({ + '95': { start: false, grid: false, table: true }, + }); + + overrides.setTrack(95, true, 'grid'); + overrides.flip(); + expect(saved()).toEqual({ '95': { start: false, table: true } }); + }); + it('removes the entry when flipped back to what the table says', () => { const { overrides, saved } = store(); overrides.setTrack(95, true, 'start'); diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts index 67eee9c32..a1e545952 100644 --- a/src/app/radarPoleSideOverrides.ts +++ b/src/app/radarPoleSideOverrides.ts @@ -1,17 +1,17 @@ -import type { - PoleSideSettings, - PoleSituation, +import { + POLE_SITUATIONS, + type PoleSideSettings, + type PoleSituation, } from './processors/radarPoleSideSettings'; /** * One track's corrections: the side (true: left) the player chose for a race - * start and for a restart, and the side the table gave when they chose it. + * start, a restart and a standing grid, and the side the table gave for a + * start or restart when they chose it (a grid has no table: it is the left). */ -export interface PoleSideCorrection { - start?: boolean; - restart?: boolean; - table: boolean; -} +export type PoleSideCorrection = Partial> & { + table?: boolean; +}; /** iRacing track id (as a string) -> the player's corrections there. */ export type PoleSideOverrides = Record; @@ -34,14 +34,19 @@ export const parsePoleSideOverrides = (raw: unknown): PoleSideOverrides => { !/^[1-9]\d{0,9}$/.test(id) || !value || typeof value !== 'object' || - typeof value.table !== 'boolean' + (value.table !== undefined && typeof value.table !== 'boolean') ) { continue; } - const correction: PoleSideCorrection = { table: value.table }; - if (typeof value.start === 'boolean') correction.start = value.start; - if (typeof value.restart === 'boolean') correction.restart = value.restart; - if (correction.start !== undefined || correction.restart !== undefined) { + const correction: PoleSideCorrection = {}; + if (typeof value.table === 'boolean') correction.table = value.table; + for (const situation of POLE_SITUATIONS) { + const side = (value as Record)[situation]; + if (typeof side === 'boolean') correction[situation] = side; + } + if ( + POLE_SITUATIONS.some((situation) => correction[situation] !== undefined) + ) { overrides[id] = correction; } } @@ -121,22 +126,29 @@ export class RadarPoleSideOverrides implements PoleSideSettings { const poleLeft = !current; // Only a real difference from the table is kept. const before = Object.hasOwn(stored, key) ? stored[key] : undefined; - const sideOf = (which: PoleSituation) => - which === situation - ? poleLeft === tableLeft - ? undefined - : poleLeft - : before?.[which]; - const start = sideOf('start'); - const restart = sideOf('restart'); + const sides = Object.fromEntries( + POLE_SITUATIONS.map((which): [PoleSituation, boolean | undefined] => [ + which, + which === situation + ? poleLeft === tableLeft + ? undefined + : poleLeft + : before?.[which], + ]) + ) as Record; const overrides: PoleSideOverrides = Object.fromEntries( Object.entries(stored).filter(([id]) => id !== key) ); - if (start !== undefined || restart !== undefined) { + if (POLE_SITUATIONS.some((which) => sides[which] !== undefined)) { overrides[key] = { - ...(start !== undefined && { start }), - ...(restart !== undefined && { restart }), - table: tableLeft, + ...Object.fromEntries( + POLE_SITUATIONS.filter((which) => sides[which] !== undefined).map( + (which) => [which, sides[which]] + ) + ), + ...(situation !== 'grid' + ? { table: tableLeft } + : before?.table !== undefined && { table: before.table }), }; } this.persistence.write(overrides); diff --git a/src/frontend/components/Settings/sections/AboutSettings.tsx b/src/frontend/components/Settings/sections/AboutSettings.tsx index ed5efeb8c..ac8b7efda 100644 --- a/src/frontend/components/Settings/sections/AboutSettings.tsx +++ b/src/frontend/components/Settings/sections/AboutSettings.tsx @@ -113,10 +113,11 @@ export const AboutSettings = () => { Radar pole-side corrections
    - Track IDs and the pole side you chose for a race start or a - restart with the radar key, together with the side the - built-in table gave, are sent at startup when your corrections - change, to improve the table for everyone. + Track IDs and the pole side you chose for a race start, a + restart or a standing grid with the radar key, together with + the side the built-in table gave, are sent shortly after your + corrections change (or at the next startup), to improve the + table for everyone.
  • System information diff --git a/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx index a8c8e85f5..5f6dabafe 100644 --- a/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx +++ b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.tsx @@ -30,10 +30,10 @@ export const RadarPoleSideKey = () => { Pole side key

    - In some cases the radar may draw the lanes on the wrong side at a start - or restart, because the sim does not say which side the pole line is on. - The key flips the side on the current track and remembers it for future - races. + In some cases the radar may draw the lanes on the wrong side at a start, + restart or standing grid, because the sim does not say which side the + pole line is on. The key flips the side on the current track and + remembers it for future races.

    {entry && ( Date: Sun, 18 Oct 2026 12:55:36 +0300 Subject: [PATCH 18/32] fix: address the code review of the radar branch The spotter's Off no longer separates cars, a repeated session info keeps a flip and its message, the flip key forgets the track on disconnect, the notice does not outlive a rewound replay, a car's recent top speed is a sliding maximum so a steady slowing is seen, the crash drop needs under 8 m/s, the generator keeps catalogue hints when run without one, and a retried pole side report keeps its event id. Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.ts | 8 +- src/app/processors/RadarProcessor.spec.ts | 89 ++++++++++++++++++++++- src/app/processors/RadarProcessor.ts | 44 ++++++++--- src/app/processors/radarHazards.spec.ts | 18 +++++ src/app/processors/radarHazards.ts | 26 +++++-- tools/generate-radar-pole-sides.ts | 4 +- 6 files changed, 167 insertions(+), 22 deletions(-) diff --git a/src/app/analytics.ts b/src/app/analytics.ts index 2d239dfa4..592ff4844 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -95,6 +95,7 @@ export class Analytics { private pitLaneCalibrationAttempted = false; private pitLaneCalibrationTask: Promise | undefined; private radarPoleSideTask: Promise | undefined; + private poleSideEvent: { hash: string; uuid: string } | undefined; private radarPoleSideTimer: ReturnType | undefined; private watchingRadarPoleSides = false; @@ -294,10 +295,15 @@ export class Analytics { logger.warn('[Analytics] Skipping oversized radar pole side report'); return; } + // The same report keeps its event id across retries, so a send whose + // acknowledgement was lost is not counted twice while the app runs. + if (this.poleSideEvent?.hash !== report.hash) { + this.poleSideEvent = { hash: report.hash, uuid: crypto.randomUUID() }; + } await this.provider.captureConfirmed({ distinctId: userId, event: 'radar_pole_side', - uuid: crypto.randomUUID(), + uuid: this.poleSideEvent.uuid, properties: report.properties, }); // Recorded only after PostHog acknowledged it, so a failure retries. diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 1712aa09a..5f742c1ea 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -230,7 +230,7 @@ describe('RadarProcessor', () => { time: 1, pcts: [0.5, 0.5 + m(2), 0.5 - m(2)], onPitRoad: [false, true, false], - carLeftRight: CarLeftRight.Off, + carLeftRight: CarLeftRight.Clear, }) ); @@ -248,7 +248,7 @@ describe('RadarProcessor', () => { time: 1, pcts: [0.5, 0.5 + m(2), 0.5 - m(2)], onPitRoad: [true, true, false], - carLeftRight: CarLeftRight.Off, + carLeftRight: CarLeftRight.Clear, }) ); @@ -361,7 +361,7 @@ describe('RadarProcessor', () => { time: 1, pcts: [0.1, 0.5, 0.5 + m(2)], camCarIdx: 1, - carLeftRight: CarLeftRight.Off, + carLeftRight: CarLeftRight.Clear, }) ); @@ -572,6 +572,89 @@ describe('RadarProcessor', () => { expect(processor.snapshot().poleSideNotice).toBe('right'); }); + const limeRock = () => { + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + session.WeekendInfo.TrackName = 'limerock 2019 gp'; + return session; + }; + + it('does not space cars apart when the spotter says nothing at all', () => { + const processor = createProcessor(); + processor.onFrame( + frame({ + time: 1, + pcts: [0.5, 0.5 + m(2)], + onPitRoad: [false, false], + carLeftRight: CarLeftRight.Off, + }) + ); + expect(processor.snapshot().cars[0].dist).toBeCloseTo(2); + }); + + it('keeps a flip and its message when session info arrives again', () => { + const player: { poleLeft?: boolean } = {}; + const poleSides = { + override: () => player.poleLeft, + setTrack: vi.fn(), + }; + const session = limeRock(); + const processor = new RadarProcessor(undefined, poleSides); + processor.init(session); + const at = (time: number) => + ({ + ...rollingTelemetry, + SessionTime: { value: [time] }, + }) as unknown as Telemetry; + processor.onFrame(at(100)); + + player.poleLeft = true; + processor.onFrame(at(101)); + expect(processor.snapshot().poleSideNotice).toBe('left'); + + // The sim sends the session again, as it does all the time in a race. + processor.init(session); + poleSides.setTrack.mockClear(); + processor.onFrame(at(101.04)); + expect(processor.snapshot().poleSideNotice).toBe('left'); + expect(poleSides.setTrack).not.toHaveBeenCalled(); + }); + + it('forgets the track when the session ends, so the key has nothing to flip', () => { + const poleSides = { override: () => undefined, setTrack: vi.fn() }; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(rollingSession as unknown as Session); + processor.onLifecycle({ type: 'disconnect' }); + expect(poleSides.setTrack).toHaveBeenLastCalledWith( + undefined, + false, + 'start' + ); + }); + + it('shows the message only shortly after the flip, even in a rewound replay', () => { + const player: { poleLeft?: boolean } = {}; + const poleSides = { + override: () => player.poleLeft, + setTrack: vi.fn(), + }; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(limeRock()); + const at = (time: number) => + ({ + ...rollingTelemetry, + SessionTime: { value: [time] }, + }) as unknown as Telemetry; + processor.onFrame(at(100)); + player.poleLeft = true; + processor.onFrame(at(101)); + expect(processor.snapshot().poleSideNotice).toBe('left'); + + // Scrubbed back a minute: the message belongs to the future. + processor.onFrame(at(40)); + expect(processor.snapshot().poleSideNotice).toBeNull(); + }); + it('puts the pole line on the left on a super speedway', () => { const processor = new RadarProcessor(); const session = rollingSession as unknown as Session; diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 0944390f4..c89a18073 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -129,6 +129,8 @@ export class RadarProcessor implements TelemetryProcessor { private latest: RadarSnapshot = emptySnapshot(0); private trackId: number | undefined; + /** Whether init() has set the pole side up for a track since the last lifecycle end. */ + private poleTrackKnown = false; /** The side the table gives for a pace field, and the side in use. */ private tablePoleLeft = false; private poleLeft = false; @@ -153,19 +155,25 @@ export class RadarProcessor implements TelemetryProcessor { // TrackType is "road course", "short track", "super speedway", "dirt oval" // and so on; anything that is not road racing is an oval const trackType = session?.WeekendInfo?.TrackType ?? ''; + const previousTrackId = this.trackId; this.trackId = session?.WeekendInfo?.TrackID; this.tablePoleLeft = paceFieldPoleLeft( this.trackId, session?.WeekendInfo?.TrackName, trackType !== '' && !/road|rally/i.test(trackType) ); - this.situation = 'start'; - this.poleSides.setTrack(this.trackId, this.tablePoleLeft, this.situation); - this.poleLeft = - this.poleSides.override(this.trackId, this.situation) ?? - this.tablePoleLeft; - this.lanes.setPoleLeft(this.poleLeft); - this.poleNoticeUntil = -Infinity; + // iRacing sends session info again and again; only a new track starts the + // pole side over, or a flip would lose its message and its situation. + if (!this.poleTrackKnown || this.trackId !== previousTrackId) { + this.poleTrackKnown = true; + this.situation = 'start'; + this.poleSides.setTrack(this.trackId, this.tablePoleLeft, this.situation); + this.poleLeft = + this.poleSides.override(this.trackId, this.situation) ?? + this.tablePoleLeft; + this.lanes.setPoleLeft(this.poleLeft); + this.poleNoticeUntil = -Infinity; + } const options = session?.WeekendInfo?.WeekendOptions; this.grid = { standingStart: options?.StandingStart === 1, @@ -320,8 +328,11 @@ export class RadarProcessor implements TelemetryProcessor { ? poleSign(this.poleLeft) * ((formation.columns - 1) / 2 - formation.ownColumn) : 0, - poleSideNotice: - time < this.poleNoticeUntil ? (this.poleLeft ? 'left' : 'right') : null, + poleSideNotice: this.poleNoticeShown(time) + ? this.poleLeft + ? 'left' + : 'right' + : null, cars, hazards, }; @@ -338,6 +349,12 @@ export class RadarProcessor implements TelemetryProcessor { this.lanes.reset(); this.hazards.reset(); this.laneFocus = -1; + // Out of the session the flip key has no track to act on. A new session + // number keeps the track: iRacing sends the session info again after it. + if (event.type === 'disconnect') { + this.poleTrackKnown = false; + this.poleSides.setTrack(undefined, false, 'start'); + } this.latest = emptySnapshot(this.latest.version + 1); } @@ -371,13 +388,20 @@ export class RadarProcessor implements TelemetryProcessor { car.lane = lane.lane; car.laneSource = lane.source; } - return spotter !== null; + // Off is the spotter saying nothing at all (LMU without positions, say). + return spotter !== null && spotter !== CarLeftRight.Off; } /** * Follows the player's own pole side for this track, which a keybinding can * change at any time, and says so on the radar for a few seconds. */ + /** Within POLE_NOTICE_S of the flip, and not left over from a replay's future. */ + private poleNoticeShown(time: number): boolean { + const left = this.poleNoticeUntil - time; + return left > 0 && left <= POLE_NOTICE_S; + } + private applyPoleSide( time: number, formation: Formation | null, diff --git a/src/app/processors/radarHazards.spec.ts b/src/app/processors/radarHazards.spec.ts index ddaa7988f..5d4b56248 100644 --- a/src/app/processors/radarHazards.spec.ts +++ b/src/app/processors/radarHazards.spec.ts @@ -161,6 +161,24 @@ describe('RadarHazardTracker', () => { ]); }); + it('sees a steady slowing, not just a fast one', () => { + const field = new Field(); + field.run(5); + // 4 m/s^2 down from our speed: each second is a little slower than the + // last few seconds' pace, which must not look like pace every time. + for (let speed = 50; speed >= 20; speed -= 2) field.run(0.5, { speed }); + expect(field.last).toEqual([ + expect.objectContaining({ carIdx: 2, kind: 'slow' }), + ]); + }); + + it('does not call a hairpin at walking-fast pace a crash', () => { + const field = new Field(); + field.run(5); + for (const speed of [40, 28, 18, 11]) field.run(0.4, { speed }); + expect(field.run(2).map((hazard) => hazard.kind)).not.toContain('crash'); + }); + it('clears once the car is back up to speed', () => { const field = new Field(); field.run(5); diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index fb0b3aeda..9df9e9618 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -21,7 +21,7 @@ const SUDDEN_SHARE = 0.4; * ...and below this speed in m/s, so braking for a hairpin is not a crash; a * car that is really crashed or spun is down to a crawl within moments. */ -const SUDDEN_MAX_MS = 12; +const SUDDEN_MAX_MS = 8; /** Below this, in m/s, a car crawls: slow on any part of the track. */ const CRAWL_MS = 12; /** @@ -49,9 +49,12 @@ const REJOIN_S = 5; const QUIET_GRACE_S = 5; interface CarHazardState { - /** The car's own top speed within the last SUDDEN_WINDOW_S, and when. */ + /** The car's own top speed over the last two to four seconds. */ peak: number; - peakAt: number; + /** Top speed in the current and in the previous bucket of SUDDEN_WINDOW_S. */ + peakNow: number; + peakBefore: number; + bucketAt: number; /** Speed the car had before a sudden drop, or -1 while it has not dropped. */ droppedFrom: number; droppedAt: number; @@ -71,7 +74,9 @@ interface CarHazardState { const freshState = (): CarHazardState => ({ peak: 0, - peakAt: -1, + peakNow: 0, + peakBefore: 0, + bucketAt: -1, droppedFrom: -1, droppedAt: -1, stoppedSince: -1, @@ -198,10 +203,17 @@ export class RadarHazardTracker { } // The car's own pace over the last moments, to tell a drop from a corner. - if (speed >= state.peak || time - state.peakAt > SUDDEN_WINDOW_S) { - state.peak = speed; - state.peakAt = time; + // Kept over two buckets of SUDDEN_WINDOW_S, so it looks back two to four + // seconds and never falls to the current speed all at once: a steady + // slowing stays below its own recent pace instead of resetting to it. + if (state.bucketAt < 0 || time - state.bucketAt >= SUDDEN_WINDOW_S) { + state.peakBefore = state.peakNow; + state.peakNow = speed; + state.bucketAt = time; + } else { + state.peakNow = Math.max(state.peakNow, speed); } + state.peak = Math.max(state.peakNow, state.peakBefore); if ( state.droppedFrom < 0 && state.peak >= RACING_MS && diff --git a/tools/generate-radar-pole-sides.ts b/tools/generate-radar-pole-sides.ts index 030c0635f..2944f3c28 100644 --- a/tools/generate-radar-pole-sides.ts +++ b/tools/generate-radar-pole-sides.ts @@ -159,7 +159,9 @@ const main = () => { const sorted = Object.fromEntries( Object.entries(tracks) .map(([id, entry]): [string, Entry] => { - const { name, iracing } = catalogue.get(id) ?? entry; + // Without a catalogue, keep the hints the file already had. + const { name, iracing } = + catalogue.get(id) ?? previous.tracks?.[id] ?? entry; return [ id, { ...entry, ...(name && { name }), ...(iracing && { iracing }) }, From 3cdffd127293c87ec8fa4b928f050a197d470ce7 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 22:47:06 +0300 Subject: [PATCH 19/32] fix: address the second code review of the radar branch Release the pole side target when the host drops the radar processor, write the pole side corrections asynchronously (debounced, flushed on quit), and add a story for the pole side key row. Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/ProcessorHost.ts | 2 + src/app/processors/RadarProcessor.spec.ts | 16 ++++ src/app/processors/RadarProcessor.ts | 11 ++- src/app/processors/TelemetryProcessor.ts | 2 + src/app/storage/radarPoleSideOverrides.ts | 80 +++++++++++++++++-- .../RadarPoleSideKey.stories.tsx | 35 ++++++++ src/main.ts | 2 + 7 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.stories.tsx diff --git a/src/app/processors/ProcessorHost.ts b/src/app/processors/ProcessorHost.ts index 53e4d2857..74181e98a 100644 --- a/src/app/processors/ProcessorHost.ts +++ b/src/app/processors/ProcessorHost.ts @@ -430,6 +430,8 @@ export class ProcessorHost { } private deactivate(state: RuntimeState): void { + const processor = state.processor; + this.runSafely(state, 'deactivate', () => processor?.dispose?.()); state.processor = undefined; state.tickSchedule = {}; state.lastPublishedToken = undefined; diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 5f742c1ea..0b09a33ad 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -527,6 +527,22 @@ describe('RadarProcessor', () => { expect(processor.snapshot().roadLane).toBe(-before); }); + it('stops aiming the flip key at its track once the host drops it', () => { + const poleSides = { override: () => undefined, setTrack: vi.fn() }; + const session = structuredClone(rollingSession) as unknown as Session; + session.WeekendInfo.TrackID = 353; + const processor = new RadarProcessor(undefined, poleSides); + processor.init(session); + + processor.dispose(); + + expect(poleSides.setTrack).toHaveBeenLastCalledWith( + undefined, + false, + 'start' + ); + }); + it('has no road offset outside a formation', () => { const processor = new RadarProcessor(); processor.init(rollingSession as unknown as Session); diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index c89a18073..2aff13436 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -340,6 +340,12 @@ export class RadarProcessor implements TelemetryProcessor { this.latest = { ...next, version: this.latest.version + 1 }; } + /** A dropped radar must not leave the flip key aimed at its last track. */ + dispose(): void { + this.poleTrackKnown = false; + this.poleSides.setTrack(undefined, false, 'start'); + } + onLifecycle(event: SessionLifecycleEvent): void { if (event.type === 'enter') return; this.previousPcts = []; @@ -351,10 +357,7 @@ export class RadarProcessor implements TelemetryProcessor { this.laneFocus = -1; // Out of the session the flip key has no track to act on. A new session // number keeps the track: iRacing sends the session info again after it. - if (event.type === 'disconnect') { - this.poleTrackKnown = false; - this.poleSides.setTrack(undefined, false, 'start'); - } + if (event.type === 'disconnect') this.dispose(); this.latest = emptySnapshot(this.latest.version + 1); } diff --git a/src/app/processors/TelemetryProcessor.ts b/src/app/processors/TelemetryProcessor.ts index a941b7012..940fccb6e 100644 --- a/src/app/processors/TelemetryProcessor.ts +++ b/src/app/processors/TelemetryProcessor.ts @@ -11,4 +11,6 @@ export interface TelemetryProcessor { onFrame(frame: Telemetry): void; onLifecycle(event: SessionLifecycleEvent): void; snapshot(): Snapshot; + /** The host is dropping this instance: release anything shared. */ + dispose?(): void; } diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index 83a59357c..d15295abb 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -1,10 +1,78 @@ -import { readData, writeData } from './storage'; -import { RadarPoleSideOverrides } from '../radarPoleSideOverrides'; +import { app } from 'electron'; +import { readFileSync } from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import logger from '../logger'; +import { + RadarPoleSideOverrides, + type PoleSideOverrides, +} from '../radarPoleSideOverrides'; -const STORAGE_KEY = 'radarPoleSideOverrides'; +/** + * Its own file rather than config.json: config.json is written with a + * synchronous read-modify-write, and an async write to it could undo a + * setting saved in between. + */ +const FILE_PATH = path.join( + app.getPath('userData'), + 'radar-pole-side-overrides.json' +); -/** The player's pole side corrections, kept in the app's config file. */ +const WRITE_DEBOUNCE_MS = 250; + +let pending: PoleSideOverrides | undefined; +let writeTimer: NodeJS.Timeout | null = null; +let writeInFlight: Promise = Promise.resolve(); + +const write = async (): Promise => { + const overrides = pending; + pending = undefined; + if (!overrides) return; + const temp = `${FILE_PATH}.${process.pid}.tmp`; + try { + await fsp.writeFile(temp, JSON.stringify(overrides)); + await fsp.rename(temp, FILE_PATH); + } catch (error) { + logger.error('[RadarPoleSide] Failed to write', FILE_PATH, error); + await fsp.unlink(temp).catch(() => undefined); + } +}; + +const queueWrite = (): Promise => { + writeInFlight = writeInFlight.then(write); + return writeInFlight; +}; + +/** Writes any pending correction now; the before-quit coordinator awaits it. */ +export const flushRadarPoleSideOverrides = (): Promise => { + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = null; + return queueWrite(); + } + return writeInFlight; +}; + +/** The player's pole side corrections, kept in a small file of their own. */ export const radarPoleSideOverrides = new RadarPoleSideOverrides({ - read: () => readData(STORAGE_KEY), - write: (overrides) => writeData(STORAGE_KEY, overrides), + // shortcut: one sync read of a tiny file, on first use; make it async if + // the override lookup ever becomes awaitable. + read: () => { + try { + return JSON.parse(readFileSync(FILE_PATH, 'utf8')) as unknown; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[RadarPoleSide] Failed to read', FILE_PATH, error); + } + return undefined; + } + }, + write: (overrides) => { + pending = overrides; + if (writeTimer) clearTimeout(writeTimer); + writeTimer = setTimeout(() => { + writeTimer = null; + void queueWrite(); + }, WRITE_DEBOUNCE_MS); + }, }); diff --git a/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.stories.tsx b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.stories.tsx new file mode 100644 index 000000000..4c873a5a0 --- /dev/null +++ b/src/frontend/components/Settings/sections/RadarSettings/RadarPoleSideKey.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { DEFAULT_KEYBINDINGS } from '@irdashies/types'; +import { RadarPoleSideKey } from './RadarPoleSideKey'; + +const meta: Meta = { + component: RadarPoleSideKey, + title: 'components/Settings/RadarSettings/RadarPoleSideKey', + decorators: [ + (Story) => { + // The row reads its binding through the preload bridge. + Object.defineProperty(window, 'keybindingsBridge', { + configurable: true, + writable: true, + value: { + getKeybindings: async () => ({ ...DEFAULT_KEYBINDINGS }), + updateKeybinding: async () => ({ ...DEFAULT_KEYBINDINGS }), + resetKeybinding: async () => ({ ...DEFAULT_KEYBINDINGS }), + resetAllKeybindings: async () => ({ ...DEFAULT_KEYBINDINGS }), + startRecording: async () => undefined, + stopRecording: async () => undefined, + onGamepadCaptured: () => () => undefined, + }, + }); + return ( +
    + +
    + ); + }, + ], +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/src/main.ts b/src/main.ts index 1ad5e9967..24506e05e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,6 +82,7 @@ import { ReplayContextRuntime } from './app/processors/replayContextRuntime'; import { setupTapeReplayBridge } from './app/bridge/tapeReplayBridge'; import { createTapeReplayWindow } from './app/tapeReplayWindow'; import type { ArchiveIndex } from './app/processors/ReplayContextProcessor'; +import { flushRadarPoleSideOverrides } from './app/storage/radarPoleSideOverrides'; import { createKnownLocalUserIdStore } from './app/storage/knownLocalUserIds'; import { setupLapHistoryBridge } from './app/bridge/lapHistoryBridge'; import { onDashboardUpdated } from './app/storage/dashboardEvents'; @@ -669,6 +670,7 @@ const handleBeforeQuit = createBeforeQuitHandler({ flushIncidentsOnShutdown(), flushLapHistoryOnShutdown(), knownLocalUserIds.flush(), + flushRadarPoleSideOverrides(), releaseNotes?.flush(), flushGarage61SearchSessionOnShutdown(), ]); From 3ef9cfe1e528748c21786b1a2e9fc13805674403 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 22:48:46 +0300 Subject: [PATCH 20/32] fix: keep a pole side correction when its write fails, test the host disposing a dropped processor Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/ProcessorHost.spec.ts | 20 ++++++++++++++++++++ src/app/storage/radarPoleSideOverrides.ts | 2 ++ 2 files changed, 22 insertions(+) diff --git a/src/app/processors/ProcessorHost.spec.ts b/src/app/processors/ProcessorHost.spec.ts index ec7059d32..a458eeaef 100644 --- a/src/app/processors/ProcessorHost.spec.ts +++ b/src/app/processors/ProcessorHost.spec.ts @@ -368,6 +368,26 @@ describe('ProcessorHost', () => { expect(secondRenderer.send).toHaveBeenCalledOnce(); }); + it('disposes a processor it drops for lack of subscribers', () => { + const bus = new ChannelBus(); + const renderer = target(12); + bus.subscribe(renderer, 'reference-laps.snapshot'); + const processor = fakeProcessor('reference-laps.snapshot', 'event'); + const dispose = vi.fn(); + Object.assign(processor, { dispose }); + const host = new ProcessorHost({ + bus, + metrics: metrics(), + definitions: [definition('reference-laps.snapshot', () => processor)], + }); + host.onFrame({} as Telemetry); + + bus.unsubscribe(renderer.id, 'reference-laps.snapshot'); + host.onFrame({} as Telemetry); + + expect(dispose).toHaveBeenCalledOnce(); + }); + it('owns session and replay lifecycle dispatch and disposes once', () => { const bus = new ChannelBus(); bus.subscribe(target(7), 'standings.snapshot'); diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index d15295abb..a1ea15e7e 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -34,6 +34,8 @@ const write = async (): Promise => { await fsp.rename(temp, FILE_PATH); } catch (error) { logger.error('[RadarPoleSide] Failed to write', FILE_PATH, error); + // Keep the correction for the next write, unless a newer one came in. + pending ??= overrides; await fsp.unlink(temp).catch(() => undefined); } }; From e6ea5c3049f3bede22acbe861529ef248470f577 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 22:58:19 +0300 Subject: [PATCH 21/32] fix: resolve the pole side file path lazily so importing the module needs no electron app Co-Authored-By: Claude Sonnet 5.5 --- src/app/storage/radarPoleSideOverrides.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index a1ea15e7e..42a079b63 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -13,10 +13,8 @@ import { * synchronous read-modify-write, and an async write to it could undo a * setting saved in between. */ -const FILE_PATH = path.join( - app.getPath('userData'), - 'radar-pole-side-overrides.json' -); +const filePath = () => + path.join(app.getPath('userData'), 'radar-pole-side-overrides.json'); const WRITE_DEBOUNCE_MS = 250; @@ -28,12 +26,12 @@ const write = async (): Promise => { const overrides = pending; pending = undefined; if (!overrides) return; - const temp = `${FILE_PATH}.${process.pid}.tmp`; + const temp = `${filePath()}.${process.pid}.tmp`; try { await fsp.writeFile(temp, JSON.stringify(overrides)); - await fsp.rename(temp, FILE_PATH); + await fsp.rename(temp, filePath()); } catch (error) { - logger.error('[RadarPoleSide] Failed to write', FILE_PATH, error); + logger.error('[RadarPoleSide] Failed to write', filePath(), error); // Keep the correction for the next write, unless a newer one came in. pending ??= overrides; await fsp.unlink(temp).catch(() => undefined); @@ -61,10 +59,10 @@ export const radarPoleSideOverrides = new RadarPoleSideOverrides({ // the override lookup ever becomes awaitable. read: () => { try { - return JSON.parse(readFileSync(FILE_PATH, 'utf8')) as unknown; + return JSON.parse(readFileSync(filePath(), 'utf8')) as unknown; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.warn('[RadarPoleSide] Failed to read', FILE_PATH, error); + logger.warn('[RadarPoleSide] Failed to read', filePath(), error); } return undefined; } From 11ea837bbef008fbccb80a645ba41baf703dfdf6 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 23:01:27 +0300 Subject: [PATCH 22/32] fix: read the pole side file ahead of the radar without sync I/O, flush a failed write on quit Co-Authored-By: Claude Sonnet 5.5 --- .../iracingSdk/iracingSdkBridge.tape.spec.ts | 1 + src/app/bridge/iracingSdk/iracingSdkBridge.ts | 1 + src/app/storage/radarPoleSideOverrides.ts | 34 +++++++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts index 9e8fed26c..fa4dabd1a 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.tape.spec.ts @@ -40,6 +40,7 @@ vi.mock('../../storage/referenceLaps', () => ({ vi.mock('../../storage/radarTuning', () => ({ getRadarTuning: vi.fn() })); vi.mock('../../storage/radarPoleSideOverrides', () => ({ radarPoleSideOverrides: {}, + loadRadarPoleSideOverrides: async () => undefined, })); vi.mock('../../perfMetrics', () => ({ TelemetryPerfMetrics: class { diff --git a/src/app/bridge/iracingSdk/iracingSdkBridge.ts b/src/app/bridge/iracingSdk/iracingSdkBridge.ts index 2c7dfb68b..e58e030c1 100644 --- a/src/app/bridge/iracingSdk/iracingSdkBridge.ts +++ b/src/app/bridge/iracingSdk/iracingSdkBridge.ts @@ -158,6 +158,7 @@ export async function publishIRacingSDKEvents( const radarPoleSideStorage = channelBus ? await import('../../storage/radarPoleSideOverrides') : undefined; + await radarPoleSideStorage?.loadRadarPoleSideOverrides(); const processorHost = channelBus && referenceLapStorage ? createDefaultProcessorHost({ diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index 42a079b63..2133bb921 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -1,5 +1,4 @@ import { app } from 'electron'; -import { readFileSync } from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import logger from '../logger'; @@ -48,25 +47,30 @@ export const flushRadarPoleSideOverrides = (): Promise => { if (writeTimer) { clearTimeout(writeTimer); writeTimer = null; - return queueWrite(); } - return writeInFlight; + // A write that failed left its correction pending with no timer. + return writeInFlight.then(() => (pending ? queueWrite() : undefined)); +}; + +let stored: unknown; + +/** + * Reads the file ahead of the radar, which looks corrections up synchronously + * and so must not be created before this resolves. + */ +export const loadRadarPoleSideOverrides = async (): Promise => { + try { + stored = JSON.parse(await fsp.readFile(filePath(), 'utf8')) as unknown; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[RadarPoleSide] Failed to read', filePath(), error); + } + } }; /** The player's pole side corrections, kept in a small file of their own. */ export const radarPoleSideOverrides = new RadarPoleSideOverrides({ - // shortcut: one sync read of a tiny file, on first use; make it async if - // the override lookup ever becomes awaitable. - read: () => { - try { - return JSON.parse(readFileSync(filePath(), 'utf8')) as unknown; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.warn('[RadarPoleSide] Failed to read', filePath(), error); - } - return undefined; - } - }, + read: () => stored, write: (overrides) => { pending = overrides; if (writeTimer) clearTimeout(writeTimer); From 13aa5a8dd518e52ecbaa2b2d7ac300e1a17c427f Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 23:08:45 +0300 Subject: [PATCH 23/32] fix: load the pole side file once before any reader, never replace a file that could not be read, test the storage Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.radarPoleSide.spec.ts | 1 + src/app/analytics.ts | 7 +- .../storage/radarPoleSideOverrides.spec.ts | 99 +++++++++++++++++++ src/app/storage/radarPoleSideOverrides.ts | 55 ++++++++--- 4 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 src/app/storage/radarPoleSideOverrides.spec.ts diff --git a/src/app/analytics.radarPoleSide.spec.ts b/src/app/analytics.radarPoleSide.spec.ts index 14da1514a..68cfd7a64 100644 --- a/src/app/analytics.radarPoleSide.spec.ts +++ b/src/app/analytics.radarPoleSide.spec.ts @@ -45,6 +45,7 @@ const { corrections, flipped } = vi.hoisted(() => ({ flipped: { listeners: new Set<() => void>() }, })); vi.mock('./storage/radarPoleSideOverrides', () => ({ + loadRadarPoleSideOverrides: async () => true, radarPoleSideOverrides: { snapshot: () => corrections.value, onChange: (listener: () => void) => { diff --git a/src/app/analytics.ts b/src/app/analytics.ts index 592ff4844..003181363 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -14,7 +14,10 @@ import { MAX_POLE_SIDE_PROPERTIES_BYTES, buildRadarPoleSideReport, } from './analytics/radarPoleSideReport'; -import { radarPoleSideOverrides } from './storage/radarPoleSideOverrides'; +import { + loadRadarPoleSideOverrides, + radarPoleSideOverrides, +} from './storage/radarPoleSideOverrides'; import { readPitLaneAnalyticsCheckpoint, writePitLaneAnalyticsCheckpoint, @@ -277,6 +280,8 @@ export class Analytics { ): Promise { try { if (!this.provider || getAnalyticsOptOut()) return; + // An unreadable file would look like every correction was reverted. + if (!(await loadRadarPoleSideOverrides())) return; const report = buildRadarPoleSideReport( radarPoleSideOverrides.snapshot(), version diff --git a/src/app/storage/radarPoleSideOverrides.spec.ts b/src/app/storage/radarPoleSideOverrides.spec.ts new file mode 100644 index 000000000..d427e176a --- /dev/null +++ b/src/app/storage/radarPoleSideOverrides.spec.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let userData = ''; +vi.mock('electron', () => ({ app: { getPath: () => userData } })); +vi.mock('../logger', () => ({ + default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +const FILE = 'radar-pole-side-overrides.json'; + +const loadModule = async () => { + vi.resetModules(); + return import('./radarPoleSideOverrides'); +}; + +describe('radarPoleSideOverrides storage', () => { + beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'pole-side-')); + }); + + afterEach(() => { + fs.rmSync(userData, { recursive: true, force: true }); + }); + + it('starts empty without a file', async () => { + const storage = await loadModule(); + + expect(await storage.loadRadarPoleSideOverrides()).toBe(true); + expect(storage.radarPoleSideOverrides.snapshot()).toEqual({}); + }); + + it('reads the file once, before anything looks a correction up', async () => { + fs.writeFileSync( + path.join(userData, FILE), + JSON.stringify({ '95': { start: false, table: true } }) + ); + const storage = await loadModule(); + await storage.loadRadarPoleSideOverrides(); + fs.writeFileSync(path.join(userData, FILE), '{}'); + await storage.loadRadarPoleSideOverrides(); + + expect(storage.radarPoleSideOverrides.override(95, 'start')).toBe(false); + }); + + it('writes a flip when flushed', async () => { + const storage = await loadModule(); + await storage.loadRadarPoleSideOverrides(); + storage.radarPoleSideOverrides.setTrack(95, true, 'start'); + storage.radarPoleSideOverrides.flip(); + + await storage.flushRadarPoleSideOverrides(); + + expect( + JSON.parse(fs.readFileSync(path.join(userData, FILE), 'utf8')) + ).toEqual({ '95': { start: false, table: true } }); + }); + + it('flushes a correction whose write failed', async () => { + const storage = await loadModule(); + await storage.loadRadarPoleSideOverrides(); + const gone = path.join(userData, 'later'); + userData = gone; + storage.radarPoleSideOverrides.setTrack(95, true, 'start'); + storage.radarPoleSideOverrides.flip(); + await storage.flushRadarPoleSideOverrides(); + expect(fs.existsSync(path.join(gone, FILE))).toBe(false); + + fs.mkdirSync(gone); + await storage.flushRadarPoleSideOverrides(); + + expect(fs.existsSync(path.join(gone, FILE))).toBe(true); + fs.rmSync(gone, { recursive: true, force: true }); + }); + + it('never replaces a file it could not read', async () => { + // A directory where the file should be: reading fails with EISDIR. + fs.mkdirSync(path.join(userData, FILE)); + const storage = await loadModule(); + + expect(await storage.loadRadarPoleSideOverrides()).toBe(false); + storage.radarPoleSideOverrides.setTrack(95, true, 'start'); + storage.radarPoleSideOverrides.flip(); + await storage.flushRadarPoleSideOverrides(); + + expect(fs.statSync(path.join(userData, FILE)).isDirectory()).toBe(true); + }); + + it('moves an invalid file aside and starts empty', async () => { + fs.writeFileSync(path.join(userData, FILE), '{oops'); + const storage = await loadModule(); + + expect(await storage.loadRadarPoleSideOverrides()).toBe(true); + expect(storage.radarPoleSideOverrides.snapshot()).toEqual({}); + expect(fs.existsSync(path.join(userData, `${FILE}.invalid`))).toBe(true); + }); +}); diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index 2133bb921..5d43f8f43 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -21,10 +21,13 @@ let pending: PoleSideOverrides | undefined; let writeTimer: NodeJS.Timeout | null = null; let writeInFlight: Promise = Promise.resolve(); +/** The file exists but could not be read: saving would replace good data. */ +let saveBlocked = false; + const write = async (): Promise => { const overrides = pending; pending = undefined; - if (!overrides) return; + if (!overrides || saveBlocked) return; const temp = `${filePath()}.${process.pid}.tmp`; try { await fsp.writeFile(temp, JSON.stringify(overrides)); @@ -53,21 +56,51 @@ export const flushRadarPoleSideOverrides = (): Promise => { }; let stored: unknown; +let loaded: Promise | undefined; -/** - * Reads the file ahead of the radar, which looks corrections up synchronously - * and so must not be created before this resolves. - */ -export const loadRadarPoleSideOverrides = async (): Promise => { - try { - stored = JSON.parse(await fsp.readFile(filePath(), 'utf8')) as unknown; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - logger.warn('[RadarPoleSide] Failed to read', filePath(), error); +const READ_RETRY_MS = 50; + +const readFile = async (): Promise => { + for (let attempt = 0; ; attempt++) { + try { + return await fsp.readFile(filePath(), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + // A virus scanner can hold the file for a moment on Windows. + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, READ_RETRY_MS)); + continue; + } + saveBlocked = true; + logger.error('[RadarPoleSide] Failed to read', filePath(), error); + return undefined; } } }; +/** + * Reads the file once, ahead of the radar, which looks corrections up + * synchronously. Everything that reads corrections awaits this first. + * Resolves false when the file exists but could not be used. + */ +export const loadRadarPoleSideOverrides = (): Promise => + (loaded ??= (async () => { + const text = await readFile(); + if (text === undefined) return !saveBlocked; + try { + stored = JSON.parse(text) as unknown; + } catch (error) { + // Not JSON: keep it aside rather than lose it under the next save. + logger.error('[RadarPoleSide] Invalid file, moved aside', error); + await fsp + .rename(filePath(), `${filePath()}.invalid`) + .catch(() => { + saveBlocked = true; + }); + } + return !saveBlocked; + })()); + /** The player's pole side corrections, kept in a small file of their own. */ export const radarPoleSideOverrides = new RadarPoleSideOverrides({ read: () => stored, From 180e2e08f637f15c5799fcf51f79b558e2f58e3f Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 23:29:38 +0300 Subject: [PATCH 24/32] fix: recheck the analytics opt-out right before sending the pole side report Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/analytics.ts b/src/app/analytics.ts index 003181363..b41244c65 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -305,6 +305,8 @@ export class Analytics { if (this.poleSideEvent?.hash !== report.hash) { this.poleSideEvent = { hash: report.hash, uuid: crypto.randomUUID() }; } + // The player may have opted out while the file load was pending. + if (getAnalyticsOptOut()) return; await this.provider.captureConfirmed({ distinctId: userId, event: 'radar_pole_side', From 1aeb24c4fb5df07431f46dc7d714fdc8ec19bdc6 Mon Sep 17 00:00:00 2001 From: estem8 Date: Sun, 18 Oct 2026 23:34:24 +0300 Subject: [PATCH 25/32] fix: forget a sudden drop once the car keeps moving above a crawl for 5 s Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/radarHazards.spec.ts | 8 ++++++++ src/app/processors/radarHazards.ts | 22 +++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/app/processors/radarHazards.spec.ts b/src/app/processors/radarHazards.spec.ts index 5d4b56248..fcf5536dc 100644 --- a/src/app/processors/radarHazards.spec.ts +++ b/src/app/processors/radarHazards.spec.ts @@ -186,6 +186,14 @@ describe('RadarHazardTracker', () => { expect(field.run(0.2, { speed: 50 })).toEqual([]); }); + it('forgets a drop once the car keeps moving above a crawl for 5 s', () => { + const field = new Field(); + field.run(5); + field.run(1, { speed: 2 }); + expect(field.run(4, { speed: 35 })).toHaveLength(1); + expect(field.run(2, { speed: 35 })).toEqual([]); + }); + it('shows an excursion, then the rejoin, then nothing', () => { const field = new Field(); field.run(5); diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index 9df9e9618..9f895e506 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -32,6 +32,8 @@ const CRAWL_MS = 12; const SHEDDING_SHARE = 0.85; /** Back above this share of the speed before a drop, the car is racing again. */ const RECOVER_SHARE = 0.75; +/** Seconds a car must keep moving above CRAWL_MS before a drop is forgotten. */ +const DROP_FORGET_S = 5; /** Seconds a car must stay slow before it is reported. */ const SLOW_HOLD_S = 1; /** Seconds after a sudden drop before it is reported, to skip a single bad frame. */ @@ -58,6 +60,8 @@ interface CarHazardState { /** Speed the car had before a sudden drop, or -1 while it has not dropped. */ droppedFrom: number; droppedAt: number; + /** Session time the car last got above CRAWL_MS since the drop, or -1. */ + movingSince: number; /** Session time the car was last below STOPPED_MS, or -1 while it moves. */ stoppedSince: number; /** Session time the car was last slow for the focus car, or -1. */ @@ -79,6 +83,7 @@ const freshState = (): CarHazardState => ({ bucketAt: -1, droppedFrom: -1, droppedAt: -1, + movingSince: -1, stoppedSince: -1, slowSince: -1, offSince: -1, @@ -221,11 +226,18 @@ export class RadarHazardTracker { ) { state.droppedFrom = state.peak; state.droppedAt = time; - } else if ( - state.droppedFrom >= 0 && - speed >= state.droppedFrom * RECOVER_SHARE - ) { - state.droppedFrom = -1; + state.movingSince = -1; + } else if (state.droppedFrom >= 0) { + // Still driving, though not back up to speed: the drop is over after a + // while and the ordinary slow rule judges the car. + if (speed < CRAWL_MS) state.movingSince = -1; + else if (state.movingSince < 0) state.movingSince = time; + if ( + speed >= state.droppedFrom * RECOVER_SHARE || + (state.movingSince >= 0 && time - state.movingSince >= DROP_FORGET_S) + ) { + state.droppedFrom = -1; + } } const off = surface === TrackLocation.OffTrack; From 2af30a78ab50e70eff9fdcd17cf4559b7b2630cb Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 08:42:05 +0300 Subject: [PATCH 26/32] fix: keep pole side types out of the processors, log no file paths, store the report hash asynchronously Co-Authored-By: Claude Sonnet 5.5 --- src/app/analytics.radarPoleSide.spec.ts | 8 +++++ src/app/analytics.ts | 13 +++++--- src/app/processors/RadarProcessor.ts | 2 +- src/app/processors/processorRegistry.ts | 2 +- src/app/radarPoleSideOverrides.ts | 2 +- src/app/storage/radarPoleSideOverrides.ts | 16 ++++++---- src/app/storage/radarPoleSideReportHash.ts | 32 +++++++++++++++++++ src/types/index.ts | 1 + .../radarPoleSideSettings.ts | 0 9 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 src/app/storage/radarPoleSideReportHash.ts rename src/{app/processors => types}/radarPoleSideSettings.ts (100%) diff --git a/src/app/analytics.radarPoleSide.spec.ts b/src/app/analytics.radarPoleSide.spec.ts index 68cfd7a64..fd5d0a531 100644 --- a/src/app/analytics.radarPoleSide.spec.ts +++ b/src/app/analytics.radarPoleSide.spec.ts @@ -36,6 +36,14 @@ vi.mock('./storage/storage', () => ({ writeData: vi.fn((key: string, value: unknown) => store.data.set(key, value)), })); +vi.mock('./storage/radarPoleSideReportHash', () => ({ + readRadarPoleSideReportHash: async () => + store.data.get('radarPoleSideReportHash'), + writeRadarPoleSideReportHash: async (hash: string) => { + store.data.set('radarPoleSideReportHash', hash); + }, +})); + vi.mock('./storage/analytics', () => ({ getAnalyticsOptOut: vi.fn().mockReturnValue(false), })); diff --git a/src/app/analytics.ts b/src/app/analytics.ts index b41244c65..40be79b9a 100644 --- a/src/app/analytics.ts +++ b/src/app/analytics.ts @@ -5,6 +5,10 @@ import os from 'node:os'; import crypto from 'node:crypto'; import type { DashboardLayout } from '@irdashies/types'; import { readData, writeData } from './storage/storage'; +import { + readRadarPoleSideReportHash, + writeRadarPoleSideReportHash, +} from './storage/radarPoleSideReportHash'; import { getAnalyticsOptOut } from './storage/analytics'; import logger from './logger'; import type ElectronLog from 'electron-log'; @@ -31,9 +35,6 @@ declare const POSTHOG_KEY: string; */ const POLE_SIDE_REPORT_DELAY_MS = 60 * 1000; -/** Hash of the last radar pole side report PostHog acknowledged. */ -const POLE_SIDE_HASH_KEY = 'radarPoleSideReportHash'; - const DEDUP_WINDOW_MS = 60 * 60 * 1000; const DEDUP_KEY_LENGTH = 30; const DEDUP_MAX_ENTRIES = 5000; @@ -286,7 +287,7 @@ export class Analytics { radarPoleSideOverrides.snapshot(), version ); - const stored = readData(POLE_SIDE_HASH_KEY); + const stored = await readRadarPoleSideReportHash(); const last = typeof stored === 'string' && /^[0-9a-f]{64}$/.test(stored) ? stored @@ -314,7 +315,9 @@ export class Analytics { properties: report.properties, }); // Recorded only after PostHog acknowledged it, so a failure retries. - if (!getAnalyticsOptOut()) writeData(POLE_SIDE_HASH_KEY, report.hash); + if (!getAnalyticsOptOut()) { + await writeRadarPoleSideReportHash(report.hash); + } } catch { logger.warn('[Analytics] Could not publish radar pole side report'); } diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 2aff13436..360e65489 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -28,7 +28,7 @@ import { NO_POLE_SIDE_SETTINGS, type PoleSideSettings, type PoleSituation, -} from './radarPoleSideSettings'; +} from '@irdashies/types'; import { wrapDelta } from './radarMath'; /** diff --git a/src/app/processors/processorRegistry.ts b/src/app/processors/processorRegistry.ts index 7b0dfee1c..727a88d0a 100644 --- a/src/app/processors/processorRegistry.ts +++ b/src/app/processors/processorRegistry.ts @@ -30,7 +30,7 @@ import { CarSystemsProcessor } from './CarSystemsProcessor'; import { SessionTimingProcessor } from './SessionTimingProcessor'; import { StandingsProcessor } from './StandingsProcessor'; import { TrackStateProcessor } from './TrackStateProcessor'; -import type { PoleSideSettings } from './radarPoleSideSettings'; +import type { PoleSideSettings } from '@irdashies/types'; interface ProcessorRegistryOptions { referenceLapPersistence: ReferenceLapPersistence; diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts index a1e545952..7b549d3a4 100644 --- a/src/app/radarPoleSideOverrides.ts +++ b/src/app/radarPoleSideOverrides.ts @@ -2,7 +2,7 @@ import { POLE_SITUATIONS, type PoleSideSettings, type PoleSituation, -} from './processors/radarPoleSideSettings'; +} from '@irdashies/types'; /** * One track's corrections: the side (true: left) the player chose for a race diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index 5d43f8f43..322342fc7 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -15,6 +15,10 @@ import { const filePath = () => path.join(app.getPath('userData'), 'radar-pole-side-overrides.json'); +/** Error logs reach analytics: a filesystem error carries the user's path. */ +const errorCode = (error: unknown): string => + (error as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + const WRITE_DEBOUNCE_MS = 250; let pending: PoleSideOverrides | undefined; @@ -33,7 +37,7 @@ const write = async (): Promise => { await fsp.writeFile(temp, JSON.stringify(overrides)); await fsp.rename(temp, filePath()); } catch (error) { - logger.error('[RadarPoleSide] Failed to write', filePath(), error); + logger.error('[RadarPoleSide] Failed to write', errorCode(error)); // Keep the correction for the next write, unless a newer one came in. pending ??= overrides; await fsp.unlink(temp).catch(() => undefined); @@ -72,7 +76,7 @@ const readFile = async (): Promise => { continue; } saveBlocked = true; - logger.error('[RadarPoleSide] Failed to read', filePath(), error); + logger.error('[RadarPoleSide] Failed to read', errorCode(error)); return undefined; } } @@ -92,11 +96,9 @@ export const loadRadarPoleSideOverrides = (): Promise => } catch (error) { // Not JSON: keep it aside rather than lose it under the next save. logger.error('[RadarPoleSide] Invalid file, moved aside', error); - await fsp - .rename(filePath(), `${filePath()}.invalid`) - .catch(() => { - saveBlocked = true; - }); + await fsp.rename(filePath(), `${filePath()}.invalid`).catch(() => { + saveBlocked = true; + }); } return !saveBlocked; })()); diff --git a/src/app/storage/radarPoleSideReportHash.ts b/src/app/storage/radarPoleSideReportHash.ts new file mode 100644 index 000000000..360661a8b --- /dev/null +++ b/src/app/storage/radarPoleSideReportHash.ts @@ -0,0 +1,32 @@ +import { app } from 'electron'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import logger from '../logger'; + +const filePath = () => + path.join(app.getPath('userData'), 'radar-pole-side-report-hash.txt'); + +/** The stored text of the last acknowledged report's hash, if any. */ +export const readRadarPoleSideReportHash = async (): Promise< + string | undefined +> => { + try { + return (await fsp.readFile(filePath(), 'utf8')).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[RadarPoleSide] Could not read report hash'); + } + return undefined; + } +}; + +/** Awaited, not debounced: written at most once a minute, after a send. */ +export const writeRadarPoleSideReportHash = async ( + hash: string +): Promise => { + try { + await fsp.writeFile(filePath(), hash); + } catch { + logger.warn('[RadarPoleSide] Could not save report hash'); + } +}; diff --git a/src/types/index.ts b/src/types/index.ts index 812d81f52..6a2b4b507 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,3 +36,4 @@ export * from './widgets'; export * from './releaseNotes'; export * from './releaseNotesFixture'; export * from './tapeReplay'; +export * from './radarPoleSideSettings'; diff --git a/src/app/processors/radarPoleSideSettings.ts b/src/types/radarPoleSideSettings.ts similarity index 100% rename from src/app/processors/radarPoleSideSettings.ts rename to src/types/radarPoleSideSettings.ts From 7311a13434e9f179f1261405eedf52f1f8840e57 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 08:54:50 +0300 Subject: [PATCH 27/32] fix: address the code review of the pole side and hazard changes Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.ts | 18 +++++++-- src/app/processors/radarHazards.ts | 17 +++++---- src/app/radarPoleSideOverrides.ts | 37 +++++++------------ src/app/storage/radarPoleSideOverrides.ts | 10 +++-- src/frontend/components/Radar/radarDraw.ts | 6 ++- .../components/Radar/radarDrawHazards.ts | 6 ++- 6 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 360e65489..547fae749 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -109,6 +109,8 @@ export class RadarProcessor implements TelemetryProcessor { private trackLength = 0; private readonly excluded = new Set(); + /** The cars on the focus car's side of the pit wall, reused every tick. */ + private readonly sameSide: RadarCar[] = []; private previousPcts: number[] = []; private speeds: number[] = []; private previousTime = -1; @@ -156,6 +158,7 @@ export class RadarProcessor implements TelemetryProcessor { // and so on; anything that is not road racing is an oval const trackType = session?.WeekendInfo?.TrackType ?? ''; const previousTrackId = this.trackId; + const previousTable = this.tablePoleLeft; this.trackId = session?.WeekendInfo?.TrackID; this.tablePoleLeft = paceFieldPoleLeft( this.trackId, @@ -163,8 +166,13 @@ export class RadarProcessor implements TelemetryProcessor { trackType !== '' && !/road|rally/i.test(trackType) ); // iRacing sends session info again and again; only a new track starts the - // pole side over, or a flip would lose its message and its situation. - if (!this.poleTrackKnown || this.trackId !== previousTrackId) { + // pole side over, or a flip would lose its message and its situation. A + // corrected track name that changes the table side starts it over too. + if ( + !this.poleTrackKnown || + this.trackId !== previousTrackId || + this.tablePoleLeft !== previousTable + ) { this.poleTrackKnown = true; this.situation = 'start'; this.poleSides.setTrack(this.trackId, this.tablePoleLeft, this.situation); @@ -274,7 +282,11 @@ export class RadarProcessor implements TelemetryProcessor { cars.sort((a, b) => Math.abs(a.dist) - Math.abs(b.dist)); // Cars across the pit wall are beside the track, not in a lane of it. const focusOnPit = onPitRoad[focus] === true; - const sameSide = cars.filter((car) => car.onPitRoad === focusOnPit); + const sameSide = this.sameSide; + sameSide.length = 0; + for (const car of cars) { + if (car.onPitRoad === focusOnPit) sameSide.push(car); + } const spotterSpeaks = this.assignLanes( frame, time, diff --git a/src/app/processors/radarHazards.ts b/src/app/processors/radarHazards.ts index 9f895e506..4ac390d52 100644 --- a/src/app/processors/radarHazards.ts +++ b/src/app/processors/radarHazards.ts @@ -259,10 +259,14 @@ export class RadarHazardTracker { } if (off) { + state.slowSince = -1; return time - state.offSince >= OFF_HOLD_S ? 'off' : null; } if (state.rejoinedAt >= 0) { - if (time - state.rejoinedAt <= REJOIN_S) return 'rejoin'; + if (time - state.rejoinedAt <= REJOIN_S) { + state.slowSince = -1; + return 'rejoin'; + } state.rejoinedAt = -1; } @@ -271,14 +275,13 @@ export class RadarHazardTracker { } else { state.stoppedSince = -1; } - if (state.stoppedSince >= 0 && time - state.stoppedSince >= SLOW_HOLD_S) { - return 'crash'; - } if ( - state.droppedFrom >= 0 && - reachable && - time - state.droppedAt >= SUDDEN_HOLD_S + (state.stoppedSince >= 0 && time - state.stoppedSince >= SLOW_HOLD_S) || + (state.droppedFrom >= 0 && + reachable && + time - state.droppedAt >= SUDDEN_HOLD_S) ) { + state.slowSince = -1; return 'crash'; } diff --git a/src/app/radarPoleSideOverrides.ts b/src/app/radarPoleSideOverrides.ts index 7b549d3a4..1582caf3d 100644 --- a/src/app/radarPoleSideOverrides.ts +++ b/src/app/radarPoleSideOverrides.ts @@ -79,8 +79,12 @@ export class RadarPoleSideOverrides implements PoleSideSettings { } private load(): PoleSideOverrides { - this.overrides ??= parsePoleSideOverrides(this.persistence.read()); - return this.overrides; + if (this.overrides) return this.overrides; + const raw = this.persistence.read(); + const parsed = parsePoleSideOverrides(raw); + // undefined: the file is not read yet, so do not keep an empty answer. + if (raw !== undefined) this.overrides = parsed; + return parsed; } override( @@ -126,30 +130,17 @@ export class RadarPoleSideOverrides implements PoleSideSettings { const poleLeft = !current; // Only a real difference from the table is kept. const before = Object.hasOwn(stored, key) ? stored[key] : undefined; - const sides = Object.fromEntries( - POLE_SITUATIONS.map((which): [PoleSituation, boolean | undefined] => [ - which, - which === situation - ? poleLeft === tableLeft - ? undefined - : poleLeft - : before?.[which], - ]) - ) as Record; + const entry: PoleSideCorrection = { + ...before, + [situation]: poleLeft === tableLeft ? undefined : poleLeft, + }; + // A grid has no table: it keeps the table the start or restart recorded. + if (situation !== 'grid') entry.table = tableLeft; const overrides: PoleSideOverrides = Object.fromEntries( Object.entries(stored).filter(([id]) => id !== key) ); - if (POLE_SITUATIONS.some((which) => sides[which] !== undefined)) { - overrides[key] = { - ...Object.fromEntries( - POLE_SITUATIONS.filter((which) => sides[which] !== undefined).map( - (which) => [which, sides[which]] - ) - ), - ...(situation !== 'grid' - ? { table: tableLeft } - : before?.table !== undefined && { table: before.table }), - }; + if (POLE_SITUATIONS.some((which) => entry[which] !== undefined)) { + overrides[key] = JSON.parse(JSON.stringify(entry)) as PoleSideCorrection; } this.persistence.write(overrides); this.overrides = overrides; diff --git a/src/app/storage/radarPoleSideOverrides.ts b/src/app/storage/radarPoleSideOverrides.ts index 322342fc7..29efb951a 100644 --- a/src/app/storage/radarPoleSideOverrides.ts +++ b/src/app/storage/radarPoleSideOverrides.ts @@ -90,12 +90,16 @@ const readFile = async (): Promise => { export const loadRadarPoleSideOverrides = (): Promise => (loaded ??= (async () => { const text = await readFile(); - if (text === undefined) return !saveBlocked; + if (text === undefined) { + stored = null; + return !saveBlocked; + } try { stored = JSON.parse(text) as unknown; - } catch (error) { + } catch { + stored = null; // Not JSON: keep it aside rather than lose it under the next save. - logger.error('[RadarPoleSide] Invalid file, moved aside', error); + logger.error('[RadarPoleSide] Invalid file, moved aside'); await fsp.rename(filePath(), `${filePath()}.invalid`).catch(() => { saveBlocked = true; }); diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index 91fc1107b..9547ada0b 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -582,7 +582,11 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { hazards[beyond], others, style, - flash + flash, + roadLateral( + scene.roadLane ?? 0, + scene.laneWidth ?? style.carWidth + 0.7 + ) ); } } diff --git a/src/frontend/components/Radar/radarDrawHazards.ts b/src/frontend/components/Radar/radarDrawHazards.ts index 8597380b6..74a543d81 100644 --- a/src/frontend/components/Radar/radarDrawHazards.ts +++ b/src/frontend/components/Radar/radarDrawHazards.ts @@ -93,9 +93,11 @@ export const drawHazardMarker = ( hazard: RadarDrawHazard, others: number, style: RadarStyle, - flash: number + flash: number, + /** Where the road is drawn across the disc, as `roadLateral` gives it. */ + roadLateral = 0 ) => { - projector.project(hazard.dist, 0, pose); + projector.project(hazard.dist, roadLateral, pose); let bearing = Math.atan2(pose.y - centre, pose.x - centre); // Measured from straight up, then kept in front of us. let fromUp = bearing + Math.PI / 2; From 1cdc197c0c495385b323dc80b3e9ab62a665faa3 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 08:57:33 +0300 Subject: [PATCH 28/32] fix: ease the radar road to its lane instead of jumping when a formation ends Co-Authored-By: Claude Sonnet 5.5 --- .../components/Radar/components/RadarDisplay.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/frontend/components/Radar/components/RadarDisplay.tsx b/src/frontend/components/Radar/components/RadarDisplay.tsx index 1f4c1edaa..64eb9b7b2 100644 --- a/src/frontend/components/Radar/components/RadarDisplay.tsx +++ b/src/frontend/components/Radar/components/RadarDisplay.tsx @@ -86,6 +86,8 @@ export interface RadarDisplayProps { /** Longest pause between paints still counted as driving, for the dashes. */ const MAX_PAINT_GAP_S = 0.1; +/** How fast the road follows its lane, per second. */ +const ROAD_EASE_PER_S = 8; const FALLBACK_APPEARANCE: RadarCarAppearance = { fill: '#94a3b8', @@ -163,6 +165,8 @@ export const RadarDisplay = memo( let handle = 0; // Our own odometer: lap distance jumps at the line, this never does. let travelled = 0; + // The road eases to its lane instead of jumping when a formation ends. + let roadLane = latest.current.frame.roadLane ?? 0; let lastPaint = performance.now(); let frameMs: number | undefined; const paint = () => { @@ -179,6 +183,9 @@ export const RadarDisplay = memo( ); lastPaint = started; travelled += snap.playerSpeed * sincePaint; + roadLane += + ((snap.roadLane ?? 0) - roadLane) * + Math.min(sincePaint * ROAD_EASE_PER_S, 1); const laneWidth = look.carWidth + current.laneGapM; cars.length = snap.cars.length; for (let index = 0; index < snap.cars.length; index += 1) { @@ -274,7 +281,7 @@ export const RadarDisplay = memo( laneWidth, hazards, poleSideNotice: snap.poleSideNotice, - roadLane: snap.roadLane, + roadLane, }); frameMs = performance.now() - started; if (active) handle = requestAnimationFrame(paint); From 1b7fb496f3dc9758dddea19d96f6fb026c7bcae3 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 09:00:22 +0300 Subject: [PATCH 29/32] test: cover the pole side report hash storage Co-Authored-By: Claude Sonnet 5.5 --- .../storage/radarPoleSideReportHash.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/app/storage/radarPoleSideReportHash.spec.ts diff --git a/src/app/storage/radarPoleSideReportHash.spec.ts b/src/app/storage/radarPoleSideReportHash.spec.ts new file mode 100644 index 000000000..fd8937df4 --- /dev/null +++ b/src/app/storage/radarPoleSideReportHash.spec.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + readRadarPoleSideReportHash, + writeRadarPoleSideReportHash, +} from './radarPoleSideReportHash'; + +let userData = ''; +vi.mock('electron', () => ({ app: { getPath: () => userData } })); +vi.mock('../logger', () => ({ + default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +describe('radarPoleSideReportHash storage', () => { + beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'pole-side-hash-')); + }); + + afterEach(() => { + fs.rmSync(userData, { recursive: true, force: true }); + }); + + it('has no hash before the first report', async () => { + expect(await readRadarPoleSideReportHash()).toBeUndefined(); + }); + + it('reads back the hash it wrote', async () => { + await writeRadarPoleSideReportHash('a'.repeat(64)); + + expect(await readRadarPoleSideReportHash()).toBe('a'.repeat(64)); + }); + + it('does not throw when the folder is gone', async () => { + userData = path.join(userData, 'gone'); + + await expect( + writeRadarPoleSideReportHash('a'.repeat(64)) + ).resolves.toBeUndefined(); + expect(await readRadarPoleSideReportHash()).toBeUndefined(); + }); +}); From c1723d0d88ff5f4ea77444266dff18dbf824d174 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 09:14:41 +0300 Subject: [PATCH 30/32] test: use promise-based fs in the pole side hash spec hooks Co-Authored-By: Claude Sonnet 5.5 --- src/app/storage/radarPoleSideReportHash.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/storage/radarPoleSideReportHash.spec.ts b/src/app/storage/radarPoleSideReportHash.spec.ts index fd8937df4..ac7a16e9a 100644 --- a/src/app/storage/radarPoleSideReportHash.spec.ts +++ b/src/app/storage/radarPoleSideReportHash.spec.ts @@ -14,12 +14,14 @@ vi.mock('../logger', () => ({ })); describe('radarPoleSideReportHash storage', () => { - beforeEach(() => { - userData = fs.mkdtempSync(path.join(os.tmpdir(), 'pole-side-hash-')); + beforeEach(async () => { + userData = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'pole-side-hash-') + ); }); - afterEach(() => { - fs.rmSync(userData, { recursive: true, force: true }); + afterEach(async () => { + await fs.promises.rm(userData, { recursive: true, force: true }); }); it('has no hash before the first report', async () => { From cb71bb1a172f097e3609eb4ca04e28eb072e3ee8 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 19 Oct 2026 09:16:32 +0300 Subject: [PATCH 31/32] test: use promise-based fs in the pole side overrides storage spec Co-Authored-By: Claude Sonnet 5.5 --- .../storage/radarPoleSideOverrides.spec.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/app/storage/radarPoleSideOverrides.spec.ts b/src/app/storage/radarPoleSideOverrides.spec.ts index d427e176a..db7f43302 100644 --- a/src/app/storage/radarPoleSideOverrides.spec.ts +++ b/src/app/storage/radarPoleSideOverrides.spec.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs'; +import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -9,6 +9,12 @@ vi.mock('../logger', () => ({ default: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, })); +const exists = (file: string) => + fs.access(file).then( + () => true, + () => false + ); + const FILE = 'radar-pole-side-overrides.json'; const loadModule = async () => { @@ -17,12 +23,12 @@ const loadModule = async () => { }; describe('radarPoleSideOverrides storage', () => { - beforeEach(() => { - userData = fs.mkdtempSync(path.join(os.tmpdir(), 'pole-side-')); + beforeEach(async () => { + userData = await fs.mkdtemp(path.join(os.tmpdir(), 'pole-side-')); }); - afterEach(() => { - fs.rmSync(userData, { recursive: true, force: true }); + afterEach(async () => { + await fs.rm(userData, { recursive: true, force: true }); }); it('starts empty without a file', async () => { @@ -33,13 +39,13 @@ describe('radarPoleSideOverrides storage', () => { }); it('reads the file once, before anything looks a correction up', async () => { - fs.writeFileSync( + await fs.writeFile( path.join(userData, FILE), JSON.stringify({ '95': { start: false, table: true } }) ); const storage = await loadModule(); await storage.loadRadarPoleSideOverrides(); - fs.writeFileSync(path.join(userData, FILE), '{}'); + await fs.writeFile(path.join(userData, FILE), '{}'); await storage.loadRadarPoleSideOverrides(); expect(storage.radarPoleSideOverrides.override(95, 'start')).toBe(false); @@ -54,7 +60,7 @@ describe('radarPoleSideOverrides storage', () => { await storage.flushRadarPoleSideOverrides(); expect( - JSON.parse(fs.readFileSync(path.join(userData, FILE), 'utf8')) + JSON.parse(await fs.readFile(path.join(userData, FILE), 'utf8')) ).toEqual({ '95': { start: false, table: true } }); }); @@ -66,18 +72,18 @@ describe('radarPoleSideOverrides storage', () => { storage.radarPoleSideOverrides.setTrack(95, true, 'start'); storage.radarPoleSideOverrides.flip(); await storage.flushRadarPoleSideOverrides(); - expect(fs.existsSync(path.join(gone, FILE))).toBe(false); + expect(await exists(path.join(gone, FILE))).toBe(false); - fs.mkdirSync(gone); + await fs.mkdir(gone); await storage.flushRadarPoleSideOverrides(); - expect(fs.existsSync(path.join(gone, FILE))).toBe(true); - fs.rmSync(gone, { recursive: true, force: true }); + expect(await exists(path.join(gone, FILE))).toBe(true); + await fs.rm(gone, { recursive: true, force: true }); }); it('never replaces a file it could not read', async () => { // A directory where the file should be: reading fails with EISDIR. - fs.mkdirSync(path.join(userData, FILE)); + await fs.mkdir(path.join(userData, FILE)); const storage = await loadModule(); expect(await storage.loadRadarPoleSideOverrides()).toBe(false); @@ -85,15 +91,15 @@ describe('radarPoleSideOverrides storage', () => { storage.radarPoleSideOverrides.flip(); await storage.flushRadarPoleSideOverrides(); - expect(fs.statSync(path.join(userData, FILE)).isDirectory()).toBe(true); + expect((await fs.stat(path.join(userData, FILE))).isDirectory()).toBe(true); }); it('moves an invalid file aside and starts empty', async () => { - fs.writeFileSync(path.join(userData, FILE), '{oops'); + await fs.writeFile(path.join(userData, FILE), '{oops'); const storage = await loadModule(); expect(await storage.loadRadarPoleSideOverrides()).toBe(true); expect(storage.radarPoleSideOverrides.snapshot()).toEqual({}); - expect(fs.existsSync(path.join(userData, `${FILE}.invalid`))).toBe(true); + expect(await exists(path.join(userData, `${FILE}.invalid`))).toBe(true); }); }); From 1541a9c008ecb418d509744043388f529cc0bf17 Mon Sep 17 00:00:00 2001 From: estem8 Date: Mon, 26 Oct 2026 00:17:39 +0300 Subject: [PATCH 32/32] fix: radar pace car on the shifted road, drop the pole notice on a replay rewind Co-Authored-By: Claude Sonnet 5.5 --- src/app/processors/RadarProcessor.spec.ts | 5 +++++ src/app/processors/RadarProcessor.ts | 2 ++ src/frontend/components/Radar/radarDraw.ts | 20 ++++++++------------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/app/processors/RadarProcessor.spec.ts b/src/app/processors/RadarProcessor.spec.ts index 0b09a33ad..3fe4a2ef8 100644 --- a/src/app/processors/RadarProcessor.spec.ts +++ b/src/app/processors/RadarProcessor.spec.ts @@ -458,6 +458,11 @@ describe('RadarProcessor', () => { processor.onFrame(at(105)); expect(processor.snapshot().poleSideNotice).toBeNull(); + + // Scrubbing back to the flip must not bring the old notice back. + processor.onFrame(at(99)); + processor.onFrame(at(101)); + expect(processor.snapshot().poleSideNotice).toBeNull(); }); it('keeps the start and the restart apart, saying nothing when only the situation changes', () => { diff --git a/src/app/processors/RadarProcessor.ts b/src/app/processors/RadarProcessor.ts index 547fae749..73ad7a327 100644 --- a/src/app/processors/RadarProcessor.ts +++ b/src/app/processors/RadarProcessor.ts @@ -458,6 +458,8 @@ export class RadarProcessor implements TelemetryProcessor { dt > MAX_FRAME_GAP_S || this.trackLength <= 0; this.previousTime = time; + // A scrubbed-back replay must not replay the old notice. + if (dt < 0) this.poleNoticeUntil = -Infinity; if (restart) this.speedsSettleUntil = time + SPEED_SETTLE_S; // Weight of the newest sample, scaled so the average responds the same // at any call rate. diff --git a/src/frontend/components/Radar/radarDraw.ts b/src/frontend/components/Radar/radarDraw.ts index 9547ada0b..3f0c2bc2e 100644 --- a/src/frontend/components/Radar/radarDraw.ts +++ b/src/frontend/components/Radar/radarDraw.ts @@ -254,13 +254,12 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { resetRimWarnings(); resetDebugLabels(); + const roadX = roadLateral( + scene.roadLane ?? 0, + scene.laneWidth ?? style.carWidth + 0.7 + ); if (style.showTrackMap) { - drawRoad( - ctx, - style, - pixelsPerMetre, - roadLateral(scene.roadLane ?? 0, scene.laneWidth ?? style.carWidth + 0.7) - ); + drawRoad(ctx, style, pixelsPerMetre, roadX); } if (style.showCrosshair) { drawCrosshair(ctx, centre, radius, style, pixelsPerMetre, scene.travelled); @@ -517,7 +516,7 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { car.width * pixelsPerMetre ); } else { - projector.project(follow.dist, 0, pose); + projector.project(follow.dist, roadX, pose); drawFollowLine(ctx, centre, pose, length, length); drawCarBody( ctx, @@ -561,7 +560,7 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { } ctx.globalAlpha = 1; if (follow && followBeyondRange) { - projector.project(follow.dist, 0, pose); + projector.project(follow.dist, roadX, pose); drawFollowPointer(ctx, centre, radius, follow, style.metric); } const hazards = scene.hazards; @@ -583,10 +582,7 @@ export const drawRadar = (ctx: CanvasRenderingContext2D, scene: RadarScene) => { others, style, flash, - roadLateral( - scene.roadLane ?? 0, - scene.laneWidth ?? style.carWidth + 0.7 - ) + roadX ); } }