Skip to content

Commit e8d379e

Browse files
authored
feat: Add warning for over rotating the wheel (#57)
1 parent 4ad4831 commit e8d379e

File tree

7 files changed

+236
-19
lines changed

7 files changed

+236
-19
lines changed

src/frontend/components/Input/InputSteer/InputSteer.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ export default {
77
angleRad: {
88
control: {
99
type: 'range',
10-
min: -3.14,
11-
max: 3.14,
10+
min: -2*3.14,
11+
max: 2*3.14,
1212
step: 0.01,
1313
},
1414
},

src/frontend/components/Input/InputSteer/InputSteer.tsx

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMemo, useRef, useEffect } from 'react';
12
import {
23
DefaultB,
34
DefaultW,
@@ -10,6 +11,7 @@ import {
1011
UshapeB,
1112
UshapeW,
1213
} from './wheels';
14+
import { RotationIndicator } from './RotationIndicator';
1315

1416
export type WheelStyle = 'formula' | 'lmp' | 'nascar' | 'ushape' | 'default';
1517

@@ -42,27 +44,42 @@ export interface InputSteerProps {
4244
wheelColor?: 'dark' | 'light';
4345
}
4446

45-
export const InputSteer = ({
47+
export function InputSteer({
4648
angleRad = 0,
4749
wheelStyle = 'default',
4850
wheelColor = 'light',
49-
}: InputSteerProps) => {
50-
const WheelComponent =
51-
wheelStyle in wheelComponentMap
51+
}: InputSteerProps) {
52+
const wheelRef = useRef<HTMLDivElement>(null);
53+
54+
// Memoize the wheel component selection (only changes when style/color change)
55+
const WheelComponent = useMemo(() => {
56+
return wheelStyle in wheelComponentMap
5257
? wheelComponentMap[wheelStyle][wheelColor]
5358
: wheelComponentMap.default[wheelColor];
59+
}, [wheelStyle, wheelColor]);
60+
61+
// Use CSS custom properties for smooth updates without React re-renders
62+
useEffect(() => {
63+
if (wheelRef.current) {
64+
wheelRef.current.style.setProperty('--wheel-rotation', `${angleRad * -1}rad`);
65+
}
66+
}, [angleRad]);
5467

5568
return (
56-
<div className="w-[120px] fill-white relative">
57-
<WheelComponent
69+
<div className="w-[120px] h-full flex fill-white relative">
70+
<div
71+
ref={wheelRef}
72+
className='w-full h-full flex'
5873
style={{
59-
width: '100%',
60-
height: '100%',
61-
transform: `rotate(${angleRad * -1}rad)`,
74+
transform: 'rotate(var(--wheel-rotation, 0rad))',
6275
transformBox: 'fill-box',
6376
transformOrigin: 'center',
77+
willChange: 'transform',
6478
}}
65-
/>
79+
>
80+
<WheelComponent />
81+
</div>
82+
<RotationIndicator currentAngleRad={angleRad} />
6683
</div>
6784
);
68-
};
85+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, it, expect } from 'vitest';
3+
import { RotationIndicator } from './RotationIndicator';
4+
5+
describe('RotationIndicator', () => {
6+
it('should not render when angle is within normal range', () => {
7+
const { container } = render(<RotationIndicator currentAngleRad={0} />);
8+
9+
expect(container.firstChild).toBeNull();
10+
});
11+
12+
it('should not render when angle is at threshold', () => {
13+
const { container } = render(<RotationIndicator currentAngleRad={270 * (Math.PI / 180)} />);
14+
15+
expect(container.firstChild).toBeNull();
16+
});
17+
18+
it('should render when angle exceeds threshold clockwise', () => {
19+
render(<RotationIndicator currentAngleRad={280 * (Math.PI / 180)} />);
20+
21+
expect(screen.getByText('280°')).toBeInTheDocument();
22+
});
23+
24+
it('should render when angle exceeds threshold counterclockwise', () => {
25+
render(<RotationIndicator currentAngleRad={-280 * (Math.PI / 180)} />);
26+
27+
expect(screen.getByText('280°')).toBeInTheDocument();
28+
});
29+
30+
it('should render with extreme angle', () => {
31+
render(<RotationIndicator currentAngleRad={350 * (Math.PI / 180)} />);
32+
33+
expect(screen.getByText('350°')).toBeInTheDocument();
34+
});
35+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { RotationIndicator } from './RotationIndicator';
3+
4+
const meta: Meta<typeof RotationIndicator> = {
5+
component: RotationIndicator,
6+
parameters: {
7+
layout: 'centered',
8+
},
9+
argTypes: {
10+
currentAngleRad: {
11+
control: {
12+
type: 'range',
13+
min: -2*3.14,
14+
max: 2*3.14,
15+
step: 0.01,
16+
},
17+
},
18+
},
19+
};
20+
21+
export default meta;
22+
type Story = StoryObj<typeof meta>;
23+
24+
export const CenterPosition: Story = {
25+
args: {
26+
currentAngleRad: 0, // 0 degrees - center
27+
},
28+
};
29+
30+
export const SmallRight: Story = {
31+
args: {
32+
currentAngleRad: 0.25, // ~14 degrees - small right
33+
},
34+
};
35+
36+
export const SmallLeft: Story = {
37+
args: {
38+
currentAngleRad: -0.25, // ~-14 degrees - small left
39+
},
40+
};
41+
42+
export const MediumRight: Story = {
43+
args: {
44+
currentAngleRad: 0.5, // ~29 degrees - medium right
45+
},
46+
};
47+
48+
export const MediumLeft: Story = {
49+
args: {
50+
currentAngleRad: -0.5, // ~-29 degrees - medium left
51+
},
52+
};
53+
54+
export const FullRight: Story = {
55+
args: {
56+
currentAngleRad: 2 * Math.PI, // 360 degrees - full right
57+
},
58+
};
59+
60+
export const FullLeft: Story = {
61+
args: {
62+
currentAngleRad: -2 * Math.PI, // -360 degrees - full left
63+
},
64+
};
65+
66+
export const BeyondRange: Story = {
67+
args: {
68+
currentAngleRad: 3 * Math.PI, // 540 degrees - beyond range
69+
},
70+
};
71+
72+
export const BeyondRangeNegative: Story = {
73+
args: {
74+
currentAngleRad: -3 * Math.PI, // -540 degrees - beyond range
75+
},
76+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useMemo } from 'react';
2+
import {
3+
ArrowsClockwiseIcon,
4+
ArrowsCounterClockwiseIcon,
5+
} from '@phosphor-icons/react';
6+
7+
interface RotationIndicatorProps {
8+
currentAngleRad: number;
9+
}
10+
11+
export function RotationIndicator({
12+
currentAngleRad
13+
}: RotationIndicatorProps) {
14+
// Memoize calculations to avoid recalculating on every render
15+
const { shouldShow, angleDegrees, direction } =
16+
useMemo(() => {
17+
// Convert radians to degrees
18+
const angleDegrees = (currentAngleRad * -1 * 180) / Math.PI;
19+
20+
// Only show when beyond ±270 degrees
21+
const shouldShow = Math.abs(angleDegrees) > 270;
22+
23+
if (!shouldShow) {
24+
return {
25+
shouldShow,
26+
angleDegrees,
27+
direction: 'none' as const,
28+
};
29+
}
30+
31+
// Determine direction to center
32+
const direction = angleDegrees > 0 ? 'left' : 'right';
33+
34+
return {
35+
shouldShow,
36+
angleDegrees,
37+
direction,
38+
};
39+
}, [currentAngleRad]);
40+
41+
// Don't render anything if not showing
42+
if (!shouldShow) {
43+
return null;
44+
}
45+
46+
return (
47+
<div
48+
className={`absolute inset-0 flex items-center justify-center`}
49+
>
50+
{/* Central flashing indicator */}
51+
<div className="relative flex items-center justify-center">
52+
{/* Background indicator */}
53+
<div
54+
className="absolute w-8 h-8 rounded-full bg-red-500/40 backdrop-blur-sm border-2 border-red-500"
55+
style={{
56+
animation: 'pulse 1s ease-in-out infinite',
57+
animationDelay: '0.5s',
58+
}}
59+
/>
60+
61+
{/* Direction arrow */}
62+
<div className="absolute w-6 h-6 flex items-center justify-center">
63+
{direction === 'left' ? (
64+
<ArrowsCounterClockwiseIcon
65+
size={20}
66+
weight="bold"
67+
className="text-white"
68+
/>
69+
) : (
70+
<ArrowsClockwiseIcon
71+
size={20}
72+
weight="bold"
73+
className="text-white"
74+
/>
75+
)}
76+
</div>
77+
</div>
78+
79+
{/* Centered text */}
80+
<div className="absolute top-1/8 text-xs text-white min-w-[2.5rem] text-center font-medium bg-black/40 rounded-md">
81+
{shouldShow ? `${Math.abs(angleDegrees).toFixed(0)}°` : ''}
82+
</div>
83+
</div>
84+
);
85+
}

src/frontend/components/Settings/types.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface TrackMapWidgetSettings extends BaseWidgetSettings {
4141

4242
export interface SteerWidgetSettings extends BaseWidgetSettings {
4343
config: {
44-
style: 'formula' | 'lmp' | 'nascar' | 'round' | 'ushape' | 'default';
44+
style: 'formula' | 'lmp' | 'nascar' | 'ushape' | 'default';
4545
color: 'dark' | 'light';
4646
};
4747
}
@@ -66,8 +66,3 @@ export interface InputWidgetSettings extends BaseWidgetSettings {
6666
steer: SteerWidgetSettings;
6767
};
6868
}
69-
70-
/* eslint-disable @typescript-eslint/no-empty-object-type */
71-
export interface AdvancedSettings extends BaseWidgetSettings {
72-
// Add specific advanced settings here
73-
}

src/frontend/theme.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ layer(base);
2323
opacity: 1;
2424
}
2525
}
26+
27+
@keyframes pulse {
28+
0%, 100% {
29+
opacity: 1;
30+
}
31+
50% {
32+
opacity: 0.5;
33+
}
34+
}
2635
}
2736

2837
html {

0 commit comments

Comments
 (0)