USER
how to reduce the gap between the images with the continuous loop.
// Start of Selection
import React, { useState, useEffect, useCallback, useRef } from "react";
import { degreesToRadians } from "popmotion";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { Easings } from "@/lib/utils";
interface ProjectImage {
id: number;
url: string;
title: string;
subtitle: string;
width: number;
x: number;
y: number;
angle: number;
}
interface Project {
id: number;
name: string;
images: ProjectImage[];
}
// Constant project data defined outside the component
const PROJECTS: Project[] = [
{
id: 1,
name: "Project Alpha",
images: [
{
id: 1,
url: `https://picsum.photos/800/400?random=1`,
title: "Alpha Main View",
subtitle: "Frontend dashboard",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
{
id: 2,
url: `https://picsum.photos/600/400?random=2`,
title: "Alpha User Profile",
subtitle: "User settings page",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
{
id: 3,
url: `https://picsum.photos/700/400?random=3`,
title: "Alpha Analytics",
subtitle: "Data visualization",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
],
},
{
id: 2,
name: "Project Beta",
images: [
{
id: 4,
url: `https://picsum.photos/750/400?random=4`,
title: "Beta Homepage",
subtitle: "Responsive design",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
{
id: 5,
url: `https://picsum.photos/650/400?random=5`,
title: "Beta Mobile App",
subtitle: "iOS interface",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
],
},
{
id: 3,
name: "Project Gamma",
images: [
{
id: 6,
url: `https://picsum.photos/900/400?random=6`,
title: "Gamma 3D Model",
subtitle: "Product showcase",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
{
id: 7,
url: `https://picsum.photos/550/400?random=7`,
title: "Gamma AR View",
subtitle: "Augmented reality feature",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
{
id: 8,
url: `https://picsum.photos/720/400?random=8`,
title: "Gamma User Testing",
subtitle: "Usability study results",
width: 138.38,
x: 0,
y: 0,
angle: 0,
},
],
},
];
const ProjectImageGallery: React.FC = () => {
const [projects, setProjects] = useState<Project[]>(PROJECTS);
const [rotation, setRotation] = useState(0);
const [startAngle, setStartAngle] = useState(degreesToRadians(-2));
const [endAngle, setEndAngle] = useState(degreesToRadians(182));
const containerRef = useRef<HTMLDivElement>(null);
const [ellipseWidth, setEllipseWidth] = useState(2700);
const [ellipseHeight, setEllipseHeight] = useState(400);
const [visibleImages, setVisibleImages] = useState<ProjectImage[]>([]);
// Update ellipse dimensions based on parent size
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
setEllipseWidth(clientWidth);
setEllipseHeight(clientHeight);
}
};
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, []);
const updateImagePositions = useCallback(
(currentRotation: number) => {
const allImages = projects.flatMap((project) => project.images);
const numImages = allImages.length;
const a = ellipseWidth / 2;
const b = ellipseHeight / 2;
// Ramanujan's approximation for ellipse perimeter
const perimeter =
Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
const stride = perimeter / numImages;
let angle = currentRotation;
const updatedProjects = projects.map((project) => ({
...project,
images: project.images.map((image) => {
let accumulatedArc = 0;
const deltaTheta = 0.0001;
while (accumulatedArc < stride) {
accumulatedArc +=
Math.sqrt(
Math.pow(a * Math.sin(angle), 2) +
Math.pow(b * Math.cos(angle), 2),
) * deltaTheta;
angle += deltaTheta;
angle %= 2 * Math.PI;
}
const x = a * Math.cos(angle) + a;
const y = b * Math.sin(angle) + b;
return { ...image, x, y, angle };
}),
}));
return updatedProjects;
},
[ellipseWidth, ellipseHeight],
);
const isImageVisible = useCallback(
(angle: number) => {
const normalizedAngle = (angle + 2 * Math.PI) % (2 * Math.PI);
const normalizedStart = (startAngle + 2 * Math.PI) % (2 * Math.PI);
const normalizedEnd = (endAngle + 2 * Math.PI) % (2 * Math.PI);
if (normalizedStart <= normalizedEnd) {
return (
normalizedAngle >= normalizedStart && normalizedAngle <= normalizedEnd
);
} else {
return (
normalizedAngle >= normalizedStart || normalizedAngle <= normalizedEnd
);
}
},
[startAngle, endAngle],
);
const determineVisibleImages = useCallback(() => {
const allImages = projects.flatMap((project) => project.images);
const visible = allImages.filter((image) => isImageVisible(image.angle));
// Sort visible images by angle to prevent overlap
const sortedVisible = visible.sort((a, b) => a.angle - b.angle);
// Adjust angles to prevent overlap
const buffer = 0.05; // Minimum angle between images
for (let i = 1; i < sortedVisible.length; i++) {
const prev = sortedVisible[i - 1];
const current = sortedVisible[i];
if (current.angle - prev.angle < buffer) {
sortedVisible[i].angle = prev.angle + buffer;
sortedVisible[i].angle %= 2 * Math.PI;
}
}
return sortedVisible;
}, [projects, isImageVisible]);
useEffect(() => {
const updatedProjects = updateImagePositions(rotation);
setProjects(updatedProjects);
}, [rotation, updateImagePositions]);
useEffect(() => {
const visibleImages = determineVisibleImages();
setVisibleImages(visibleImages);
}, [projects, determineVisibleImages]);
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * 0 + e.deltaX;
setRotation((prevRotation) => prevRotation + delta * 0.001);
};
window.addEventListener("wheel", handleWheel);
return () => window.removeEventListener("wheel", handleWheel);
}, []);
return (
<div
className="relative mx-auto flex w-full max-w-screen-xl items-center justify-center"
style={{
// width: `${ellipseWidth}px`,
height: `${ellipseHeight}px`,
}}
>
<div className="relative mx-auto w-full px-4 sm:px-6 lg:px-8">
<div
ref={containerRef}
className="relative w-full"
style={{
width: "100%",
paddingTop: `${(ellipseHeight / ellipseWidth) * 100}%`,
}}
>
<AnimatePresence>
{projects.map((project) =>
project.images.map(
(image) =>
isImageVisible(image.angle) && (
<motion.div
key={image.id}
initial={{ opacity: 0, scale: 0.8, y: -100 }}
animate={{
opacity: 1,
scale: 1,
y: 0,
transition: {
duration: 0.3,
ease: Easings.easeOutCubic,
},
}}
exit={{
opacity: 0,
scale: 0.8,
y: -100,
transition: {
duration: 0.3,
ease: Easings.easeOutExpo,
},
}}
transition={{ duration: 0.3 }}
className="absolute inset-x-0"
style={{
left: `${((image.x - image.width / 2) / ellipseWidth) * 100}%`,
top: `${(image.y / ellipseHeight) * 100}%`,
width: `${image.width}px`,
height: "auto",
transform: "translate(-50%, -50%)",
zIndex: 20,
}}
>
<Image
src={image.url}
alt={image.title}
placeholder="blur"
blurDataURL={image.url}
width={image.width}
height={300}
className="m-0 h-[300px] w-full rounded-lg object-cover shadow-md"
/>
{/* <div className="absolute bottom-0 left-0 right-0 rounded-b-lg bg-black bg-opacity-50 p-4 text-white">
<h3 className="text-lg font-semibold">{image.title}</h3>
<p className="text-sm">{image.subtitle}</p>
</div> */}
</motion.div>
),
),
)}
</AnimatePresence>
<svg
className="pointer-events-none absolute left-0 top-0 h-full w-full"
style={{ zIndex: 10 }}
>
<path
d={`M ${ellipseWidth / 2},${ellipseHeight / 2}
L ${(ellipseWidth / 2) * (1 + Math.cos(startAngle))},${
(ellipseHeight / 2) * (1 + Math.sin(startAngle))
}
A ${ellipseWidth / 2},${ellipseHeight / 2} 0 ${
endAngle - startAngle > Math.PI ? 1 : 0
},1
${(ellipseWidth / 2) * (1 + Math.cos(endAngle))},${
(ellipseHeight / 2) * (1 + Math.sin(endAngle))
} Z`}
fill="rgba(255, 255, 255, 0.5)"
stroke="black"
strokeWidth="1"
/>
</svg>
</div>
{/* <div className="mt-4">
<h2 className="mb-2 text-xl font-semibold">Active Project:</h2>
{visibleImages.length > 0 ? (
<ul className="list-inside list-disc">
{Array.from(
new Set(
visibleImages.map(
(image) =>
projects.find((project) =>
project.images.some((img) => img.id === image.id),
)?.name,
),
),
).map((projectName, index) => (
<li key={index}>
<strong>{projectName}</strong>
</li>
))}
</ul>
) : (
<p>
No projects are currently visible in the selected angle range.
</p>
)}
</div> */}
</div>
</div>
);
};
export default ProjectImageGallery;