-
Notifications
You must be signed in to change notification settings - Fork 77
fix: smooth blind spot monitor #685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
8f5b397
2734d93
ad9f666
aad9047
a3ffdce
1cd5481
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import type { Telemetry } from '@irdashies/types'; | ||
| import { BlindSpotProcessor } from './BlindSpotProcessor'; | ||
|
|
||
| const frame = (carLeftRight: number, positions: number[], isOnTrack = true) => | ||
| ({ | ||
| CarLeftRight: { value: [carLeftRight] }, | ||
| CarIdxLapDistPct: { value: positions }, | ||
| IsOnTrack: { value: [isOnTrack] }, | ||
| }) as unknown as Telemetry; | ||
|
|
||
| describe('BlindSpotProcessor', () => { | ||
| it('publishes overlap state and full-precision positions together', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(2, [0.123456, 0.123789])); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 2, | ||
| carIdxLapDistPct: [0.123456, 0.123789], | ||
| isOnTrack: true, | ||
| version: 1, | ||
| }); | ||
| }); | ||
|
|
||
| it('clears safety state at lifecycle boundaries', () => { | ||
| const processor = new BlindSpotProcessor(); | ||
| processor.onFrame(frame(3, [0.5, 0.6])); | ||
| processor.onLifecycle({ type: 'disconnect' }); | ||
|
|
||
| expect(processor.snapshot()).toMatchObject({ | ||
| carLeftRight: 0, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: false, | ||
| version: 2, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import type { | ||
| BlindSpotSnapshot, | ||
| Session, | ||
| SessionLifecycleEvent, | ||
| Telemetry, | ||
| } from '@irdashies/types'; | ||
| import type { TelemetryProcessor } from './TelemetryProcessor'; | ||
|
|
||
| const scalar = (frame: Telemetry, key: string): unknown => | ||
| (frame as unknown as Record<string, { value?: unknown[] } | undefined>)[key] | ||
| ?.value?.[0]; | ||
|
|
||
| const copyPositions = (target: number[], frame: Telemetry): boolean => { | ||
| const source = frame.CarIdxLapDistPct?.value ?? []; | ||
| let changed = target.length !== source.length; | ||
| target.length = source.length; | ||
| for (let index = 0; index < source.length; index += 1) { | ||
| if (target[index] !== source[index]) changed = true; | ||
| target[index] = source[index]; | ||
| } | ||
| return changed; | ||
| }; | ||
|
|
||
| export class BlindSpotProcessor implements TelemetryProcessor<BlindSpotSnapshot> { | ||
| readonly channel = 'blind-spot.snapshot'; | ||
| readonly tickRateHz = 60; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the blind-spot widget is enabled, every full-precision AGENTS.md reference: AGENTS.md:L12-L18 Useful? React with 👍 / 👎. |
||
|
|
||
| private readonly latest: BlindSpotSnapshot = { | ||
| carLeftRight: 0, | ||
| carIdxLapDistPct: [], | ||
| isOnTrack: false, | ||
| version: 0, | ||
| }; | ||
|
|
||
| init(session: Session): void { | ||
| void session; | ||
| } | ||
|
|
||
| onFrame(frame: Telemetry): void { | ||
| let changed = copyPositions( | ||
| this.latest.carIdxLapDistPct as number[], | ||
| frame | ||
| ); | ||
| const carLeftRight = scalar(frame, 'CarLeftRight'); | ||
| const isOnTrack = scalar(frame, 'IsOnTrack'); | ||
| changed = | ||
| this.set( | ||
| 'carLeftRight', | ||
| typeof carLeftRight === 'number' ? carLeftRight : 0 | ||
| ) || changed; | ||
| changed = | ||
| this.set('isOnTrack', isOnTrack === true || isOnTrack === 1) || changed; | ||
| if (changed) this.latest.version += 1; | ||
| } | ||
|
|
||
| onLifecycle(event: SessionLifecycleEvent): void { | ||
| if (event.type === 'enter') return; | ||
| (this.latest.carIdxLapDistPct as number[]).length = 0; | ||
| this.latest.carLeftRight = 0; | ||
| this.latest.isOnTrack = false; | ||
| this.latest.version += 1; | ||
| } | ||
|
|
||
| snapshot(): BlindSpotSnapshot { | ||
| return this.latest; | ||
| } | ||
|
|
||
| private set<K extends 'carLeftRight' | 'isOnTrack'>( | ||
| key: K, | ||
| value: BlindSpotSnapshot[K] | ||
| ): boolean { | ||
| if (this.latest[key] === value) return false; | ||
| this.latest[key] = value; | ||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,7 @@ | ||
| import type { | ||
| LapTimesSnapshot, | ||
| ReferenceLapsSnapshot, | ||
| } from '@irdashies/types'; | ||
| import type { LapTimesSnapshot, ReferenceLapsSnapshot } from '@irdashies/types'; | ||
| import type { ReferenceLapPersistence } from './ReferenceLapProcessor'; | ||
| import { CarSpeedsProcessor } from './CarSpeedsProcessor'; | ||
| import { BlindSpotProcessor } from './BlindSpotProcessor'; | ||
| import { DriverControlsProcessor } from './DriverControlsProcessor'; | ||
| import { FuelProjectionProcessor } from './FuelProjectionProcessor'; | ||
| import { LapLogProcessor } from './LapLogProcessor'; | ||
|
|
@@ -57,11 +55,15 @@ const defineProcessor = <K extends AnyProcessorDefinition['channel']>( | |
| export const createProcessorDefinitions = ({ | ||
| referenceLapPersistence, | ||
| }: ProcessorRegistryOptions): readonly AnyProcessorDefinition[] => [ | ||
| defineProcessor({ | ||
| channel: 'blind-spot.snapshot', | ||
| metricsPrefix: 'blindSpot', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When performance metrics are enabled for this 60 Hz processor, AGENTS.md reference: AGENTS.md:L12-L18 Useful? React with 👍 / 👎. |
||
| create: () => new BlindSpotProcessor(), | ||
| }), | ||
| defineProcessor({ | ||
| channel: 'fuel.projection', | ||
| metricsPrefix: 'fuelProjection', | ||
| create: ({ sourceReplay }) => | ||
| new FuelProjectionProcessor({ sourceReplay }), | ||
| create: ({ sourceReplay }) => new FuelProjectionProcessor({ sourceReplay }), | ||
| }), | ||
| defineProcessor({ | ||
| channel: 'lap-times.snapshot', | ||
|
|
@@ -90,8 +92,7 @@ export const createProcessorDefinitions = ({ | |
| metricsPrefix: 'relativeGap', | ||
| create: ({ snapshot }) => | ||
| new RelativeGapProcessor({ | ||
| snapshot: () => | ||
| snapshot('reference-laps.snapshot') ?? noReferenceLaps, | ||
| snapshot: () => snapshot('reference-laps.snapshot') ?? noReferenceLaps, | ||
| }), | ||
| }), | ||
| defineProcessor({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { CarLeftRight, type BlindSpotSnapshot } from '@irdashies/types'; | ||
| import { useBlindSpotMonitor } from './useBlindSpotMonitor'; | ||
|
|
||
| let blindSpotSnapshot: BlindSpotSnapshot; | ||
|
|
||
| vi.mock('@irdashies/context', () => ({ | ||
| useDriverCarIdx: () => 0, | ||
| useTrackLength: () => 5000, | ||
| useBlindSpotSelector: (selector: (snapshot: BlindSpotSnapshot) => unknown) => | ||
| selector(blindSpotSnapshot), | ||
| })); | ||
|
|
||
| vi.mock('./useBlindSpotMonitorSettings', () => ({ | ||
| useBlindSpotMonitorSettings: () => ({ distAhead: 4, distBehind: 4 }), | ||
| })); | ||
|
|
||
| const snapshot = (rivalProgress: number): BlindSpotSnapshot => ({ | ||
| carLeftRight: CarLeftRight.CarLeft, | ||
| carIdxLapDistPct: [0.5, rivalProgress], | ||
| isOnTrack: true, | ||
| version: 1, | ||
| }); | ||
|
|
||
| describe('useBlindSpotMonitor', () => { | ||
| beforeEach(() => { | ||
| blindSpotSnapshot = snapshot(0.5004); | ||
| }); | ||
|
|
||
| it('does not enter a render loop while tracking an adjacent car', () => { | ||
| let renderCount = 0; | ||
| const { result, rerender } = renderHook(() => { | ||
| renderCount += 1; | ||
| return useBlindSpotMonitor(); | ||
| }); | ||
|
|
||
| expect(result.current.show).toBe(true); | ||
| expect(result.current.leftState).toBe(CarLeftRight.CarLeft); | ||
|
|
||
| blindSpotSnapshot = snapshot(0.5005); | ||
| rerender(); | ||
|
|
||
| expect(result.current.leftPercent).toBeGreaterThan(0); | ||
| expect(renderCount).toBeLessThan(10); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,8 @@ | ||
| import { useMemo, useState, useEffect } from 'react'; | ||
| import type { TrackStateSnapshot } from '@irdashies/types'; | ||
| import { useMemo, useState, useEffect, useRef } from 'react'; | ||
| import type { BlindSpotSnapshot } from '@irdashies/types'; | ||
| import { shallow } from 'zustand/shallow'; | ||
| import { | ||
| useTrackStateSelector, | ||
| useBlindSpotSelector, | ||
| useDriverCarIdx, | ||
| useTrackLength, | ||
| } from '@irdashies/context'; | ||
|
|
@@ -19,13 +19,8 @@ interface BlindSpotMonitorState { | |
| } | ||
|
|
||
| const EMPTY_POSITIONS: readonly number[] = []; | ||
| const EMPTY_BLIND_SPOT_TELEMETRY: readonly [ | ||
| CarLeftRight, | ||
| readonly number[], | ||
| boolean, | ||
| ] = [CarLeftRight.Off, EMPTY_POSITIONS, false]; | ||
|
|
||
| const selectBlindSpotTelemetry = (snapshot: TrackStateSnapshot) => | ||
| const TELEPORT_THRESHOLD = 0.5; | ||
| const selectBlindSpotTelemetry = (snapshot: BlindSpotSnapshot) => | ||
| [ | ||
| snapshot.carLeftRight as CarLeftRight, | ||
| snapshot.carIdxLapDistPct, | ||
|
|
@@ -41,23 +36,22 @@ const blindSpotTelemetryEqual = ( | |
| previous[2] === next[2]; | ||
|
|
||
| export const useBlindSpotMonitor = (): BlindSpotMonitorState => { | ||
| const [carLeftRight, lapDistPcts, isOnTrack] = | ||
| useTrackStateSelector(selectBlindSpotTelemetry, { | ||
| const [carLeftRight, lapDistPcts, isOnTrack] = useBlindSpotSelector( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Blind Spot Monitor is the only positional widget, adding this selector activates the new blind-spot processor while Useful? React with 👍 / 👎. |
||
| selectBlindSpotTelemetry, | ||
| { | ||
| equality: blindSpotTelemetryEqual, | ||
| }) ?? EMPTY_BLIND_SPOT_TELEMETRY; | ||
| } | ||
| ) ?? [CarLeftRight.Off, EMPTY_POSITIONS, false]; | ||
| const driverCarIdx = useDriverCarIdx() ?? 0; | ||
| const trackLength = useTrackLength(); | ||
| const settings = useBlindSpotMonitorSettings(); | ||
|
|
||
| const [leftCarIdx, setLeftCarIdx] = useState<number | null>(null); | ||
| const [rightCarIdx, setRightCarIdx] = useState<number | null>(null); | ||
| const [prevPercents, setPrevPercents] = useState<{ | ||
| const prevPercentsRef = useRef<{ | ||
| left: number | null; | ||
| right: number | null; | ||
| }>({ | ||
| left: null, | ||
| right: null, | ||
| }); | ||
| }>({ left: null, right: null }); | ||
|
|
||
| const result = useMemo(() => { | ||
| const defaultState = { | ||
|
|
@@ -127,9 +121,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => { | |
| leftPercent = | ||
| is3Wide && leftCarIdx === null ? 0 : calculatePercent(leftCarIdx); | ||
|
|
||
| const previousLeft = prevPercentsRef.current.left; | ||
| if ( | ||
| prevPercents.left !== null && | ||
| Math.abs(prevPercents.left - leftPercent) > 0.5 | ||
| previousLeft !== null && | ||
| Math.abs(previousLeft - leftPercent) > TELEPORT_THRESHOLD | ||
| ) { | ||
| disableTransition = true; | ||
| } | ||
|
|
@@ -145,9 +140,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => { | |
| rightPercent = | ||
| is3Wide && rightCarIdx === null ? 0 : calculatePercent(rightCarIdx); | ||
|
|
||
| const previousRight = prevPercentsRef.current.right; | ||
| if ( | ||
| prevPercents.right !== null && | ||
| Math.abs(prevPercents.right - rightPercent) > 0.5 | ||
| previousRight !== null && | ||
| Math.abs(previousRight - rightPercent) > TELEPORT_THRESHOLD | ||
| ) { | ||
| disableTransition = true; | ||
| } | ||
|
|
@@ -170,14 +166,13 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => { | |
| isOnTrack, | ||
| leftCarIdx, | ||
| rightCarIdx, | ||
| prevPercents, | ||
| ]); | ||
|
|
||
| useEffect(() => { | ||
| if (carLeftRight <= CarLeftRight.Clear) { | ||
| setLeftCarIdx(null); | ||
| setRightCarIdx(null); | ||
| setPrevPercents({ left: null, right: null }); | ||
| prevPercentsRef.current = { left: null, right: null }; | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -225,10 +220,10 @@ export const useBlindSpotMonitor = (): BlindSpotMonitorState => { | |
| // If BOTH are null (fresh 3-wide), we stay at 0% | ||
| } | ||
|
|
||
| setPrevPercents({ | ||
| prevPercentsRef.current = { | ||
| left: result.leftPercent !== 0 ? result.leftPercent : null, | ||
| right: result.rightPercent !== 0 ? result.rightPercent : null, | ||
| }); | ||
| }; | ||
| }, [ | ||
| result.show, | ||
| carLeftRight, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import type { WidgetRuntimeDefinition } from '../../widgetRuntime'; | |
| export default { | ||
| id: 'blindspotmonitor', | ||
| sessionData: true, | ||
| channels: ['track-state.snapshot'], | ||
| channels: ['blind-spot.snapshot'], | ||
| ratePreset: 'driverFocused', | ||
| channelRates: { 'blind-spot.snapshot': 60 }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the live iRacing path, AGENTS.md reference: AGENTS.md:L12-L18 Useful? React with 👍 / 👎. |
||
| } satisfies WidgetRuntimeDefinition; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new processor coverage constructs
BlindSpotProcessorand callsonFramedirectly with a hand-built object; none of these tests callsinit(session)or drives a recorded frame sequence. This leaves activation/session initialization and compatibility with recorded telemetry shapes untested, contrary to the mandatory fixture-backedinit → onFrame* → snapshotsequence indocs/ARCHITECTURE_RULES.mdR14.1; add coverage that exercises the complete lifecycle.AGENTS.md reference: AGENTS.md:L12-L18
Useful? React with 👍 / 👎.