Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
50 changes: 33 additions & 17 deletions picker-wheel/src/components/Wheel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ type Props = {
}

export function Wheel({ options, onWinner, spinning, setSpinning }: Props) {
const [rotation, setRotation] = useState(0)
const baseRotRef = useRef(0)
const [rotation, setRotation] = useState(() => Math.random() * 360)
const baseRotRef = useRef(Math.random() * 360)
const spinButtonRef = useRef<HTMLButtonElement | null>(null)

const slices = useMemo(() => computeSlices(options.map(o => o.label)), [options])

useEffect(() => {
// Reset rotation if options change significantly
setRotation(prev => prev % 360)
// Reset to a new random rotation if options change significantly
if (options.length > 0) {
const newRandomRotation = Math.random() * 360
setRotation(newRandomRotation)
baseRotRef.current = newRandomRotation
}
}, [options.length])

const handleSpin = () => {
Expand All @@ -34,9 +38,16 @@ export function Wheel({ options, onWinner, spinning, setSpinning }: Props) {

const onTransitionEnd = () => {
// Determine the winner based on final rotation
const normalized = ((rotation % 360) + 360) % 360
const landingAngleFromTop = (360 - (normalized % 360)) % 360
const idx = angleToIndex(landingAngleFromTop, slices.length)
// The pointer is at the top (12 o'clock position)
// Since slices now start at the top and go clockwise, we can directly
// calculate which slice is under the pointer
const normalizedRotation = ((rotation % 360) + 360) % 360

// The wheel rotates clockwise, so we need to find which slice
// is at the top after rotation
const pointerAngle = normalizedRotation % 360
const idx = angleToIndex(pointerAngle, slices.length)

const winner = options[idx]
if (winner) onWinner(winner)
setSpinning(false)
Expand All @@ -48,8 +59,16 @@ export function Wheel({ options, onWinner, spinning, setSpinning }: Props) {
return (
<div className="flex flex-col items-center">
<div className="relative">
{/* Pointer - moved outside and made more prominent */}
<div className={`absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1 transition-all duration-300 ${spinning ? 'animate-pulse scale-110' : 'scale-100'}`}>
<div className="flex flex-col items-center">
<div className="h-0 w-0 border-l-[16px] border-r-[16px] border-b-[28px] border-l-transparent border-r-transparent border-b-red-500 drop-shadow-lg" aria-hidden="true" />
<div className="mt-1 h-3 w-1 bg-red-500 rounded-full" aria-hidden="true" />
</div>
</div>

<div
className="mx-auto h-[280px] w-[280px] select-none rounded-full border border-gray-200 shadow-sm"
className="mx-auto h-[280px] w-[280px] select-none rounded-full border-2 border-gray-300 shadow-lg"
style={{
transform: `rotate(${rotation}deg)`,
transition: spinning ? 'transform 3.2s cubic-bezier(0.12, 0.11, 0, 1)' : undefined,
Expand All @@ -62,7 +81,7 @@ export function Wheel({ options, onWinner, spinning, setSpinning }: Props) {
>
<svg viewBox="-100 -100 200 200" width={size} height={size} className="block h-full w-full">
{slices.map((slice, i) => (
<path key={i} d={slice.path} fill={options[i]?.color || slice.color} />
<path key={i} d={slice.path} fill={options[i]?.color || slice.color} stroke="#fff" strokeWidth="1" />
))}
{slices.map((slice, i) => (
<text
Expand All @@ -74,28 +93,25 @@ export function Wheel({ options, onWinner, spinning, setSpinning }: Props) {
dominantBaseline="middle"
fontSize="4"
fill="#111827"
fontWeight="bold"
>
{options[i]?.label || ''}
</text>
))}
<circle cx="0" cy="0" r="99" fill="none" stroke="#e5e7eb" strokeWidth="0.5" />
{/* Center circle */}
<circle cx="0" cy="0" r="8" fill="#374151" stroke="#fff" strokeWidth="2" />
</svg>

{/* Pointer */}
<div className="pointer-events-none absolute left-1/2 top-0 -translate-x-1/2">
<div className="h-0 w-0 border-l-8 border-r-8 border-b-[14px] border-l-transparent border-r-transparent border-b-rose-500" aria-hidden="true" />
</div>
</div>
</div>

<button
ref={spinButtonRef}
onClick={handleSpin}
className="mt-4 rounded-md bg-brand px-5 py-2.5 text-white shadow hover:bg-brand-dark focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
className="mt-6 rounded-lg bg-brand px-6 py-3 text-lg font-semibold text-white shadow-lg hover:bg-brand-dark focus:outline-none focus-visible:ring-2 focus-visible:ring-brand transition-colors"
aria-label="Spin the wheel"
disabled={spinning || options.length === 0}
>
{spinning ? 'Spinning…' : 'Spin'}
{spinning ? 'Spinning…' : 'Spin the Wheel!'}
</button>
</div>
)
Expand Down
28 changes: 24 additions & 4 deletions picker-wheel/src/components/WinnerAnnouncement.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
'use client'

import { useEffect, useState } from 'react'

type Props = { winner: string }

export function WinnerAnnouncement({ winner }: Props) {
const [showAnimation, setShowAnimation] = useState(false)

useEffect(() => {
if (winner) {
setShowAnimation(true)
const timer = setTimeout(() => setShowAnimation(false), 2000)
return () => clearTimeout(timer)
}
}, [winner])

return (
<div className="mt-6" aria-live="polite" aria-atomic="true" role="status">
<div className="mt-8" aria-live="polite" aria-atomic="true" role="status">
{winner ? (
<div className="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-green-800">
Winner: <strong>{winner}</strong>
<div className={`rounded-xl border-2 border-yellow-400 bg-gradient-to-r from-yellow-50 to-orange-50 px-6 py-4 text-center shadow-lg transition-all duration-500 ${showAnimation ? 'scale-105 shadow-xl' : 'scale-100'}`}>
<div className="text-2xl mb-2">🎉</div>
<div className="text-lg font-bold text-gray-800 mb-1">🏆 Winner! 🏆</div>
<div className="text-xl font-extrabold text-orange-600">{winner}</div>
<div className="text-sm text-gray-600 mt-2">Congratulations!</div>
</div>
) : (
<p className="text-sm text-gray-500">Spin the wheel to pick a winner.</p>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-center text-gray-600">
<div className="text-lg mb-1">🎲</div>
<p className="text-sm">Spin the wheel to pick a winner!</p>
</div>
)}
</div>
)
Expand Down
28 changes: 23 additions & 5 deletions picker-wheel/src/lib/__tests__/wheel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,29 @@ describe('wheel math', () => {
expect(slices[0].path).toContain('A 100 100') // arc command exists
})

test('angleToIndex maps angles to indices', () => {
expect(angleToIndex(0, 4)).toBe(0)
expect(angleToIndex(89.9, 4)).toBe(0)
expect(angleToIndex(90, 4)).toBe(1)
expect(angleToIndex(359, 4)).toBe(3)
test('angleToIndex maps angles to indices correctly', () => {
// Test with 4 slices (90 degrees each)
expect(angleToIndex(0, 4)).toBe(0) // First slice
expect(angleToIndex(45, 4)).toBe(0) // Still first slice
expect(angleToIndex(89.9, 4)).toBe(0) // Still first slice
expect(angleToIndex(90, 4)).toBe(1) // Second slice
expect(angleToIndex(180, 4)).toBe(2) // Third slice
expect(angleToIndex(270, 4)).toBe(3) // Fourth slice
expect(angleToIndex(359, 4)).toBe(3) // Still fourth slice
expect(angleToIndex(360, 4)).toBe(0) // Back to first slice
})

test('angleToIndex works with different slice counts', () => {
// Test with 3 slices (120 degrees each)
expect(angleToIndex(0, 3)).toBe(0)
expect(angleToIndex(60, 3)).toBe(0)
expect(angleToIndex(120, 3)).toBe(1)
expect(angleToIndex(240, 3)).toBe(2)

// Test with 6 slices (60 degrees each)
expect(angleToIndex(30, 6)).toBe(0)
expect(angleToIndex(60, 6)).toBe(1)
expect(angleToIndex(120, 6)).toBe(2)
})

test('shuffleArray produces permutation', () => {
Expand Down
6 changes: 4 additions & 2 deletions picker-wheel/src/lib/wheel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Slice = {
}

// Create pie slice paths for n options, centered at 0,0 radius 100
// Slices start at the top (12 o'clock) and go clockwise
export function computeSlices(labels: string[], colors?: string[]): Slice[] {
const n = Math.max(1, labels.length)
const anglePer = 360 / n
Expand All @@ -18,8 +19,9 @@ export function computeSlices(labels: string[], colors?: string[]): Slice[] {
const toRad = (deg: number) => (deg * Math.PI) / 180

return labels.map((label, i) => {
const startAngle = i * anglePer
const endAngle = (i + 1) * anglePer
// Start at -90 degrees to position first slice at top (12 o'clock)
const startAngle = i * anglePer - 90
const endAngle = (i + 1) * anglePer - 90

const largeArc = endAngle - startAngle > 180 ? 1 : 0

Expand Down