178 lines
6.8 KiB
JavaScript
178 lines
6.8 KiB
JavaScript
import { useEffect, useState, useRef, useCallback } from "react";
|
|
import ForceGraph3D from "react-force-graph-3d";
|
|
import graphData from "../data.json";
|
|
import SpriteText from "https://esm.sh/three-spritetext";
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
import { faInfoCircle, faExternalLinkAlt, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
|
|
|
export default function GraphComponent({ selectedCategory }) {
|
|
const fgRef = useRef();
|
|
const [selectedNode, setSelectedNode] = useState(null);
|
|
const [graphDataState, setGraphDataState] = useState({ nodes: [], links: [] });
|
|
const [imageIndex, setImageIndex] = useState(0); // State to track current image in the carousel
|
|
|
|
// Set distance from camera orbit
|
|
const distance = 400;
|
|
const speed = 0.3;
|
|
|
|
useEffect(() => {
|
|
// Clone the original graph data to avoid modifying it directly
|
|
const updatedGraphData = {
|
|
nodes: graphData.nodes.map(node => ({
|
|
...node,
|
|
visible: !selectedCategory || node.category === selectedCategory // Hide nodes not in selected category
|
|
})),
|
|
links: graphData.links.map(link => ({
|
|
...link,
|
|
visible:
|
|
(!selectedCategory ||
|
|
(graphData.nodes.find(n => n.id === link.source)?.category === selectedCategory &&
|
|
graphData.nodes.find(n => n.id === link.target)?.category === selectedCategory)) // Hide links if either connected node is hidden
|
|
}))
|
|
};
|
|
|
|
setGraphDataState(updatedGraphData);
|
|
|
|
// Camera orbit logic
|
|
const cameraOrbit = () => {
|
|
let angle = 0;
|
|
fgRef.current.cameraPosition({ z: distance });
|
|
|
|
// Set interval for camera orbit effect
|
|
const intervalId = setInterval(() => {
|
|
fgRef.current.cameraPosition({
|
|
x: distance * Math.sin(angle),
|
|
z: distance * Math.cos(angle)
|
|
});
|
|
angle += (Math.PI / 300) * speed;
|
|
}, 10);
|
|
|
|
// Clear up the interval when the component is unmounted
|
|
return () => clearInterval(intervalId);
|
|
};
|
|
|
|
// Call camera orbit after the graph is set
|
|
const cleanupCameraOrbit = cameraOrbit();
|
|
|
|
return cleanupCameraOrbit;
|
|
}, [selectedCategory]);
|
|
|
|
const handleNodeClick = useCallback((node) => {
|
|
setSelectedNode(node);
|
|
setImageIndex(0); // Reset the image index when a new node is clicked
|
|
}, []);
|
|
|
|
const handleBackgroundClick = useCallback(() => {
|
|
setSelectedNode(null);
|
|
}, []);
|
|
|
|
// Carousel navigation functions
|
|
const prevImage = () => {
|
|
if (selectedNode && selectedNode.image && Array.isArray(selectedNode.image)) {
|
|
setImageIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : selectedNode.image.length - 1));
|
|
}
|
|
};
|
|
|
|
const nextImage = () => {
|
|
if (selectedNode && selectedNode.image && Array.isArray(selectedNode.image)) {
|
|
setImageIndex((prevIndex) => (prevIndex < selectedNode.image.length - 1 ? prevIndex + 1 : 0));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Information Window Wrapper */}
|
|
{selectedNode && (
|
|
<div className="bg-gray-700 rounded-lg shadow-md z-10
|
|
absolute left-[50%] top-[50%] transform-gpu -translate-x-1/2 -translate-y-1/2 p-4 h-[max-content] max-h-[80%]">
|
|
{/* Image and Description */}
|
|
<div className="rounded-sm flex flex-row h-[100%] w-[100%] space-x-4">
|
|
{/* Image Section */}
|
|
{selectedNode.image && (
|
|
<div className="flex flex-col justify-center items-center relative">
|
|
{/* If it's an array of images, render carousel */}
|
|
{Array.isArray(selectedNode.image) ? (
|
|
<div className="relative">
|
|
<img
|
|
className="rounded-sm object-contain w-[100%] h-auto"
|
|
src={selectedNode.image[imageIndex]}
|
|
alt={`${selectedNode.name} - Image ${imageIndex + 1}`}
|
|
/>
|
|
{/* Left and Right Chevron Buttons */}
|
|
<button
|
|
className="absolute left-0 top-1/2 transform -translate-y-1/2 text-white text-xl"
|
|
onClick={prevImage}
|
|
>
|
|
<FontAwesomeIcon icon={faChevronLeft} />
|
|
</button>
|
|
<button
|
|
className="absolute right-0 top-1/2 transform -translate-y-1/2 text-white text-xl"
|
|
onClick={nextImage}
|
|
>
|
|
<FontAwesomeIcon icon={faChevronRight} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
// Single image
|
|
<img
|
|
className="rounded-sm object-contain w-[100%]"
|
|
src={selectedNode.image}
|
|
alt={selectedNode.name}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Text Content */}
|
|
<div className="w-[100%] max-w-64ch">
|
|
<div className="flex flex-row space-x-2">
|
|
{/* Title */}
|
|
<h3 className="text-lg font-bold">{selectedNode.name}</h3>
|
|
{/* Info Button fontawesome */}
|
|
{selectedNode.link && (
|
|
<div className="flex space-x-2 ml-2">
|
|
<button className="text-[rgba(255,255,255,0.5)] text-sm">
|
|
<FontAwesomeIcon icon={faInfoCircle} />
|
|
</button>
|
|
<button className="hover:text-white text-[rgba(255,255,255,0.5)] text-sm">
|
|
<a href={selectedNode.link} target="_blank" rel="noreferrer">
|
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
|
</a>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<h6 className="text-xs text-gray-400 mb-2">{selectedNode.location.city} | {selectedNode.location.state} | {selectedNode.location.country}</h6>
|
|
{/* Description */}
|
|
<p className="text-sm text-justify">{selectedNode.description}</p>
|
|
</div>
|
|
{/* Close button */}
|
|
<button
|
|
className="self-start hover:text-white text-[rgba(255,255,255,0.5)] text-sm"
|
|
onClick={() => setSelectedNode(null)}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ForceGraph3D */}
|
|
<ForceGraph3D
|
|
ref={fgRef}
|
|
graphData={graphDataState}
|
|
nodeAutoColorBy="group"
|
|
nodeThreeObject={node => {
|
|
const sprite = new SpriteText(node.name);
|
|
sprite.color = node.color;
|
|
sprite.textHeight = 8;
|
|
return sprite;
|
|
}}
|
|
linkVisibility={link => link.visible} // Hide links properly
|
|
nodeVisibility={node => node.visible} // Hide nodes properly
|
|
onNodeClick={handleNodeClick}
|
|
onBackgroundClick={handleBackgroundClick}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|