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:

Quick tip…
Try to minimize your questioning about the problem. Because it will be much easier for someone to debug it, or at least try to provide some illustration of the problem.

I don’t quite understand the scope of this question: I am using getWorldPosition(), but the lines are still not positioned properly.

What do you mean by properly? Next to the sphere, along the sphere, the lines are supposed to be connected - but how, aren’t the lines themselves rendered as you expect?