Initial commit

This commit is contained in:
Niels Pauls
2026-04-02 12:37:25 +02:00
commit 303c522419
24 changed files with 9028 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
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>
);
}