diff --git a/src/frontend/components/Input/InputSteer/InputSteer.stories.tsx b/src/frontend/components/Input/InputSteer/InputSteer.stories.tsx index af8bcb9..bcb4d97 100644 --- a/src/frontend/components/Input/InputSteer/InputSteer.stories.tsx +++ b/src/frontend/components/Input/InputSteer/InputSteer.stories.tsx @@ -7,8 +7,8 @@ export default { angleRad: { control: { type: 'range', - min: -3.14, - max: 3.14, + min: -2*3.14, + max: 2*3.14, step: 0.01, }, }, diff --git a/src/frontend/components/Input/InputSteer/InputSteer.tsx b/src/frontend/components/Input/InputSteer/InputSteer.tsx index 92cff88..adb6878 100644 --- a/src/frontend/components/Input/InputSteer/InputSteer.tsx +++ b/src/frontend/components/Input/InputSteer/InputSteer.tsx @@ -1,3 +1,4 @@ +import { useMemo, useRef, useEffect } from 'react'; import { DefaultB, DefaultW, @@ -10,6 +11,7 @@ import { UshapeB, UshapeW, } from './wheels'; +import { RotationIndicator } from './RotationIndicator'; export type WheelStyle = 'formula' | 'lmp' | 'nascar' | 'ushape' | 'default'; @@ -42,27 +44,42 @@ export interface InputSteerProps { wheelColor?: 'dark' | 'light'; } -export const InputSteer = ({ +export function InputSteer({ angleRad = 0, wheelStyle = 'default', wheelColor = 'light', -}: InputSteerProps) => { - const WheelComponent = - wheelStyle in wheelComponentMap +}: InputSteerProps) { + const wheelRef = useRef(null); + + // Memoize the wheel component selection (only changes when style/color change) + const WheelComponent = useMemo(() => { + return wheelStyle in wheelComponentMap ? wheelComponentMap[wheelStyle][wheelColor] : wheelComponentMap.default[wheelColor]; + }, [wheelStyle, wheelColor]); + + // Use CSS custom properties for smooth updates without React re-renders + useEffect(() => { + if (wheelRef.current) { + wheelRef.current.style.setProperty('--wheel-rotation', `${angleRad * -1}rad`); + } + }, [angleRad]); return ( -
- +
+ > + +
+
); -}; +} diff --git a/src/frontend/components/Input/InputSteer/RotationIndicator.spec.tsx b/src/frontend/components/Input/InputSteer/RotationIndicator.spec.tsx new file mode 100644 index 0000000..55eaa08 --- /dev/null +++ b/src/frontend/components/Input/InputSteer/RotationIndicator.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { RotationIndicator } from './RotationIndicator'; + +describe('RotationIndicator', () => { + it('should not render when angle is within normal range', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should not render when angle is at threshold', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render when angle exceeds threshold clockwise', () => { + render(); + + expect(screen.getByText('280°')).toBeInTheDocument(); + }); + + it('should render when angle exceeds threshold counterclockwise', () => { + render(); + + expect(screen.getByText('280°')).toBeInTheDocument(); + }); + + it('should render with extreme angle', () => { + render(); + + expect(screen.getByText('350°')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/frontend/components/Input/InputSteer/RotationIndicator.stories.tsx b/src/frontend/components/Input/InputSteer/RotationIndicator.stories.tsx new file mode 100644 index 0000000..5a78f11 --- /dev/null +++ b/src/frontend/components/Input/InputSteer/RotationIndicator.stories.tsx @@ -0,0 +1,76 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RotationIndicator } from './RotationIndicator'; + +const meta: Meta = { + component: RotationIndicator, + parameters: { + layout: 'centered', + }, + argTypes: { + currentAngleRad: { + control: { + type: 'range', + min: -2*3.14, + max: 2*3.14, + step: 0.01, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const CenterPosition: Story = { + args: { + currentAngleRad: 0, // 0 degrees - center + }, +}; + +export const SmallRight: Story = { + args: { + currentAngleRad: 0.25, // ~14 degrees - small right + }, +}; + +export const SmallLeft: Story = { + args: { + currentAngleRad: -0.25, // ~-14 degrees - small left + }, +}; + +export const MediumRight: Story = { + args: { + currentAngleRad: 0.5, // ~29 degrees - medium right + }, +}; + +export const MediumLeft: Story = { + args: { + currentAngleRad: -0.5, // ~-29 degrees - medium left + }, +}; + +export const FullRight: Story = { + args: { + currentAngleRad: 2 * Math.PI, // 360 degrees - full right + }, +}; + +export const FullLeft: Story = { + args: { + currentAngleRad: -2 * Math.PI, // -360 degrees - full left + }, +}; + +export const BeyondRange: Story = { + args: { + currentAngleRad: 3 * Math.PI, // 540 degrees - beyond range + }, +}; + +export const BeyondRangeNegative: Story = { + args: { + currentAngleRad: -3 * Math.PI, // -540 degrees - beyond range + }, +}; \ No newline at end of file diff --git a/src/frontend/components/Input/InputSteer/RotationIndicator.tsx b/src/frontend/components/Input/InputSteer/RotationIndicator.tsx new file mode 100644 index 0000000..eb58544 --- /dev/null +++ b/src/frontend/components/Input/InputSteer/RotationIndicator.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { + ArrowsClockwiseIcon, + ArrowsCounterClockwiseIcon, +} from '@phosphor-icons/react'; + +interface RotationIndicatorProps { + currentAngleRad: number; +} + +export function RotationIndicator({ + currentAngleRad +}: RotationIndicatorProps) { + // Memoize calculations to avoid recalculating on every render + const { shouldShow, angleDegrees, direction } = + useMemo(() => { + // Convert radians to degrees + const angleDegrees = (currentAngleRad * -1 * 180) / Math.PI; + + // Only show when beyond ±270 degrees + const shouldShow = Math.abs(angleDegrees) > 270; + + if (!shouldShow) { + return { + shouldShow, + angleDegrees, + direction: 'none' as const, + }; + } + + // Determine direction to center + const direction = angleDegrees > 0 ? 'left' : 'right'; + + return { + shouldShow, + angleDegrees, + direction, + }; + }, [currentAngleRad]); + + // Don't render anything if not showing + if (!shouldShow) { + return null; + } + + return ( +
+ {/* Central flashing indicator */} +
+ {/* Background indicator */} +
+ + {/* Direction arrow */} +
+ {direction === 'left' ? ( + + ) : ( + + )} +
+
+ + {/* Centered text */} +
+ {shouldShow ? `${Math.abs(angleDegrees).toFixed(0)}°` : ''} +
+
+ ); +} diff --git a/src/frontend/components/Settings/types.ts b/src/frontend/components/Settings/types.ts index 0c0b6e7..e3a7111 100644 --- a/src/frontend/components/Settings/types.ts +++ b/src/frontend/components/Settings/types.ts @@ -41,7 +41,7 @@ export interface TrackMapWidgetSettings extends BaseWidgetSettings { export interface SteerWidgetSettings extends BaseWidgetSettings { config: { - style: 'formula' | 'lmp' | 'nascar' | 'round' | 'ushape' | 'default'; + style: 'formula' | 'lmp' | 'nascar' | 'ushape' | 'default'; color: 'dark' | 'light'; }; } @@ -66,8 +66,3 @@ export interface InputWidgetSettings extends BaseWidgetSettings { steer: SteerWidgetSettings; }; } - -/* eslint-disable @typescript-eslint/no-empty-object-type */ -export interface AdvancedSettings extends BaseWidgetSettings { - // Add specific advanced settings here -} diff --git a/src/frontend/theme.css b/src/frontend/theme.css index 63a9360..52954c8 100644 --- a/src/frontend/theme.css +++ b/src/frontend/theme.css @@ -23,6 +23,15 @@ layer(base); opacity: 1; } } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } } html {