import React, { useState, useRef, useEffect } from 'react'; import { motion, useMotionValue, animate } from 'framer-motion'; const RotaryDial = ({ onDial }) => { const [isDragging, setIsDragging] = useState(false); const [activeDigit, setActiveDigit] = useState(null); const rotation = useMotionValue(0); const containerRef = useRef(null); // Ref for the static container const animationControls = useRef(null); // Configuration const STOP_ANGLE = 60; // Angle of the finger stop (in degrees, 0 is 3 o'clock) const GAP = 30; // Degrees between numbers // Numbers 1-9, 0. '1' is closest to stop. const DIGITS = React.useMemo(() => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0], []); const getDigitAngle = React.useCallback( (digit) => { const index = DIGITS.indexOf(digit); return STOP_ANGLE - (index + 1) * GAP; }, [DIGITS], ); const getAngle = (event, center) => { const clientX = event.touches ? event.touches[0].clientX : event.clientX; const clientY = event.touches ? event.touches[0].clientY : event.clientY; const dx = clientX - center.x; const dy = clientY - center.y; let theta = Math.atan2(dy, dx) * (180 / Math.PI); return theta; }; const handleStart = (event, digit) => { // Only prevent default on touch to stop scrolling if (event.touches && event.cancelable) event.preventDefault(); // Stop any ongoing spring-back animation if (animationControls.current) { animationControls.current.stop(); } setIsDragging(true); setActiveDigit(digit); }; const handleMove = React.useCallback( (event) => { if (!isDragging || activeDigit === null || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }; const currentMouseAngle = getAngle(event, center); const startAngle = getDigitAngle(activeDigit); let newRotation = currentMouseAngle - startAngle; const normalizeDiff = (diff) => { while (diff <= -180) diff += 360; while (diff > 180) diff -= 360; return diff; }; newRotation = normalizeDiff(newRotation); const maxRot = STOP_ANGLE - startAngle; // If rotation is negative, it might be because we've crossed the 180 boundary // for a high number digit (like 9 or 0). // Check if adding 360 brings us into a valid positive range close to maxRot. // We allow a small buffer over maxRot for elasticity. if (newRotation < 0 && newRotation + 360 <= maxRot + 30) { newRotation += 360; } // Clamp if (newRotation < -10) newRotation = -10; if (newRotation > maxRot) newRotation = maxRot; rotation.set(newRotation); }, [activeDigit, isDragging, rotation, getDigitAngle], ); const handleEnd = React.useCallback(() => { if (!isDragging) return; setIsDragging(false); const maxRot = STOP_ANGLE - getDigitAngle(activeDigit); const threshold = 15; const currentRot = rotation.get(); if (Math.abs(currentRot - maxRot) < threshold) { if (onDial) onDial(activeDigit); } setActiveDigit(null); // Animate back to 0 animationControls.current = animate(rotation, 0, { type: 'spring', stiffness: 200, damping: 20, mass: 1, }); }, [activeDigit, isDragging, onDial, rotation, getDigitAngle]); useEffect(() => { const onMove = (e) => handleMove(e); const onUp = () => handleEnd(); if (isDragging) { window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onUp); } return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onUp); }; }, [isDragging, handleMove, handleEnd]); return (
{DIGITS.map((digit) => { const angle = getDigitAngle(digit); return (
{digit}
); })}
FEZ
CODE
{DIGITS.map((digit) => { const angle = getDigitAngle(digit); return (
handleStart(e, digit)} onTouchStart={(e) => handleStart(e, digit)} >
); })}
); }; export default RotaryDial;