canvas-website/src/components/networking/NetworkGraph3D.tsx

1164 lines
37 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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<void>;
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<THREE.Group>(null);
const lineRef = useRef<THREE.Line | null>(null);
const particlesRef = useRef<THREE.Points | null>(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 <group ref={groupRef} />;
}
// =============================================================================
// 3D Node Component with Power-Based Sizing
// =============================================================================
interface NodeSphereProps {
node: Node3D;
isSelected: boolean;
isCurrentUser: boolean;
isDarkMode: boolean;
onClick: (e: ThreeEvent<MouseEvent>) => void;
onPointerOver: () => void;
onPointerOut: () => void;
}
function NodeSphere({
node,
isSelected,
isCurrentUser,
isDarkMode,
onClick,
onPointerOver,
onPointerOut,
}: NodeSphereProps) {
const meshRef = useRef<THREE.Mesh>(null);
const glowRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(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 (
<group position={node.position}>
{/* Power aura (larger for more influential nodes) */}
{node.decisionPower > 2 && (
<mesh>
<sphereGeometry args={[displaySize * 2, 16, 16]} />
<meshBasicMaterial
color={color}
transparent
opacity={powerIndicatorOpacity}
side={THREE.BackSide}
/>
</mesh>
)}
{/* Glow ring for in-room users */}
{node.isInRoom && (
<mesh ref={glowRef}>
<sphereGeometry args={[displaySize * 1.6, 16, 16]} />
<meshBasicMaterial
color="#64b5f6"
transparent
opacity={0.25}
side={THREE.BackSide}
/>
</mesh>
)}
{/* Main node sphere */}
<mesh
ref={meshRef}
onClick={onClick}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
>
<sphereGeometry args={[displaySize, 32, 32]} />
<meshStandardMaterial
color={color}
emissive={isSelected ? color : '#000000'}
emissiveIntensity={isSelected ? 0.6 : 0}
metalness={0.4}
roughness={0.5}
/>
</mesh>
{/* Selection ring (animated) */}
{isSelected && (
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[displaySize * 1.5, displaySize * 1.7, 32]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.9} side={THREE.DoubleSide} />
</mesh>
)}
{/* Username label */}
<Billboard position={[0, displaySize + 0.12, 0]}>
<Text
fontSize={0.07}
color={isDarkMode ? '#ffffff' : '#1f2937'}
anchorX="center"
anchorY="bottom"
outlineWidth={0.004}
outlineColor={isDarkMode ? '#000000' : '#ffffff'}
>
{node.displayName || node.username}
</Text>
{/* Power indicator */}
{node.decisionPower > 1.5 && (
<Text
fontSize={0.04}
color={isDarkMode ? '#a0a0ff' : '#6366f1'}
anchorX="center"
anchorY="top"
position={[0, -0.02, 0]}
>
{node.decisionPower.toFixed(1)}
</Text>
)}
</Billboard>
</group>
);
}
// =============================================================================
// Shell Indicator Rings
// =============================================================================
interface ShellRingsProps {
sphereRadius: number;
isDarkMode: boolean;
}
function ShellRings({ sphereRadius, isDarkMode }: ShellRingsProps) {
return (
<group>
{/* Trusted shell ring */}
<mesh rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[
sphereRadius * FORCE_CONFIG.trustedShell - 0.02,
sphereRadius * FORCE_CONFIG.trustedShell + 0.02,
64
]} />
<meshBasicMaterial
color="#22c55e"
transparent
opacity={0.15}
side={THREE.DoubleSide}
/>
</mesh>
{/* Connected shell ring */}
<mesh rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[
sphereRadius * FORCE_CONFIG.connectedShell - 0.02,
sphereRadius * FORCE_CONFIG.connectedShell + 0.02,
64
]} />
<meshBasicMaterial
color="#eab308"
transparent
opacity={0.1}
side={THREE.DoubleSide}
/>
</mesh>
{/* Outer boundary sphere */}
<mesh>
<sphereGeometry args={[sphereRadius, 64, 64]} />
<meshBasicMaterial
color={isDarkMode ? '#1a1a2e' : '#e8e8f8'}
transparent
opacity={0.04}
side={THREE.BackSide}
/>
</mesh>
</group>
);
}
// =============================================================================
// Camera Controller for Zoom-to-User
// =============================================================================
interface CameraControllerProps {
targetPosition: THREE.Vector3 | null;
sphereRadius: number;
controlsRef: React.RefObject<any>;
}
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<Node3D[]>([]);
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<any>;
}
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<MouseEvent>) => {
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 */}
<ambientLight intensity={isDarkMode ? 0.35 : 0.5} />
<directionalLight position={[5, 5, 5]} intensity={0.5} />
<directionalLight position={[-5, -5, -5]} intensity={0.3} />
<pointLight position={[0, 0, 0]} intensity={0.4} color="#6060ff" />
{/* Shell indicator rings */}
<ShellRings sphereRadius={sphereRadius} isDarkMode={isDarkMode} />
{/* Animated edges */}
{edges.map((edge) => {
const sourceNode = nodeMap.get(edge.source);
const targetNode = nodeMap.get(edge.target);
if (!sourceNode || !targetNode) return null;
return (
<AnimatedEdge
key={edge.id}
sourcePosition={sourceNode.position}
targetPosition={targetNode.position}
edge={edge}
isDarkMode={isDarkMode}
/>
);
})}
{/* Nodes */}
{nodes3D.map((node) => (
<NodeSphere
key={node.id}
node={node}
isSelected={selectedNodeId === node.id}
isCurrentUser={node.id === currentUserId || node.isCurrentUser}
isDarkMode={isDarkMode}
onClick={(e) => handleNodeClick(node, e)}
onPointerOver={() => setHoveredNodeId(node.id)}
onPointerOut={() => setHoveredNodeId(null)}
/>
))}
{/* Camera controller for zoom animation */}
<CameraController
targetPosition={zoomTargetPosition}
sphereRadius={sphereRadius}
controlsRef={controlsRef}
/>
</>
);
}
// =============================================================================
// Main Component
// =============================================================================
export function NetworkGraph3D({
nodes,
edges,
currentUserId,
onNodeClick,
onNodeSelect,
onConnect,
onZoomToUser,
onViewAsUser,
isDarkMode = false,
sphereRadius = 3,
}: NetworkGraph3DProps) {
const containerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<any>(null);
const [selectedNode, setSelectedNode] = useState<SelectedNodeInfo | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [zoomTargetPosition, setZoomTargetPosition] = useState<THREE.Vector3 | null>(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 (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
position: 'relative',
background: isDarkMode
? 'radial-gradient(ellipse at center, #1a1a2e 0%, #0d0d1a 100%)'
: 'radial-gradient(ellipse at center, #f8f8ff 0%, #e8e8f0 100%)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<Canvas
camera={{ position: [0, 0, sphereRadius * 2.5], fov: 50 }}
onPointerMissed={handleBackgroundClick}
>
<SceneContent
nodes={nodes}
edges={edges}
currentUserId={currentUserId}
isDarkMode={isDarkMode}
sphereRadius={sphereRadius}
selectedNodeId={selectedNode?.node.id ?? null}
onNodeClick={handleNodeClick}
hoveredNodeId={hoveredNodeId}
setHoveredNodeId={setHoveredNodeId}
zoomTargetPosition={zoomTargetPosition}
controlsRef={controlsRef}
/>
<OrbitControls
ref={controlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={sphereRadius * 1.2}
maxDistance={sphereRadius * 5}
autoRotate={!selectedNode}
autoRotateSpeed={0.3}
/>
</Canvas>
{/* Legend */}
<div
style={{
position: 'absolute',
top: '12px',
left: '12px',
padding: '10px',
background: isDarkMode ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.9)',
borderRadius: '8px',
fontSize: '10px',
color: isDarkMode ? '#ccc' : '#444',
}}
>
<div style={{ fontWeight: 600, marginBottom: '6px' }}>Trust Shells</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#22c55e' }} />
<span>Trusted (inner)</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#eab308' }} />
<span>Connected (middle)</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#6b7280' }} />
<span>Unconnected (outer)</span>
</div>
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: `1px solid ${isDarkMode ? '#333' : '#ddd'}` }}>
<div> = Decision Power</div>
<div style={{ marginTop: '2px' }}>Max: {powerStats.maxPower} | Avg: {powerStats.avgPower}</div>
</div>
</div>
{/* Hover tooltip */}
{hoveredNodeId && !selectedNode && (
<div
style={{
position: 'absolute',
bottom: '12px',
left: '12px',
padding: '8px 12px',
background: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)',
color: isDarkMode ? '#fff' : '#1f2937',
borderRadius: '8px',
fontSize: '12px',
pointerEvents: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
}}
>
{nodes.find(n => n.id === hoveredNodeId)?.displayName ||
nodes.find(n => n.id === hoveredNodeId)?.username}
</div>
)}
{/* Selected node panel */}
{selectedNode && (
<div
style={{
position: 'absolute',
top: '12px',
right: '12px',
padding: '14px',
background: isDarkMode ? 'rgba(30, 30, 46, 0.95)' : 'rgba(255, 255, 255, 0.98)',
borderRadius: '12px',
boxShadow: isDarkMode
? '0 4px 20px rgba(0, 0, 0, 0.4)'
: '0 4px 20px rgba(0, 0, 0, 0.15)',
border: isDarkMode
? '1px solid rgba(255, 255, 255, 0.1)'
: '1px solid rgba(0, 0, 0, 0.1)',
minWidth: '200px',
}}
>
{/* User info header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
marginBottom: '12px',
paddingBottom: '10px',
borderBottom: isDarkMode
? '1px solid rgba(255, 255, 255, 0.1)'
: '1px solid rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '50%',
background: getNodeColor(selectedNode.node, isDarkMode),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
/>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: isDarkMode ? '#e0e0e0' : '#1f2937' }}>
{selectedNode.node.displayName || selectedNode.node.username}
</div>
<div style={{ fontSize: '10px', color: isDarkMode ? '#888' : '#666', display: 'flex', gap: '8px' }}>
{selectedNode.node.isInRoom && <span style={{ color: '#64b5f6' }}> In room</span>}
<span> {calculateDecisionPower(selectedNode.node.id, edges, currentUserId).toFixed(1)} power</span>
</div>
</div>
</div>
{/* Action buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Connect */}
{!selectedNode.node.isAnonymous && onConnect && (
<button
onClick={handleConnect}
disabled={isConnecting}
style={{
padding: '10px 12px',
fontSize: '12px',
background: isDarkMode ? 'rgba(234, 179, 8, 0.15)' : 'rgba(234, 179, 8, 0.1)',
color: isDarkMode ? '#fbbf24' : '#92400e',
border: `1px solid ${isDarkMode ? 'rgba(234, 179, 8, 0.3)' : 'rgba(234, 179, 8, 0.4)'}`,
borderRadius: '8px',
cursor: isConnecting ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>🔗</span>
{isConnecting ? 'Connecting...' : 'Connect'}
</button>
)}
{/* Zoom to user */}
<button
onClick={handleZoomToUser}
style={{
padding: '10px 12px',
fontSize: '12px',
background: isDarkMode ? 'rgba(100, 149, 237, 0.15)' : 'rgba(100, 149, 237, 0.1)',
color: isDarkMode ? '#64b5f6' : '#2563eb',
border: `1px solid ${isDarkMode ? 'rgba(100, 149, 237, 0.3)' : 'rgba(100, 149, 237, 0.4)'}`,
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>🎯</span>
Zoom to User
</button>
{/* View as user (broadcast mode) */}
{selectedNode.node.isInRoom && onViewAsUser && (
<button
onClick={handleViewAsUser}
style={{
padding: '10px 12px',
fontSize: '12px',
background: isDarkMode ? 'rgba(168, 85, 247, 0.15)' : 'rgba(168, 85, 247, 0.1)',
color: isDarkMode ? '#c084fc' : '#7c3aed',
border: `1px solid ${isDarkMode ? 'rgba(168, 85, 247, 0.3)' : 'rgba(168, 85, 247, 0.4)'}`,
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span>👁</span>
View as User (Broadcast)
</button>
)}
{/* Cancel */}
<button
onClick={() => setSelectedNode(null)}
style={{
padding: '10px 12px',
fontSize: '12px',
background: 'transparent',
color: isDarkMode ? '#666' : '#888',
border: `1px solid ${isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: '8px',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
)}
{/* Instructions */}
<div
style={{
position: 'absolute',
bottom: '12px',
right: '12px',
padding: '8px 12px',
background: isDarkMode ? 'rgba(0, 0, 0, 0.5)' : 'rgba(255, 255, 255, 0.8)',
color: isDarkMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.5)',
borderRadius: '8px',
fontSize: '10px',
pointerEvents: 'none',
}}
>
Drag to rotate | Scroll to zoom | Click node to interact
</div>
</div>
);
}
export default NetworkGraph3D;