Line Positioning issue

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 :slight_smile: