Hey guys, need a little help here!
I am using react-three-fiber and react-three-drei to build an undirected graph in a spherical shape.
- Nodes (
Element.tsx
) → Represented as circles - Clusters (
Blob.tsx
) → Larger circles that group some nodes
I have successfully placed the clusters on the surface of a sphere, but I’m struggling with positioning the connecting lines correctly.
I am using getWorldPosition()
, but the lines are still not positioned properly.
See the attached image for clarity.
Here’s my code:
Blob.tsx
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { GroupProps, ThreeEvent } from '@react-three/fiber';
import { Text } from '@react-three/drei';
import { wrapText } from '../utils';
import { Element } from './element';
import { vertexShader, fragmentShader } from './shaders/blobShaders';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { useGraphStore } from '../stores/graphStore';
import ExtraNode from './ExtraNode';
interface Props extends GroupProps {
clusterId: number;
}
const Blob = ({ ...props }: Props) => {
const meshRef = useRef<THREE.Mesh>(null!);
const groupRef = useRef<THREE.Group>(null!);
const [nodes, setNodes] = useState<number[]>([]);
const setElement = useGraphStore(state => state.setElement);
const cluster = useGraphStore(state =>
state.clusters.find(c => c.cluster_id === props.clusterId)
)!;
const setCluster = useGraphStore(state => state.setCluster);
const clusters = useGraphStore(state => state.clusters);
const elements = useGraphStore(state => state.elements);
const containedNodes = elements.filter(
el => el.cluster_id === cluster.cluster_id
);
useLayoutEffect(() => {
if (!cluster.clusterRef) {
setCluster({ ...cluster, clusterRef: meshRef.current });
}
}, [cluster]);
useEffect(() => {
setNodes(containedNodes.map(el => el.id));
}, [elements]);
const scaleFactor = 0.045;
const baseRadius = 0.5;
const maxLineChars = 10;
const sphereSize = baseRadius + cluster.cluster_name.length * scaleFactor;
const expandedSphereSize = sphereSize + nodes.length * 0.4;
const uniforms = useRef({
uTime: { value: 0.0 },
uColor: { value: new THREE.Color(cluster.bg) },
});
const { contextSafe } = useGSAP();
useEffect(() => {
if (!cluster.isExpanded) return;
const material = meshRef.current.material as THREE.ShaderMaterial;
material.uniforms.uColor.value = new THREE.Color('#fff');
gsap.to(meshRef.current.scale, {
x: expandedSphereSize,
y: expandedSphereSize,
ease: 'elastic.out(1,0.3)',
duration: 1,
});
}, [cluster.isExpanded]);
useEffect(() => {
if (cluster.isExpanded) return;
const material = meshRef.current.material as THREE.ShaderMaterial;
material.uniforms.uColor.value = new THREE.Color(cluster.bg);
[...containedNodes].forEach(el => {
setElement({
...el,
isExpanded: false,
links: Object.fromEntries(el.connections.map(c => [c, false])),
});
});
gsap.to(meshRef.current.scale, {
x: sphereSize,
y: sphereSize,
ease: 'elastic.out(1,0.3)',
duration: 1,
});
}, [cluster.isExpanded]);
useEffect(() => {
const allCollapsed = containedNodes.every(el => !el.isExpanded);
if (!allCollapsed || !cluster.isExpanded) return;
console.log(cluster.cluster_name, 'collapsed');
setCluster({
...cluster,
isExpanded: false,
});
}, [elements]);
const handleClick = contextSafe((e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
clusters.forEach(c => {
if (c.cluster_id === cluster.cluster_id)
return setCluster({
...c,
isExpanded: !c.isExpanded,
});
setCluster({
...c,
isExpanded: false,
});
});
containedNodes.forEach(el => {
setElement({
...el,
isExpanded: true,
});
});
});
const lines = wrapText(cluster.cluster_name, maxLineChars);
const getElements = () => {
const radius = Math.max(3, nodes.length * 1.2);
const angleStep = (Math.PI * 2) / nodes.length;
const nameScale = 0.015 * cluster.cluster_name.length;
return nodes.map((id, i) => {
const angle = i * angleStep;
const toggle = Math.pow(-1, i);
const x =
Math.cos(angle) * radius * nameScale +
toggle * 0.1 +
toggle * nodes.length * 0.1 -
cluster.cluster_name.length * 0.01 * toggle;
const y =
Math.sin(angle) * radius * nameScale +
lines.length * 0.18 * toggle -
toggle * nodes.length * 0.2 -
cluster.cluster_name.length * 0.045 * toggle;
return <Element key={id} elementId={id} position={[x, y, 0.05]} />;
});
};
return (
<group
ref={groupRef}
{...props}
name={`cluster_${cluster.cluster_id}`}
matrixAutoUpdate={false}
>
<mesh
ref={meshRef}
scale={[sphereSize, sphereSize, 0.01]}
onClick={handleClick}
>
<sphereGeometry args={[1, 64, 64]} />
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={uniforms.current}
/>
</mesh>
<ExtraNode
clusterId={cluster.cluster_id}
position={[expandedSphereSize * 0.6, expandedSphereSize * 0.2, 0.3]}
/>
<Text
position={[0, 0, 0.01]}
color={cluster.isExpanded ? '#921a6b' : cluster.color}
fontSize={0.2}
anchorX='center'
anchorY='middle'
maxWidth={0.5}
textAlign='center'
fontWeight={700}
>
{lines.join('\n').toUpperCase()}
</Text>
{getElements()}
</group>
);
};
export { Blob };
Element.tsx (Graph Node)
import { GroupProps, ThreeEvent } from '@react-three/fiber';
import { QuadraticBezierLine, Text } from '@react-three/drei';
import { wrapText } from '../utils';
import { Mesh, Vector3 } from 'three';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { GraphNode, useGraphStore } from '../stores/graphStore';
interface Props extends GroupProps {
text?: string;
elementId: number;
}
const Element = ({ elementId, ...props }: Props) => {
const meshRef = useRef<Mesh>(null!);
const nodes = useGraphStore(state => state.elements);
const clusters = useGraphStore(state => state.clusters);
const setCluster = useGraphStore(state => state.setCluster);
const setNode = useGraphStore(state => state.setElement);
const currentNode = useGraphStore(state =>
state.elements.find(e => e.id === elementId)
)!;
const parentCluster = useGraphStore(state =>
state.clusters.find(c => c.cluster_id === currentNode.cluster_id)
)!;
const [targetNodes, setTargetNodes] = useState<GraphNode[]>([]);
useLayoutEffect(() => {
if (!currentNode.elementRef) {
setNode({ ...currentNode, elementRef: meshRef.current });
}
}, [currentNode, setNode]);
useEffect(() => {
setTargetNodes(nodes.filter(n => currentNode.connections.includes(n.id)));
}, [nodes, currentNode.connections]);
const baseRadius = 1.6;
const scaleFactor = 0.05;
const maxLineChars = 10;
const lines = wrapText(currentNode.title, maxLineChars);
const radius = baseRadius + currentNode.title.length * scaleFactor;
const getQuadraticLines = () => {
if (!targetNodes.length || !meshRef.current) return null;
const startPos = new Vector3();
meshRef.current.updateMatrixWorld(true);
meshRef.current.getWorldPosition(startPos);
return targetNodes.map(({ elementRef, title, links }, i) => {
if (!elementRef) return null;
const targetPos = new Vector3();
elementRef.updateMatrixWorld(true);
elementRef.getWorldPosition(targetPos);
console.log(
`Line from ${
currentNode.title
} (${startPos.toArray()}) to ${title} (${targetPos.toArray()})`
);
return (
<QuadraticBezierLine
key={`${title}_${i}`}
color='#921a6b'
start={startPos.toArray()} // World position
end={targetPos.toArray()} // World position
visible={
currentNode.isExpanded &&
parentCluster.isExpanded &&
links[elementId]
}
/>
);
});
};
const expand = (target: GraphNode) => {
const parent = clusters.find(c => c.cluster_id === target.cluster_id);
if (!parent) return;
setCluster({
...parent,
isExpanded: true,
connections: [...parent.connections],
});
setNode({
...target,
isExpanded: true,
links: Object.fromEntries(
target.connections.map(id => [id, id == currentNode.id])
),
});
};
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
nodes.forEach(node => {
if (node.id === currentNode.id)
return setNode({
...currentNode,
links: Object.fromEntries(
Object.keys(currentNode.links).map(key => [key, true])
),
isExpanded: true,
});
if (targetNodes.find(n => n.id === node.id)) return expand(node);
setNode({
...node,
isExpanded: false,
links: Object.fromEntries(
Object.keys(node.links).map(key => [key, false])
),
});
});
};
useEffect(() => {
if (meshRef.current) {
const pos = new Vector3();
meshRef.current.getWorldPosition(pos);
console.log(`Node ${currentNode.title} World Position:`, pos.toArray());
}
}, [currentNode]);
return (
<group
{...props}
name={`node_${currentNode.id}`}
visible={currentNode.isExpanded}
>
<mesh ref={meshRef} position-z={0.01} onClick={handleClick}>
<circleGeometry args={[radius * 0.2, 32]} />
<meshBasicMaterial color={0xcce0ff} />
</mesh>
<Text
fontSize={radius * 0.06}
anchorX='center'
anchorY='middle'
maxWidth={0.5}
textAlign='center'
fontWeight={600}
color={'#921a6b'}
position={[0, 0, 0.01]}
>
{lines?.join('\n')}
</Text>
{getQuadraticLines()}
</group>
);
};
export { Element };
App.tsx
import { Canvas, ThreeEvent } from '@react-three/fiber';
import { OrbitControls, Sphere } from '@react-three/drei';
import { Blob } from './components/blob';
import { useGraphStore } from './stores/graphStore';
import './App.css';
import { useMemo, useRef, useEffect } from 'react';
import * as THREE from 'three';
interface OrientedBlobProps {
position: [number, number, number];
clusterId: string;
sphereCenter?: [number, number, number];
}
function OrientedBlob({
position,
clusterId,
sphereCenter = [0, 0, 0],
}: OrientedBlobProps): JSX.Element {
const groupRef = useRef<THREE.Group>(null);
useEffect(() => {
if (groupRef.current) {
groupRef.current.position.set(...position);
const normal = new THREE.Vector3()
.fromArray(position)
.sub(new THREE.Vector3().fromArray(sphereCenter))
.normalize();
const targetPoint = new THREE.Vector3().fromArray(position).add(normal); // Point outside the sphere
groupRef.current.lookAt(targetPoint);
}
}, [position, sphereCenter]);
return (
<group ref={groupRef}>
<Blob clusterId={+clusterId} position={[0, 0, 0]} />
</group>
);
}
function App(): JSX.Element {
const clusters = useGraphStore(state => state.clusters);
const setClusters = useGraphStore(state => state.setClusters);
const sphereRadius: number = 10;
const sphereCenter: [number, number, number] = [0, 0, 0];
const clusterPositions = useMemo((): Array<[number, number, number]> => {
const positions: Array<[number, number, number]> = [];
const totalClusters: number = clusters.length;
if (totalClusters === 0) return positions;
if (totalClusters === 1) {
return [[0, 0, sphereRadius]];
}
const goldenRatio: number = (1 + Math.sqrt(5)) / 2;
for (let i = 0; i < totalClusters; i++) {
const y: number = 1 - (i / (totalClusters - 1)) * 2; // Range from 1 to -1
const radius: number = Math.sqrt(1 - y * y); // Radius at y
const theta: number = (i * (2 * Math.PI)) / goldenRatio; // Golden angle in radians
const x: number = Math.cos(theta) * radius * sphereRadius;
const z: number = Math.sin(theta) * radius * sphereRadius;
const yPos: number = y * sphereRadius * 1.02;
positions.push([x, yPos, z]);
}
return positions;
}, [clusters, sphereRadius]);
const renderClusters = (): JSX.Element[] => {
return clusters.map((cluster, i) => {
const position: [number, number, number] = clusterPositions[i] || [
0, 0, 0,
];
return (
<OrientedBlob
key={cluster.cluster_id}
clusterId={cluster.cluster_id.toString()}
position={position}
sphereCenter={sphereCenter}
/>
);
});
};
const handleClick = (e: ThreeEvent<MouseEvent>): void => {
e.stopPropagation();
setClusters(
clusters.map(c => ({
...c,
isExpanded: false,
}))
);
};
return (
<div className='container'>
<Canvas camera={{ position: [0, 0, 15], fov: 50 }}>
<Sphere args={[sphereRadius, 64, 64]}>
<meshStandardMaterial
color='#1e88e5'
transparent
opacity={0.3}
wireframe={false}
/>
</Sphere>
{renderClusters()}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 10]} intensity={0.8} />
<mesh onClick={handleClick} position={[0, 0, -10]}>
<planeGeometry args={[100, 100]} />
<meshBasicMaterial color='#f0f0f0' transparent opacity={0} />
</mesh>
<OrbitControls enableRotate={true} enableZoom={true} />
</Canvas>
</div>
);
}
export default App;
Thanks in advance