"use client"; import React, { useEffect, useRef, useState } from "react"; import { renderToString } from "react-dom/server"; interface Icon { x: number; y: number; z: number; scale: number; opacity: number; id: number; } interface IconCloudProps { icons?: React.ReactNode[]; images?: string[]; } function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3; } export function IconCloud({ icons, images }: IconCloudProps) { const canvasRef = useRef(null); const [iconPositions, setIconPositions] = useState([]); const [rotation, _setRotation] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 }); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [targetRotation, setTargetRotation] = useState<{ x: number; y: number; startX: number; startY: number; distance: number; startTime: number; duration: number; } | null>(null); const animationFrameRef = useRef(0); const rotationRef = useRef(rotation); const iconCanvasesRef = useRef([]); const imagesLoadedRef = useRef([]); // Create icon canvases once when icons/images change useEffect(() => { if (!icons && !images) return; const items = icons || images || []; imagesLoadedRef.current = Array.from({ length: items.length }).fill( false, ) as boolean[]; const newIconCanvases = items.map((item, index) => { const offscreen = document.createElement("canvas"); offscreen.width = 40; offscreen.height = 40; const offCtx = offscreen.getContext("2d"); if (offCtx) { if (images) { // Handle image URLs directly const img = new Image(); img.crossOrigin = "anonymous"; img.src = items[index] as string; img.onload = () => { offCtx.clearRect(0, 0, offscreen.width, offscreen.height); // Create circular clipping path offCtx.beginPath(); offCtx.arc(20, 20, 20, 0, Math.PI * 2); offCtx.closePath(); offCtx.clip(); // Draw the image offCtx.drawImage(img, 0, 0, 40, 40); imagesLoadedRef.current[index] = true; }; } else { // Handle SVG icons offCtx.scale(0.4, 0.4); const svgString = renderToString(item as React.ReactElement); const img = new Image(); img.src = `data:image/svg+xml;base64,${btoa(svgString)}`; img.onload = () => { offCtx.clearRect(0, 0, offscreen.width, offscreen.height); offCtx.drawImage(img, 0, 0); imagesLoadedRef.current[index] = true; }; } } return offscreen; }); iconCanvasesRef.current = newIconCanvases; }, [icons, images]); // Generate initial icon positions on a sphere useEffect(() => { const items = icons || images || []; const newIcons: Icon[] = []; const numIcons = items.length || 20; // Fibonacci sphere parameters const offset = 2 / numIcons; const increment = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < numIcons; i++) { const y = i * offset - 1 + offset / 2; const r = Math.sqrt(1 - y * y); const phi = i * increment; const x = Math.cos(phi) * r; const z = Math.sin(phi) * r; newIcons.push({ x: x * 100, y: y * 100, z: z * 100, scale: 1, opacity: 1, id: i, }); } // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect setIconPositions(newIcons); }, [icons, images]); // Handle mouse events const handleMouseDown = (e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect || !canvasRef.current) return; const x = e.clientX - rect.left; const y = e.clientY - rect.top; const ctx = canvasRef.current.getContext("2d"); if (!ctx) return; iconPositions.forEach((icon) => { const cosX = Math.cos(rotationRef.current.x); const sinX = Math.sin(rotationRef.current.x); const cosY = Math.cos(rotationRef.current.y); const sinY = Math.sin(rotationRef.current.y); const rotatedX = icon.x * cosY - icon.z * sinY; const rotatedZ = icon.x * sinY + icon.z * cosY; const rotatedY = icon.y * cosX + rotatedZ * sinX; const screenX = canvasRef.current!.width / 2 + rotatedX; const screenY = canvasRef.current!.height / 2 + rotatedY; const scale = (rotatedZ + 200) / 300; const radius = 20 * scale; const dx = x - screenX; const dy = y - screenY; if (dx * dx + dy * dy < radius * radius) { const targetX = -Math.atan2( icon.y, Math.sqrt(icon.x * icon.x + icon.z * icon.z), ); const targetY = Math.atan2(icon.x, icon.z); const currentX = rotationRef.current.x; const currentY = rotationRef.current.y; const distance = Math.sqrt( (targetX - currentX) ** 2 + (targetY - currentY) ** 2, ); const duration = Math.min(2000, Math.max(800, distance * 1000)); setTargetRotation({ x: targetX, y: targetY, startX: currentX, startY: currentY, distance, startTime: performance.now(), duration, }); } }); setIsDragging(true); setLastMousePos({ x: e.clientX, y: e.clientY }); }; const handleMouseMove = (e: React.MouseEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); if (rect) { const x = e.clientX - rect.left; const y = e.clientY - rect.top; setMousePos({ x, y }); } if (isDragging) { const deltaX = e.clientX - lastMousePos.x; const deltaY = e.clientY - lastMousePos.y; rotationRef.current = { x: rotationRef.current.x + deltaY * 0.002, y: rotationRef.current.y + deltaX * 0.002, }; setLastMousePos({ x: e.clientX, y: e.clientY }); } }; const handleMouseUp = () => { setIsDragging(false); }; // Animation and rendering useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; const animate = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); const centerX = canvas.width / 2; const centerY = canvas.height / 2; const maxDistance = Math.sqrt(centerX * centerX + centerY * centerY); const dx = mousePos.x - centerX; const dy = mousePos.y - centerY; const distance = Math.sqrt(dx * dx + dy * dy); const speed = 0.003 + (distance / maxDistance) * 0.01; if (targetRotation) { const elapsed = performance.now() - targetRotation.startTime; const progress = Math.min(1, elapsed / targetRotation.duration); const easedProgress = easeOutCubic(progress); rotationRef.current = { x: targetRotation.startX + (targetRotation.x - targetRotation.startX) * easedProgress, y: targetRotation.startY + (targetRotation.y - targetRotation.startY) * easedProgress, }; if (progress >= 1) { // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect setTargetRotation(null); } } else if (!isDragging) { rotationRef.current = { x: rotationRef.current.x + (dy / canvas.height) * speed, y: rotationRef.current.y + (dx / canvas.width) * speed, }; } iconPositions.forEach((icon, index) => { const cosX = Math.cos(rotationRef.current.x); const sinX = Math.sin(rotationRef.current.x); const cosY = Math.cos(rotationRef.current.y); const sinY = Math.sin(rotationRef.current.y); const rotatedX = icon.x * cosY - icon.z * sinY; const rotatedZ = icon.x * sinY + icon.z * cosY; const rotatedY = icon.y * cosX + rotatedZ * sinX; const scale = (rotatedZ + 200) / 300; const opacity = Math.max(0.2, Math.min(1, (rotatedZ + 150) / 200)); ctx.save(); ctx.translate( canvas.width / 2 + rotatedX, canvas.height / 2 + rotatedY, ); ctx.scale(scale, scale); ctx.globalAlpha = opacity; if (icons || images) { // Only try to render icons/images if they exist if ( iconCanvasesRef.current[index] && imagesLoadedRef.current[index] ) { ctx.drawImage(iconCanvasesRef.current[index], -20, -20, 40, 40); } } else { // Show numbered circles if no icons/images are provided ctx.beginPath(); ctx.arc(0, 0, 20, 0, Math.PI * 2); ctx.fillStyle = "#4444ff"; ctx.fill(); ctx.fillStyle = "white"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = "16px Arial"; ctx.fillText(`${icon.id + 1}`, 0, 0); } ctx.restore(); }); animationFrameRef.current = requestAnimationFrame(animate); }; animate(); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [icons, images, iconPositions, isDragging, mousePos, targetRotation]); return ( ); }