328 lines
9.7 KiB
TypeScript
328 lines
9.7 KiB
TypeScript
"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<HTMLCanvasElement>(null);
|
|
const [iconPositions, setIconPositions] = useState<Icon[]>([]);
|
|
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<number>(0);
|
|
const rotationRef = useRef(rotation);
|
|
const iconCanvasesRef = useRef<HTMLCanvasElement[]>([]);
|
|
const imagesLoadedRef = useRef<boolean[]>([]);
|
|
|
|
// 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<HTMLCanvasElement>) => {
|
|
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<HTMLCanvasElement>) => {
|
|
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 (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={400}
|
|
height={400}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
className="tw-rounded-lg"
|
|
aria-label="Interactive 3D Icon Cloud"
|
|
role="img"
|
|
/>
|
|
);
|
|
}
|