Files
bedolaga-cabinet/src/components/wheel/FortuneWheel.tsx
c0mrade 7716e32eec fix(wheel): scatter spin sparkles so they don't streak through the centre
The real cause of the center line: SPARKLE_POSITIONS (yellow dots shown only while spinning) used `20 + (i*10)%60` / `15 + (i*13)%70`. For the first ~6 sparkles the modulo never wraps, so top/left increment linearly and the dots land on a straight diagonal through the centre — the 'line of lights' during the spin. Scatter them on a golden-angle spiral inside the wheel instead.

Reverts the earlier LED-glow change (it wasn't the cause): the rim-light blur is restored to the original.
2026-06-01 16:45:35 +03:00

435 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState, memo } from 'react';
import type { WheelPrize } from '../../api/wheel';
interface FortuneWheelProps {
prizes: WheelPrize[];
isSpinning: boolean;
targetRotation: number | null;
onSpinComplete: () => void;
}
// Pre-generate sparkle positions (shown only while spinning). The old formula
// `20 + (i*10)%60 / 15 + (i*13)%70` left the first ~6 sparkles colinear (the
// modulo never wrapped for small i), so they lined up as a diagonal streak
// through the centre. Scatter them on a phyllotaxis (golden-angle) spiral
// inside the wheel instead — well-distributed, never colinear.
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => {
const angle = i * 2.39996; // golden angle (radians)
const radius = 16 + (i % 4) * 8; // 1640% from centre, kept within the wheel
return {
top: `${(50 + radius * Math.sin(angle)).toFixed(1)}%`,
left: `${(50 + radius * Math.cos(angle)).toFixed(1)}%`,
delay: `${i * 0.15}s`,
};
});
const FortuneWheel = memo(function FortuneWheel({
prizes,
isSpinning,
targetRotation,
onSpinComplete,
}: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null);
const accumulatedRotation = useRef(0);
const [displayRotation, setDisplayRotation] = useState(0);
useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) {
const currentPos = accumulatedRotation.current % 360;
let delta = targetRotation - currentPos;
// Normalize delta to positive
while (delta < 0) delta += 360;
const newRotation = accumulatedRotation.current + 1800 + delta;
accumulatedRotation.current = newRotation;
setDisplayRotation(newRotation);
const timeout = setTimeout(() => {
onSpinComplete();
}, 5000);
return () => clearTimeout(timeout);
}
}, [isSpinning, targetRotation, onSpinComplete]);
if (prizes.length === 0) {
return (
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
<p className="text-dark-400">No prizes configured</p>
</div>
);
}
const size = 400;
const center = size / 2;
const outerRadius = size / 2 - 20;
const innerRadius = outerRadius - 15;
const prizeRadius = innerRadius - 5;
const sectorAngle = 360 / prizes.length;
const hubRadius = 45;
const createSectorPath = (index: number) => {
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + prizeRadius * Math.cos(startAngle);
const y1 = center + prizeRadius * Math.sin(startAngle);
const x2 = center + prizeRadius * Math.cos(endAngle);
const y2 = center + prizeRadius * Math.sin(endAngle);
const x1Inner = center + hubRadius * Math.cos(startAngle);
const y1Inner = center + hubRadius * Math.sin(startAngle);
const x2Inner = center + hubRadius * Math.cos(endAngle);
const y2Inner = center + hubRadius * Math.sin(endAngle);
const largeArc = sectorAngle > 180 ? 1 : 0;
return `M ${x1Inner} ${y1Inner}
L ${x1} ${y1}
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
L ${x2Inner} ${y2Inner}
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`;
};
// Position for emoji - closer to outer edge
const getEmojiPosition = (index: number) => {
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const emojiRadius = prizeRadius * 0.75;
return {
x: center + emojiRadius * Math.cos(angle),
y: center + emojiRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
};
};
// Alternate colors for sectors
const getSectorColors = (index: number, baseColor?: string) => {
if (baseColor) return baseColor;
const colors = [
'#8B5CF6',
'#EC4899',
'#3B82F6',
'#10B981',
'#F59E0B',
'#EF4444',
'#6366F1',
'#14B8A6',
];
return colors[index % colors.length];
};
return (
<div className="relative mx-auto w-full max-w-[380px] select-none">
{/* Outer glow effect */}
<div
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
isSpinning ? 'scale-105 opacity-100' : 'opacity-60'
}`}
style={{
background:
'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
filter: 'blur(25px)',
}}
/>
{/* Pointer */}
<div className="absolute left-1/2 top-[-12px] z-20 -translate-x-1/2">
<div className="relative">
<div
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
style={{
background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)',
}}
/>
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
<defs>
<linearGradient id="pointerGold" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#FDE68A" />
<stop offset="30%" stopColor="#FBBF24" />
<stop offset="70%" stopColor="#F59E0B" />
<stop offset="100%" stopColor="#D97706" />
</linearGradient>
<filter id="pointerGlow">
<feDropShadow
dx="0"
dy="2"
stdDeviation="3"
floodColor="#F59E0B"
floodOpacity="0.6"
/>
</filter>
</defs>
<polygon
points="22,56 2,14 22,0 42,14"
fill="url(#pointerGold)"
filter="url(#pointerGlow)"
/>
<polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
<circle cx="22" cy="24" r="8" fill="#FEF3C7" />
<circle cx="22" cy="24" r="5" fill="#FBBF24" />
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8" />
</svg>
</div>
</div>
{/* Main Wheel */}
<div className="relative aspect-square">
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full">
<defs>
{/* Sector gradients */}
{prizes.map((prize, index) => {
const color = getSectorColors(index, prize.color);
return (
<linearGradient
key={`grad-${index}`}
id={`sectorGrad-${index}`}
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
</linearGradient>
);
})}
{/* Outer ring gradient */}
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#C084FC" />
<stop offset="25%" stopColor="#A855F7" />
<stop offset="50%" stopColor="#7C3AED" />
<stop offset="75%" stopColor="#A855F7" />
<stop offset="100%" stopColor="#C084FC" />
</linearGradient>
{/* Hub gradient */}
<radialGradient id="hubGrad" cx="30%" cy="30%" r="70%">
<stop offset="0%" stopColor="#818CF8" />
<stop offset="50%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#4338CA" />
</radialGradient>
{/* Text shadow filter */}
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7" />
</filter>
</defs>
{/* Background shadow */}
<circle cx={center} cy={center + 6} r={outerRadius + 5} fill="rgba(0,0,0,0.3)" />
{/* Outer decorative ring */}
<circle
cx={center}
cy={center}
r={outerRadius}
fill="none"
stroke="url(#ringGrad)"
strokeWidth="15"
/>
{/* Inner ring border */}
<circle
cx={center}
cy={center}
r={innerRadius}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="2"
/>
{/* LED chase animation — pure CSS, no React re-renders */}
<style>
{`
@keyframes ledChase {
0%, 100% { fill: #374151; stroke: #1F2937; }
10%, 30% { fill: #FEF08A; stroke: #FDE047; }
}
@keyframes ledGlow {
0%, 100% { opacity: 0; }
10%, 30% { opacity: 0.4; }
}
.led-dot { animation: ledChase 6s linear infinite; }
.led-glow { opacity: 0; animation: ledGlow 6s linear infinite; }
.led-spinning .led-dot { animation-duration: 2s; }
.led-spinning .led-glow { animation-duration: 2s; }
`}
</style>
<g className={isSpinning ? 'led-spinning' : undefined}>
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180);
const ledRadius = outerRadius + 3;
const dotX = center + ledRadius * Math.cos(angle);
const dotY = center + ledRadius * Math.sin(angle);
// Delay as fraction of full cycle — CSS handles speed via animation-duration
const delay = `${(i / 20) * 6}s`;
return (
<g key={`led-${i}`}>
<circle
className="led-glow"
cx={dotX}
cy={dotY}
r={5}
fill="#FEF08A"
style={{ filter: 'blur(2px)', animationDelay: delay }}
/>
<circle
className="led-dot"
cx={dotX}
cy={dotY}
r={3.5}
strokeWidth="1"
style={{ animationDelay: delay }}
/>
</g>
);
})}
</g>
{/* Rotating wheel group */}
<g
ref={wheelRef}
style={{
transformOrigin: `${center}px ${center}px`,
transform: `rotate(${displayRotation}deg)`,
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
}}
>
{/* Sectors */}
{prizes.map((prize, index) => (
<path
key={`sector-${prize.id}`}
d={createSectorPath(index)}
fill={`url(#sectorGrad-${index})`}
stroke="rgba(255,255,255,0.15)"
strokeWidth="2"
/>
))}
{/* Sector dividers */}
{prizes.map((_, index) => {
const angle = (index * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + hubRadius * Math.cos(angle);
const y1 = center + hubRadius * Math.sin(angle);
const x2 = center + prizeRadius * Math.cos(angle);
const y2 = center + prizeRadius * Math.sin(angle);
return (
<line
key={`divider-${index}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="rgba(255,255,255,0.25)"
strokeWidth="2"
/>
);
})}
{/* Prize content - Emoji */}
{prizes.map((prize, index) => {
const pos = getEmojiPosition(index);
return (
<text
key={`emoji-${prize.id}`}
x={pos.x}
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 6 ? '32' : '26'}
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
>
{prize.emoji}
</text>
);
})}
{/* Prize content - Text removed, only emoji visible on wheel */}
</g>
{/* Center hub */}
<circle
cx={center}
cy={center}
r={hubRadius}
fill="url(#hubGrad)"
stroke="#A5B4FC"
strokeWidth="3"
/>
{/* Hub inner decoration */}
<circle
cx={center}
cy={center}
r={hubRadius - 8}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
{/* Hub shine */}
<ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
{/* Center button */}
<circle
cx={center}
cy={center}
r={hubRadius - 12}
fill="#312E81"
stroke="#6366F1"
strokeWidth="2"
/>
{/* Center text */}
<text
x={center}
y={center + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize="11"
fontWeight="bold"
fill="#C7D2FE"
letterSpacing="0.15em"
>
{isSpinning ? '...' : 'SPIN'}
</text>
</svg>
{/* Spinning overlay glow */}
{isSpinning && (
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
animation: 'pulse 0.5s ease-in-out infinite',
}}
/>
)}
</div>
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{SPARKLE_POSITIONS.map((pos, i) => (
<div
key={`sparkle-${i}`}
className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
style={{
top: pos.top,
left: pos.left,
animationDelay: pos.delay,
animationDuration: '1.5s',
opacity: 0.7,
}}
/>
))}
</div>
)}
</div>
);
});
export default FortuneWheel;