// @ts-nocheck /** * NetworkGraph3D Component * * A 3D force-directed social graph visualization using Three.js. * Renders users as spheres within a bounded transparent sphere, * with connections shown as animated flowing lines between nodes. * * Features: * - Trust-level clustering (trusted → inner, connected → middle, unconnected → outer) * - Node size proportional to decision power/influence * - Animated edge flows showing delegation direction * - Orbit controls for camera navigation (drag to rotate, scroll to zoom) * - Zoom to user with camera animation * - View as user (broadcast mode) for screen following * - Click nodes to select and interact * * Note: @ts-nocheck is used because React Three Fiber JSX types are not * being properly recognized. The code works correctly at runtime. */ import React, { useRef, useMemo, useState, useCallback, useEffect } from 'react'; import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber'; import { OrbitControls, Text, Billboard } from '@react-three/drei'; import * as THREE from 'three'; import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking'; // ============================================================================= // Types // ============================================================================= interface NetworkGraph3DProps { nodes: GraphNode[]; edges: GraphEdge[]; currentUserId?: string; onNodeClick?: (node: GraphNode) => void; onNodeSelect?: (node: GraphNode | null) => void; onConnect?: (userId: string, trustLevel?: TrustLevel) => Promise; onZoomToUser?: (node: GraphNode) => void; onViewAsUser?: (node: GraphNode) => void; isDarkMode?: boolean; sphereRadius?: number; } interface Node3D extends GraphNode { position: THREE.Vector3; velocity: THREE.Vector3; targetRadius: number; // Target distance from center based on trust level decisionPower: number; // Calculated influence metric } interface SelectedNodeInfo { node: GraphNode; screenPosition: { x: number; y: number }; } // ============================================================================= // Force Simulation Constants // ============================================================================= const FORCE_CONFIG = { // Repulsion between all nodes repulsion: 0.6, repulsionDistance: 2.5, // Attraction along edges linkStrength: 0.015, linkDistance: 1.2, // Centering force (now per-shell) shellStrength: 0.03, // Velocity damping damping: 0.88, // Sphere boundary boundaryStrength: 0.4, // Minimum movement threshold to stop simulation minVelocity: 0.0008, // Trust level shell radii (as fraction of sphere radius) trustedShell: 0.35, // Inner - most trusted connectedShell: 0.6, // Middle - connected outerShell: 0.85, // Outer - unconnected/anonymous }; // ============================================================================= // Helper Functions // ============================================================================= function getNodeColor(node: GraphNode, isDarkMode: boolean): string { if (node.roomPresenceColor) return node.roomPresenceColor; if (node.avatarColor) return node.avatarColor; return isDarkMode ? '#6b7280' : '#9ca3af'; } function getEdgeColor(edge: GraphEdge, isDarkMode: boolean): string { const level = edge.effectiveTrustLevel || edge.trustLevel; if (level === 'trusted') return isDarkMode ? '#22c55e' : '#16a34a'; if (level === 'connected') return isDarkMode ? '#eab308' : '#ca8a04'; return isDarkMode ? 'rgba(150, 150, 150, 0.5)' : 'rgba(100, 100, 100, 0.4)'; } /** * Calculate decision power for a node based on incoming connections * Higher power = more people trust/delegate to this user */ function calculateDecisionPower( nodeId: string, edges: GraphEdge[], currentUserId?: string ): number { let power = 1; // Base power for (const edge of edges) { // Count incoming connections (where this node is the target) if (edge.target === nodeId) { const weight = edge.trustLevel === 'trusted' ? 2 : 1; power += weight; } // Mutual connections add extra weight if (edge.isMutual && (edge.source === nodeId || edge.target === nodeId)) { power += 0.5; } } // Current user gets a small boost for visibility if (nodeId === currentUserId) { power += 0.5; } return power; } /** * Determine which shell a node belongs to based on trust relationships */ function getNodeShellRadius( node: GraphNode, edges: GraphEdge[], currentUserId: string | undefined, sphereRadius: number ): number { // Current user is always at center if (node.id === currentUserId || node.isCurrentUser) { return sphereRadius * 0.15; } // Check if this node has trusted relationship with current user const hasTrustedConnection = edges.some( (e) => ((e.source === currentUserId && e.target === node.id) || (e.target === currentUserId && e.source === node.id)) && (e.trustLevel === 'trusted' || e.effectiveTrustLevel === 'trusted') ); if (hasTrustedConnection) { return sphereRadius * FORCE_CONFIG.trustedShell; } // Check if connected const hasConnection = edges.some( (e) => (e.source === currentUserId && e.target === node.id) || (e.target === currentUserId && e.source === node.id) ); if (hasConnection) { return sphereRadius * FORCE_CONFIG.connectedShell; } // Unconnected - outer shell return sphereRadius * FORCE_CONFIG.outerShell; } // ============================================================================= // Animated Edge Flow Component // ============================================================================= interface AnimatedEdgeProps { sourcePosition: THREE.Vector3; targetPosition: THREE.Vector3; edge: GraphEdge; isDarkMode: boolean; } function AnimatedEdge({ sourcePosition, targetPosition, edge, isDarkMode }: AnimatedEdgeProps) { const groupRef = useRef(null); const lineRef = useRef(null); const particlesRef = useRef(null); const flowOffset = useRef(0); const color = getEdgeColor(edge, isDarkMode); const isTrusted = edge.trustLevel === 'trusted' || edge.effectiveTrustLevel === 'trusted'; const particleCount = isTrusted ? 5 : 3; // Create and setup line and particles on mount useEffect(() => { if (!groupRef.current) return; // Create line const lineGeometry = new THREE.BufferGeometry(); const positions = new Float32Array([ sourcePosition.x, sourcePosition.y, sourcePosition.z, targetPosition.x, targetPosition.y, targetPosition.z, ]); lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const lineMaterial = new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: edge.isMutual ? 0.6 : 0.3, }); const line = new THREE.Line(lineGeometry, lineMaterial); lineRef.current = line; groupRef.current.add(line); // Create particles const particleGeometry = new THREE.BufferGeometry(); const particlePositions = new Float32Array(particleCount * 3); particleGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3)); const particleMaterial = new THREE.PointsMaterial({ color: isTrusted ? '#22c55e' : '#eab308', size: isTrusted ? 0.06 : 0.04, transparent: true, opacity: 0.9, sizeAttenuation: true, }); const particles = new THREE.Points(particleGeometry, particleMaterial); particlesRef.current = particles; groupRef.current.add(particles); return () => { if (groupRef.current) { if (lineRef.current) { groupRef.current.remove(lineRef.current); lineRef.current.geometry.dispose(); (lineRef.current.material as THREE.Material).dispose(); } if (particlesRef.current) { groupRef.current.remove(particlesRef.current); particlesRef.current.geometry.dispose(); (particlesRef.current.material as THREE.Material).dispose(); } } }; }, [color, edge.isMutual, isTrusted, particleCount]); // Update line positions when nodes move useFrame(() => { if (!lineRef.current) return; const positions = lineRef.current.geometry.attributes.position.array as Float32Array; positions[0] = sourcePosition.x; positions[1] = sourcePosition.y; positions[2] = sourcePosition.z; positions[3] = targetPosition.x; positions[4] = targetPosition.y; positions[5] = targetPosition.z; lineRef.current.geometry.attributes.position.needsUpdate = true; }); // Animate particles flowing along the edge useFrame(() => { if (!particlesRef.current) return; flowOffset.current = (flowOffset.current + 0.008) % 1; const positions = particlesRef.current.geometry.attributes.position.array as Float32Array; for (let i = 0; i < particleCount; i++) { const t = ((flowOffset.current + i / particleCount) % 1); positions[i * 3] = sourcePosition.x + (targetPosition.x - sourcePosition.x) * t; positions[i * 3 + 1] = sourcePosition.y + (targetPosition.y - sourcePosition.y) * t; positions[i * 3 + 2] = sourcePosition.z + (targetPosition.z - sourcePosition.z) * t; } particlesRef.current.geometry.attributes.position.needsUpdate = true; }); return ; } // ============================================================================= // 3D Node Component with Power-Based Sizing // ============================================================================= interface NodeSphereProps { node: Node3D; isSelected: boolean; isCurrentUser: boolean; isDarkMode: boolean; onClick: (e: ThreeEvent) => void; onPointerOver: () => void; onPointerOut: () => void; } function NodeSphere({ node, isSelected, isCurrentUser, isDarkMode, onClick, onPointerOver, onPointerOut, }: NodeSphereProps) { const meshRef = useRef(null); const glowRef = useRef(null); const ringRef = useRef(null); const color = getNodeColor(node, isDarkMode); // Size based on decision power (logarithmic scale to prevent huge nodes) const powerScale = Math.log2(node.decisionPower + 1) / 3; const baseSize = 0.08 + powerScale * 0.08; const size = isCurrentUser ? baseSize * 1.4 : baseSize; const displaySize = isSelected ? size * 1.2 : size; // Animate glow for in-room users and selection ring useFrame((state) => { if (glowRef.current && node.isInRoom) { const scale = 1.4 + Math.sin(state.clock.elapsedTime * 2) * 0.15; glowRef.current.scale.setScalar(scale); } if (ringRef.current && isSelected) { ringRef.current.rotation.z = state.clock.elapsedTime * 0.5; } }); // Power indicator color (more power = more vibrant) const powerIndicatorOpacity = Math.min(0.4, 0.1 + node.decisionPower * 0.03); return ( {/* Power aura (larger for more influential nodes) */} {node.decisionPower > 2 && ( )} {/* Glow ring for in-room users */} {node.isInRoom && ( )} {/* Main node sphere */} {/* Selection ring (animated) */} {isSelected && ( )} {/* Username label */} {node.displayName || node.username} {/* Power indicator */} {node.decisionPower > 1.5 && ( ◆ {node.decisionPower.toFixed(1)} )} ); } // ============================================================================= // Shell Indicator Rings // ============================================================================= interface ShellRingsProps { sphereRadius: number; isDarkMode: boolean; } function ShellRings({ sphereRadius, isDarkMode }: ShellRingsProps) { return ( {/* Trusted shell ring */} {/* Connected shell ring */} {/* Outer boundary sphere */} ); } // ============================================================================= // Camera Controller for Zoom-to-User // ============================================================================= interface CameraControllerProps { targetPosition: THREE.Vector3 | null; sphereRadius: number; controlsRef: React.RefObject; } function CameraController({ targetPosition, sphereRadius, controlsRef }: CameraControllerProps) { const { camera } = useThree(); const isAnimating = useRef(false); const animationProgress = useRef(0); const startPosition = useRef(new THREE.Vector3()); const startTarget = useRef(new THREE.Vector3()); useEffect(() => { if (targetPosition) { isAnimating.current = true; animationProgress.current = 0; startPosition.current.copy(camera.position); if (controlsRef.current) { startTarget.current.copy(controlsRef.current.target); } } }, [targetPosition, camera, controlsRef]); useFrame(() => { if (!isAnimating.current || !targetPosition || !controlsRef.current) return; animationProgress.current += 0.02; const t = Math.min(1, animationProgress.current); const easeT = 1 - Math.pow(1 - t, 3); // Ease out cubic // Calculate target camera position (offset from node) const targetCameraPos = targetPosition.clone().add( new THREE.Vector3(0, 0.5, sphereRadius * 0.8) ); // Lerp camera position camera.position.lerpVectors(startPosition.current, targetCameraPos, easeT); // Lerp controls target controlsRef.current.target.lerpVectors(startTarget.current, targetPosition, easeT); controlsRef.current.update(); if (t >= 1) { isAnimating.current = false; } }); return null; } // ============================================================================= // Force Simulation Hook with Trust Clustering // ============================================================================= function useForceSimulation( nodes: GraphNode[], edges: GraphEdge[], sphereRadius: number, currentUserId?: string ): Node3D[] { const nodes3DRef = useRef([]); const isSettledRef = useRef(false); const frameCountRef = useRef(0); // Initialize or update nodes const nodes3D = useMemo(() => { const existingMap = new Map(nodes3DRef.current.map(n => [n.id, n])); const newNodes: Node3D[] = nodes.map((node) => { const existing = existingMap.get(node.id); const targetRadius = getNodeShellRadius(node, edges, currentUserId, sphereRadius); const decisionPower = calculateDecisionPower(node.id, edges, currentUserId); if (existing) { return { ...existing, ...node, targetRadius, decisionPower, }; } // New node - place randomly at its target shell const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); const r = targetRadius * (0.9 + Math.random() * 0.2); return { ...node, position: new THREE.Vector3( r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi) ), velocity: new THREE.Vector3(0, 0, 0), targetRadius, decisionPower, }; }); nodes3DRef.current = newNodes; isSettledRef.current = false; frameCountRef.current = 0; return newNodes; }, [nodes, edges, sphereRadius, currentUserId]); // Apply forces each frame useFrame(() => { frameCountRef.current++; // Allow occasional updates even after settling (for smooth animation) if (isSettledRef.current && frameCountRef.current % 60 !== 0) return; const nodeMap = new Map(nodes3D.map(n => [n.id, n])); let maxVelocity = 0; for (const node of nodes3D) { const force = new THREE.Vector3(0, 0, 0); // 1. Repulsion from other nodes for (const other of nodes3D) { if (node.id === other.id) continue; const diff = node.position.clone().sub(other.position); const dist = diff.length(); if (dist < FORCE_CONFIG.repulsionDistance && dist > 0.01) { // Stronger repulsion for nodes in same shell const sameShell = Math.abs(node.targetRadius - other.targetRadius) < 0.3; const repulsionMult = sameShell ? 1.5 : 1; const repulsionForce = diff .normalize() .multiplyScalar((FORCE_CONFIG.repulsion * repulsionMult) / (dist * dist)); force.add(repulsionForce); } } // 2. Attraction along edges for (const edge of edges) { let linkedNode: Node3D | undefined; if (edge.source === node.id) { linkedNode = nodeMap.get(edge.target); } else if (edge.target === node.id) { linkedNode = nodeMap.get(edge.source); } if (linkedNode) { const diff = linkedNode.position.clone().sub(node.position); const dist = diff.length(); const isTrusted = edge.trustLevel === 'trusted'; const linkDist = isTrusted ? FORCE_CONFIG.linkDistance * 0.8 : FORCE_CONFIG.linkDistance; if (dist > linkDist) { const strength = isTrusted ? FORCE_CONFIG.linkStrength * 1.5 : FORCE_CONFIG.linkStrength; const attractionForce = diff .normalize() .multiplyScalar((dist - linkDist) * strength); force.add(attractionForce); } } } // 3. Shell-based radial force (pull toward target shell radius) const currentRadius = node.position.length(); const radiusDiff = node.targetRadius - currentRadius; if (Math.abs(radiusDiff) > 0.1) { const shellForce = node.position .clone() .normalize() .multiplyScalar(radiusDiff * FORCE_CONFIG.shellStrength); force.add(shellForce); } // 4. Sphere boundary constraint if (currentRadius > sphereRadius * 0.95) { const boundaryForce = node.position .clone() .normalize() .multiplyScalar(-(currentRadius - sphereRadius * 0.9) * FORCE_CONFIG.boundaryStrength); force.add(boundaryForce); } // Apply force to velocity node.velocity.add(force); node.velocity.multiplyScalar(FORCE_CONFIG.damping); const vel = node.velocity.length(); if (vel > maxVelocity) maxVelocity = vel; } // Apply velocity to positions for (const node of nodes3D) { node.position.add(node.velocity); // Hard boundary const distFromCenter = node.position.length(); if (distFromCenter > sphereRadius) { node.position.normalize().multiplyScalar(sphereRadius * 0.98); node.velocity.multiplyScalar(0.3); } } // Check if settled if (maxVelocity < FORCE_CONFIG.minVelocity) { isSettledRef.current = true; } }); return nodes3D; } // ============================================================================= // Scene Content Component // ============================================================================= interface SceneContentProps { nodes: GraphNode[]; edges: GraphEdge[]; currentUserId?: string; isDarkMode: boolean; sphereRadius: number; selectedNodeId: string | null; onNodeClick: (node: GraphNode, screenPos: { x: number; y: number }) => void; hoveredNodeId: string | null; setHoveredNodeId: (id: string | null) => void; zoomTargetPosition: THREE.Vector3 | null; controlsRef: React.RefObject; } function SceneContent({ nodes, edges, currentUserId, isDarkMode, sphereRadius, selectedNodeId, onNodeClick, hoveredNodeId: _hoveredNodeId, setHoveredNodeId, zoomTargetPosition, controlsRef, }: SceneContentProps) { const { camera, gl } = useThree(); // Use force simulation with clustering const nodes3D = useForceSimulation(nodes, edges, sphereRadius, currentUserId); // Create node map for edge rendering const nodeMap = useMemo( () => new Map(nodes3D.map(n => [n.id, n])), [nodes3D] ); // Handle node click with screen position const handleNodeClick = useCallback( (node: Node3D, e: ThreeEvent) => { e.stopPropagation(); const vector = node.position.clone().project(camera); const rect = gl.domElement.getBoundingClientRect(); const screenX = ((vector.x + 1) / 2) * rect.width; const screenY = ((-vector.y + 1) / 2) * rect.height; onNodeClick(node, { x: screenX, y: screenY }); }, [camera, gl, onNodeClick] ); return ( <> {/* Lighting */} {/* Shell indicator rings */} {/* Animated edges */} {edges.map((edge) => { const sourceNode = nodeMap.get(edge.source); const targetNode = nodeMap.get(edge.target); if (!sourceNode || !targetNode) return null; return ( ); })} {/* Nodes */} {nodes3D.map((node) => ( handleNodeClick(node, e)} onPointerOver={() => setHoveredNodeId(node.id)} onPointerOut={() => setHoveredNodeId(null)} /> ))} {/* Camera controller for zoom animation */} ); } // ============================================================================= // Main Component // ============================================================================= export function NetworkGraph3D({ nodes, edges, currentUserId, onNodeClick, onNodeSelect, onConnect, onZoomToUser, onViewAsUser, isDarkMode = false, sphereRadius = 3, }: NetworkGraph3DProps) { const containerRef = useRef(null); const controlsRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [zoomTargetPosition, setZoomTargetPosition] = useState(null); // Calculate power stats const powerStats = useMemo(() => { const powers = nodes.map(n => calculateDecisionPower(n.id, edges, currentUserId)); const maxPower = Math.max(...powers, 1); const avgPower = powers.reduce((a, b) => a + b, 0) / powers.length || 0; return { maxPower: maxPower.toFixed(1), avgPower: avgPower.toFixed(1) }; }, [nodes, edges, currentUserId]); // Handle node click const handleNodeClick = useCallback( (node: GraphNode, screenPos: { x: number; y: number }) => { if (node.isCurrentUser || node.id === currentUserId) { setSelectedNode(null); onNodeSelect?.(null); return; } setSelectedNode({ node, screenPosition: screenPos }); onNodeSelect?.(node); onNodeClick?.(node); }, [onNodeClick, onNodeSelect, currentUserId] ); // Handle background click const handleBackgroundClick = useCallback(() => { setSelectedNode(null); onNodeSelect?.(null); setZoomTargetPosition(null); }, [onNodeSelect]); // Handle connect const handleConnect = useCallback(async () => { if (!selectedNode || !onConnect) return; setIsConnecting(true); try { const userId = selectedNode.node.username || selectedNode.node.id; await onConnect(userId, 'connected'); } catch (err) { console.error('Failed to connect:', err); } setIsConnecting(false); setSelectedNode(null); }, [selectedNode, onConnect]); // Handle zoom to user const handleZoomToUser = useCallback(() => { if (!selectedNode) return; // Find the node's 3D position const power = calculateDecisionPower(selectedNode.node.id, edges, currentUserId); const targetRadius = getNodeShellRadius(selectedNode.node, edges, currentUserId, sphereRadius); // Approximate position (actual position is managed by simulation) const theta = Math.random() * Math.PI * 2; const phi = Math.PI / 2; const position = new THREE.Vector3( targetRadius * Math.sin(phi) * Math.cos(theta), targetRadius * Math.sin(phi) * Math.sin(theta), targetRadius * Math.cos(phi) ); setZoomTargetPosition(position); onZoomToUser?.(selectedNode.node); }, [selectedNode, edges, currentUserId, sphereRadius, onZoomToUser]); // Handle view as user (broadcast mode) const handleViewAsUser = useCallback(() => { if (!selectedNode) return; onViewAsUser?.(selectedNode.node); setSelectedNode(null); }, [selectedNode, onViewAsUser]); // Keyboard handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setSelectedNode(null); onNodeSelect?.(null); setZoomTargetPosition(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onNodeSelect]); return (
{/* Legend */}
Trust Shells
Trusted (inner)
Connected (middle)
Unconnected (outer)
◆ = Decision Power
Max: {powerStats.maxPower} | Avg: {powerStats.avgPower}
{/* Hover tooltip */} {hoveredNodeId && !selectedNode && (
{nodes.find(n => n.id === hoveredNodeId)?.displayName || nodes.find(n => n.id === hoveredNodeId)?.username}
)} {/* Selected node panel */} {selectedNode && (
{/* User info header */}
{selectedNode.node.displayName || selectedNode.node.username}
{selectedNode.node.isInRoom && ● In room} ◆ {calculateDecisionPower(selectedNode.node.id, edges, currentUserId).toFixed(1)} power
{/* Action buttons */}
{/* Connect */} {!selectedNode.node.isAnonymous && onConnect && ( )} {/* Zoom to user */} {/* View as user (broadcast mode) */} {selectedNode.node.isInRoom && onViewAsUser && ( )} {/* Cancel */}
)} {/* Instructions */}
Drag to rotate | Scroll to zoom | Click node to interact
); } export default NetworkGraph3D;