"use client"; import React, { useEffect, useRef, useState, useMemo } from "react"; import { motion, AnimatePresence, useMotionValue, easeOut } from "framer-motion"; import { cn } from "../../lib/utils"; // Assuming you have this utility for class names import { animate } from "framer-motion"; export interface ThreeDImageRingProps { /** Array of image URLs to display in the ring */ images: string[]; /** Container width in pixels (will be scaled) */ width?: number; /** 3D perspective value */ perspective?: number; /** Distance of images from center (z-depth) */ imageDistance?: number; /** Initial rotation of the ring */ initialRotation?: number; /** Animation duration for entrance */ animationDuration?: number; /** Stagger delay between images */ staggerDelay?: number; /** Hover opacity for non-hovered images */ hoverOpacity?: number; /** Custom container className */ containerClassName?: string; /** Custom ring className */ ringClassName?: string; /** Custom image className */ imageClassName?: string; /** Background color of the stage */ backgroundColor?: string; /** Enable/disable drag functionality */ draggable?: boolean; /** Animation ease for entrance */ ease?: string; /** Breakpoint for mobile responsiveness (e.g., 768 for iPad mini) */ mobileBreakpoint?: number; /** Scale factor for mobile (e.g., 0.7 for 70% size) */ mobileScaleFactor?: number; /** Power for the drag end inertia animation (higher means faster stop) */ inertiaPower?: number; /** Time constant for the drag end inertia animation (duration of deceleration in ms) */ inertiaTimeConstant?: number; /** Multiplier for initial velocity when drag ends (influences initial "spin") */ inertiaVelocityMultiplier?: number; } export function ThreeDImageRing({ images, width = 300, perspective = 2000, imageDistance = 500, initialRotation = 180, animationDuration = 1.5, staggerDelay = 0.1, hoverOpacity = 0.5, containerClassName, ringClassName, imageClassName, backgroundColor, draggable = true, ease = "easeOut", mobileBreakpoint = 768, mobileScaleFactor = 0.8, inertiaPower = 0.8, // Default power for inertia inertiaTimeConstant = 300, // Default time constant for inertia inertiaVelocityMultiplier = 20, // Default multiplier for initial spin }: ThreeDImageRingProps) { const containerRef = useRef(null); const ringRef = useRef(null); const rotationY = useMotionValue(initialRotation); const startX = useRef(0); const currentRotationY = useRef(initialRotation); const isDragging = useRef(false); const velocity = useRef(0); // To track drag velocity const [currentScale, setCurrentScale] = useState(1); const [showImages, setShowImages] = useState(false); const angle = useMemo(() => 360 / images.length, [images.length]); const getBgPos = (imageIndex: number, currentRot: number, scale: number) => { const scaledImageDistance = imageDistance * scale; const effectiveRotation = currentRot - 180 - imageIndex * angle; const parallaxOffset = ((effectiveRotation % 360 + 360) % 360) / 360; return `${-(parallaxOffset * (scaledImageDistance / 1.5))}px 0px`; }; useEffect(() => { const unsubscribe = rotationY.on("change", (latestRotation) => { if (ringRef.current) { Array.from(ringRef.current.children).forEach((imgElement, i) => { (imgElement as HTMLElement).style.backgroundPosition = getBgPos( i, latestRotation, currentScale ); }); } currentRotationY.current = latestRotation; }); return () => unsubscribe(); }, [rotationY, images.length, imageDistance, currentScale, angle]); useEffect(() => { const handleResize = () => { const viewportWidth = window.innerWidth; const newScale = viewportWidth <= mobileBreakpoint ? mobileScaleFactor : 1; setCurrentScale(newScale); }; window.addEventListener("resize", handleResize); handleResize(); return () => window.removeEventListener("resize", handleResize); }, [mobileBreakpoint, mobileScaleFactor]); useEffect(() => { setShowImages(true); }, []); const handleDragStart = (event: React.MouseEvent | React.TouchEvent) => { if (!draggable) return; isDragging.current = true; const clientX = "touches" in event ? event.touches[0].clientX : event.clientX; startX.current = clientX; // Stop any ongoing animation instantly when drag starts rotationY.stop(); velocity.current = 0; // Reset velocity if (ringRef.current) { (ringRef.current as HTMLElement).style.cursor = "grabbing"; } // Attach global move and end listeners to document when dragging starts document.addEventListener("mousemove", handleDrag); document.addEventListener("mouseup", handleDragEnd); document.addEventListener("touchmove", handleDrag); document.addEventListener("touchend", handleDragEnd); }; const handleDrag = (event: MouseEvent | TouchEvent) => { // Only proceed if dragging is active if (!draggable || !isDragging.current) return; const clientX = "touches" in event ? (event as TouchEvent).touches[0].clientX : (event as MouseEvent).clientX; const deltaX = clientX - startX.current; // Update velocity based on deltaX velocity.current = -deltaX * 0.5; // Factor of 0.5 to control sensitivity rotationY.set(currentRotationY.current + velocity.current); startX.current = clientX; }; const handleDragEnd = () => { isDragging.current = false; if (ringRef.current) { ringRef.current.style.cursor = "grab"; currentRotationY.current = rotationY.get(); } document.removeEventListener("mousemove", handleDrag); document.removeEventListener("mouseup", handleDragEnd); document.removeEventListener("touchmove", handleDrag); document.removeEventListener("touchend", handleDragEnd); const initial = rotationY.get(); const velocityBoost = velocity.current * inertiaVelocityMultiplier; const target = initial + velocityBoost; // Animate with inertia manually using `animate()` animate(initial, target, { type: "inertia", velocity: velocityBoost, power: inertiaPower, timeConstant: inertiaTimeConstant, restDelta: 0.5, modifyTarget: (target) => Math.round(target / angle) * angle, onUpdate: (latest) => { rotationY.set(latest); }, }); velocity.current = 0; }; // Corrected imageVariants: no function for 'visible' state const imageVariants = { hidden: { y: 200, opacity: 0 }, visible: { y: 0, opacity: 1, // Transition properties will be defined directly on the motion.div using `custom` prop }, }; return (
{showImages && images.map((imageUrl, index) => ( { // Prevent hover effects while dragging if (isDragging.current) return; if (ringRef.current) { Array.from(ringRef.current.children).forEach((imgEl, i) => { if (i !== index) { (imgEl as HTMLElement).style.opacity = `${hoverOpacity}`; } }); } }} onHoverEnd={() => { // Prevent hover effects while dragging if (isDragging.current) return; if (ringRef.current) { Array.from(ringRef.current.children).forEach((imgEl) => { (imgEl as HTMLElement).style.opacity = `1`; }); } }} /> ))}
); } export default ThreeDImageRing;