Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand Down
39 changes: 28 additions & 11 deletions src/frontend/components/Input/InputSteer/InputSteer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo, useRef, useEffect } from 'react';
import {
DefaultB,
DefaultW,
Expand All @@ -10,6 +11,7 @@ import {
UshapeB,
UshapeW,
} from './wheels';
import { RotationIndicator } from './RotationIndicator';

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

Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<div className="w-[120px] fill-white relative">
<WheelComponent
<div className="w-[120px] h-full flex fill-white relative">
<div
ref={wheelRef}
className='w-full h-full flex'
style={{
width: '100%',
height: '100%',
transform: `rotate(${angleRad * -1}rad)`,
transform: 'rotate(var(--wheel-rotation, 0rad))',
transformBox: 'fill-box',
transformOrigin: 'center',
willChange: 'transform',
}}
/>
>
<WheelComponent />
</div>
<RotationIndicator currentAngleRad={angleRad} />
</div>
);
};
}
Original file line number Diff line number Diff line change
@@ -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(<RotationIndicator currentAngleRad={0} />);

expect(container.firstChild).toBeNull();
});

it('should not render when angle is at threshold', () => {
const { container } = render(<RotationIndicator currentAngleRad={270 * (Math.PI / 180)} />);

expect(container.firstChild).toBeNull();
});

it('should render when angle exceeds threshold clockwise', () => {
render(<RotationIndicator currentAngleRad={280 * (Math.PI / 180)} />);

expect(screen.getByText('280°')).toBeInTheDocument();
});

it('should render when angle exceeds threshold counterclockwise', () => {
render(<RotationIndicator currentAngleRad={-280 * (Math.PI / 180)} />);

expect(screen.getByText('280°')).toBeInTheDocument();
});

it('should render with extreme angle', () => {
render(<RotationIndicator currentAngleRad={350 * (Math.PI / 180)} />);

expect(screen.getByText('350°')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RotationIndicator } from './RotationIndicator';

const meta: Meta<typeof RotationIndicator> = {
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<typeof meta>;

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
},
};
85 changes: 85 additions & 0 deletions src/frontend/components/Input/InputSteer/RotationIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`absolute inset-0 flex items-center justify-center`}
>
{/* Central flashing indicator */}
<div className="relative flex items-center justify-center">
{/* Background indicator */}
<div
className="absolute w-8 h-8 rounded-full bg-red-500/40 backdrop-blur-sm border-2 border-red-500"
style={{
animation: 'pulse 1s ease-in-out infinite',
animationDelay: '0.5s',
}}
/>

{/* Direction arrow */}
<div className="absolute w-6 h-6 flex items-center justify-center">
{direction === 'left' ? (
<ArrowsCounterClockwiseIcon
size={20}
weight="bold"
className="text-white"
/>
) : (
<ArrowsClockwiseIcon
size={20}
weight="bold"
className="text-white"
/>
)}
</div>
</div>

{/* Centered text */}
<div className="absolute top-1/8 text-xs text-white min-w-[2.5rem] text-center font-medium bg-black/40 rounded-md">
{shouldShow ? `${Math.abs(angleDegrees).toFixed(0)}°` : ''}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/frontend/components/Input/InputSteer/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { useWheelRotation } from './useWheelRotation';
export type { WheelRotationState } from './useWheelRotation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { renderHook } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useWheelRotation } from './useWheelRotation';

describe('useWheelRotation', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useWheelRotation(0));

expect(result.current.totalRotations).toBe(0);
expect(result.current.hasRotatedOver360).toBe(false);
expect(result.current.rotationDirection).toBe('none');
});

it('should track clockwise rotation', () => {
const { result, rerender } = renderHook((angle) => useWheelRotation(angle), {
initialProps: 0,
});

// Simulate clockwise rotation
rerender(Math.PI / 2); // 90 degrees
rerender(Math.PI); // 180 degrees
rerender(3 * Math.PI / 2); // 270 degrees
rerender(2 * Math.PI); // 360 degrees

expect(result.current.totalRotations).toBe(1);
expect(result.current.hasRotatedOver360).toBe(true);
});

it('should track counterclockwise rotation', () => {
const { result, rerender } = renderHook((angle) => useWheelRotation(angle), {
initialProps: 0,
});

// Simulate counterclockwise rotation
rerender(-Math.PI / 2); // -90 degrees
rerender(-Math.PI); // -180 degrees
rerender(-3 * Math.PI / 2); // -270 degrees
rerender(-2 * Math.PI); // -360 degrees

expect(result.current.totalRotations).toBe(1);
expect(result.current.hasRotatedOver360).toBe(true);
});

it('should handle multiple rotations', () => {
const { result, rerender } = renderHook((angle) => useWheelRotation(angle), {
initialProps: 0,
});

// Simulate 2.5 rotations clockwise
rerender(Math.PI / 2);
rerender(Math.PI);
rerender(3 * Math.PI / 2);
rerender(2 * Math.PI);
rerender(5 * Math.PI / 2);
rerender(3 * Math.PI);
rerender(7 * Math.PI / 2);
rerender(4 * Math.PI);
rerender(9 * Math.PI / 2);

expect(result.current.totalRotations).toBe(2);
expect(result.current.hasRotatedOver360).toBe(true);
});

it('should handle angle wraparound', () => {
const { result, rerender } = renderHook((angle) => useWheelRotation(angle), {
initialProps: 0,
});

// Simulate rotation that crosses the ±π boundary
rerender(Math.PI - 0.1);
rerender(Math.PI + 0.1); // Crosses the boundary

expect(result.current.rotationDirection).toBe('clockwise');
});
});
Loading
Loading