import React, { useEffect, useRef, useState } from "react"; export interface Tag { src: string; size?: number; phi?: number; theta?: number; x?: number; y?: number; z?: number; img?: HTMLImageElement; } export type CloudMode = 'sphere' | 'horizontal'; interface GlobeTagCloudProps { tags: Tag[]; height: number; width: number; rotationSpeed?: number; enablePulse?: boolean; mode?: CloudMode; } const TagCloudCanvas: React.FC = ({ tags, height, width, rotationSpeed = 0.5, enablePulse = false, mode = 'sphere' }) => { const canvasRef = useRef(null); // initializedTags stores the *current* position state of the tags const [initializedTags, setInitializedTags] = useState([]); const [imagesLoaded, setImagesLoaded] = useState(false); const animationRef = useRef(0); // Use a ref to store positions for the animation loop to modify directly // This avoids React state updates on every frame which would be too slow // x, y, z are on the unit sphere [-1, 1] const positionsRef = useRef>([]); const rotationRef = useRef({ // Normalize initial axis (-1, 1) -> (-0.707, 0.707) x: -0.7071, y: 0.7071, }); useEffect(() => { const distributeOnGlobe = (index: number, total: number) => { const goldenRatio = (1 + Math.sqrt(5)) / 2; const i = index + 0.5; const phi = Math.acos(1 - 2 * i / total); const theta = 2 * Math.PI * i / goldenRatio; return { x: Math.cos(theta) * Math.sin(phi), y: Math.sin(theta) * Math.sin(phi), z: Math.cos(phi) }; }; const distributeOnCylinder = (index: number, total: number) => { // Distribute evenly around the Y-axis const theta = (2 * Math.PI * index) / total; // Distribute Y from -1 to 1 based on index (or random) // Using index based distribution to cover height evenly // We want to avoid all tags being on a single line if few tags // Let's use a staggered grid or just linear distribution for now // Or maybe randomize Y slightly or use golden ratio for Y too? // Simple linear spread for Y might be boring. // Let's use golden ratio for Y to avoid stacking. // Map index to [-0.8, 0.8] to avoid poles being too crowded or cut off // Actually for cylinder, top and bottom are fine. const y = 1 - (index / (total - 1)) * 2; return { x: Math.sin(theta), y: y, z: Math.cos(theta) }; }; const loadImages = async () => { try { const loadedTags = await Promise.all( tags.map(async (tag, index) => { const img = new Image(); img.src = tag.src; await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; }); // Initial distribution based on mode let pos; if (mode === 'horizontal') { pos = distributeOnCylinder(index, tags.length); } else { pos = distributeOnGlobe(index, tags.length); } const baseSize = Math.min(width, height) * 0.08; return { ...tag, img, x: pos.x, y: pos.y, z: pos.z, size: tag.size ?? baseSize }; }) ); // Initialize positionsRef positionsRef.current = loadedTags.map(tag => ({ tag, x: tag.x!, y: tag.y!, z: tag.z!, scale: 1 })); setInitializedTags(loadedTags); setImagesLoaded(true); } catch (error) { console.error("Error loading images:", error); } }; loadImages(); }, [tags, width, height, mode]); useEffect(() => { if (!imagesLoaded || !canvasRef.current || positionsRef.current.length === 0) return; const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); if (!ctx) return; const radius = Math.min(width, height) * 0.35; const handleMouseMove = (e: MouseEvent) => { if (mode === 'horizontal') return; // Disable mouse rotation for horizontal mode for now const rect = canvas.getBoundingClientRect(); const centerX = rect.left + width / 2; const centerY = rect.top + height / 2; const dx = e.clientX - centerX; const dy = e.clientY - centerY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > 0) { // Determine rotation axis from mouse position rotationRef.current.x = dy / distance; rotationRef.current.y = dx / distance; } }; const handleMouseLeave = () => { if (enablePulse && mode === 'sphere') { // Reset to diagonal, normalized rotationRef.current.x = -0.7071; rotationRef.current.y = 0.7071; } }; const drawTag = ( ctx: CanvasRenderingContext2D, tag: Tag, x: number, y: number, scale: number ) => { if (!tag.img) return; ctx.save(); ctx.translate(x, y); const size = tag.size! * (0.6 + scale * 0.4); const opacity = Math.pow(scale, 1.5); ctx.globalAlpha = Math.min(1, Math.max(0.2, opacity)); ctx.drawImage( tag.img, -size / 2, -size / 2, size, size ); ctx.restore(); }; const animate = () => { ctx.clearRect(0, 0, width, height); ctx.save(); ctx.translate(width / 2, height / 2); // Incremental rotation angle // For horizontal mode, we might want constant speed regardless of mouse? // Or just use rotationSpeed prop. const angle = rotationSpeed * 0.01; const cos = Math.cos(angle); const sin = Math.sin(angle); if (mode === 'horizontal') { // Force rotation around Y axis // Axis vector (0, 1, 0) // However, our rotation logic rotates around (rx, ry, 0) - this is a limitation of the current math! // The current math assumes rotation axis is in XY plane (perpendicular to Z). // Wait, the previous implementation: // rotationRef.current.x corresponds to logical X component of axis? // Let's re-read the math. // Current math: // const newX = (cos + rx * rx * (1 - cos)) * x + (rx * ry * (1 - cos)) * y + (ry * sin) * z; // This is Rodrigues formula for axis (rx, ry, 0) IF z-component of axis is 0. // To rotate around Y axis (0, 1, 0), we need a general rotation matrix or update the formula. // Standard Y-axis rotation: // x' = x cos(a) + z sin(a) // y' = y // z' = -x sin(a) + z cos(a) // We should branch logic here. positionsRef.current.forEach(p => { const { x, y, z } = p; const newX = x * cos + z * sin; const newY = y; const newZ = -x * sin + z * cos; p.x = newX; p.y = newY; p.z = newZ; // Scale logic relies on Z depth p.scale = (radius + newZ * radius) / (radius * 2); }); } else { // Sphere logic const rx = rotationRef.current.x; const ry = rotationRef.current.y; positionsRef.current.forEach(p => { const { x, y, z } = p; // Rotate point around current axis (rx, ry, 0) const newX = (cos + rx * rx * (1 - cos)) * x + (rx * ry * (1 - cos)) * y + (ry * sin) * z; const newY = (rx * ry * (1 - cos)) * x + (cos + ry * ry * (1 - cos)) * y - (rx * sin) * z; const newZ = (-ry * sin) * x + (rx * sin) * y + cos * z; p.x = newX; p.y = newY; p.z = newZ; p.scale = (radius + newZ * radius) / (radius * 2); }); } // Sort by Z for proper occlusion const sortedPositions = [...positionsRef.current].sort((a, b) => b.z - a.z); sortedPositions.forEach(p => { drawTag(ctx, p.tag, p.x * radius, p.y * radius, p.scale); }); ctx.restore(); animationRef.current = requestAnimationFrame(animate); }; canvas.addEventListener("mousemove", handleMouseMove); canvas.addEventListener("mouseleave", handleMouseLeave); animationRef.current = requestAnimationFrame(animate); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } canvas.removeEventListener("mousemove", handleMouseMove); canvas.removeEventListener("mouseleave", handleMouseLeave); }; }, [imagesLoaded, height, width, rotationSpeed, enablePulse, mode]); return (
); }; export default TagCloudCanvas;