/** * NetworkGraphMinimap Component * * A 2D force-directed graph visualization in the bottom-right corner. * Shows: * - User's full network in grey * - Room participants in their presence colors * - Connections as edges between nodes * - Mutual connections as thicker lines * * Features: * - Click node to view profile / connect * - Click edge to edit metadata * - Hover for tooltips * - Expand button to open full 3D view */ import React, { useEffect, useRef, useState, useCallback } from 'react'; import * as d3 from 'd3'; import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking'; import { UserSearchModal } from './UserSearchModal'; // ============================================================================= // Types // ============================================================================= interface NetworkGraphMinimapProps { nodes: GraphNode[]; edges: GraphEdge[]; myConnections: string[]; currentUserId?: string; onConnect: (userId: string, trustLevel?: TrustLevel) => Promise; onDisconnect?: (connectionId: string) => Promise; onNodeClick?: (node: GraphNode) => void; onGoToUser?: (node: GraphNode) => void; onFollowUser?: (node: GraphNode) => void; onOpenProfile?: (node: GraphNode) => void; onEdgeClick?: (edge: GraphEdge) => void; onExpandClick?: () => void; width?: number; height?: number; isCollapsed?: boolean; onToggleCollapse?: () => void; isDarkMode?: boolean; } interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {} interface SimulationLink extends d3.SimulationLinkDatum { id: string; isMutual: boolean; } // ============================================================================= // Styles - Theme-aware functions // ============================================================================= const getStyles = (isDarkMode: boolean) => ({ container: { position: 'fixed' as const, bottom: '60px', right: '10px', zIndex: 1000, display: 'flex', flexDirection: 'column' as const, alignItems: 'flex-end', gap: '8px', }, panel: { backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 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)', overflow: 'hidden', transition: 'all 0.2s ease', border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', }, panelCollapsed: { width: '48px', height: '48px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)', }, title: { fontSize: '12px', fontWeight: 600, color: isDarkMode ? '#e0e0e0' : '#374151', margin: 0, }, headerButtons: { display: 'flex', gap: '4px', }, iconButton: { width: '28px', height: '28px', border: 'none', background: 'none', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', color: isDarkMode ? '#a0a0a0' : '#6b7280', transition: 'background-color 0.15s, color 0.15s', }, canvas: { display: 'block', backgroundColor: isDarkMode ? 'transparent' : 'rgba(249, 250, 251, 0.5)', }, tooltip: { position: 'absolute' as const, backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)', color: isDarkMode ? '#fff' : '#1f2937', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', pointerEvents: 'none' as const, whiteSpace: 'nowrap' as const, zIndex: 1001, transform: 'translate(-50%, -100%)', marginTop: '-8px', boxShadow: isDarkMode ? 'none' : '0 2px 8px rgba(0, 0, 0, 0.15)', border: isDarkMode ? 'none' : '1px solid rgba(0, 0, 0, 0.1)', }, collapsedIcon: { fontSize: '20px', }, }); // ============================================================================= // Component // ============================================================================= export function NetworkGraphMinimap({ nodes, edges, myConnections: _myConnections, currentUserId, onConnect, onDisconnect, onNodeClick, onGoToUser, onFollowUser, onOpenProfile, onEdgeClick, onExpandClick, width = 240, height = 180, isCollapsed = false, onToggleCollapse, isDarkMode = false, }: NetworkGraphMinimapProps) { const svgRef = useRef(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const [selectedNode, setSelectedNode] = useState<{ node: GraphNode; x: number; y: number } | null>(null); const [isConnecting, setIsConnecting] = useState(false); const simulationRef = useRef | null>(null); // Get theme-aware styles const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]); // Initialize and update the D3 simulation useEffect(() => { if (!svgRef.current || isCollapsed || nodes.length === 0) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); // Create simulation nodes and links const simNodes: SimulationNode[] = nodes.map(n => ({ ...n })); const nodeMap = new Map(simNodes.map(n => [n.id, n])); const simLinks: SimulationLink[] = edges .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)) .map(e => ({ source: nodeMap.get(e.source)!, target: nodeMap.get(e.target)!, id: e.id, isMutual: e.isMutual, })); // Create the simulation with faster decay for stabilization const simulation = d3.forceSimulation(simNodes) .force('link', d3.forceLink(simLinks) .id(d => d.id) .distance(40)) .force('charge', d3.forceManyBody().strength(-80)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(12)) // Speed up stabilization: higher decay = faster settling .alphaDecay(0.05) // Lower alpha min threshold for stopping .alphaMin(0.01); simulationRef.current = simulation; // Create container group const g = svg.append('g'); // Create arrow marker definitions for edges const defs = svg.append('defs'); // Arrow marker for regular edges (grey) defs.append('marker') .attr('id', 'arrow-grey') .attr('viewBox', '0 -5 10 10') .attr('refX', 18) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-4L10,0L0,4') .attr('fill', 'rgba(150, 150, 150, 0.6)'); // Arrow marker for connected (yellow) defs.append('marker') .attr('id', 'arrow-connected') .attr('viewBox', '0 -5 10 10') .attr('refX', 18) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-4L10,0L0,4') .attr('fill', 'rgba(234, 179, 8, 0.8)'); // Arrow marker for trusted (green) defs.append('marker') .attr('id', 'arrow-trusted') .attr('viewBox', '0 -5 10 10') .attr('refX', 18) .attr('refY', 0) .attr('markerWidth', 6) .attr('markerHeight', 6) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-4L10,0L0,4') .attr('fill', 'rgba(34, 197, 94, 0.8)'); // Helper to get edge color based on trust level const getEdgeColor = (d: SimulationLink) => { const edge = edges.find(e => e.id === d.id); if (!edge) return 'rgba(150, 150, 150, 0.4)'; // Use effective trust level for mutual connections, otherwise the edge's trust level const level = edge.effectiveTrustLevel || edge.trustLevel; if (level === 'trusted') { return 'rgba(34, 197, 94, 0.7)'; // green } else if (level === 'connected') { return 'rgba(234, 179, 8, 0.7)'; // yellow } return 'rgba(150, 150, 150, 0.4)'; }; // Helper to get arrow marker based on trust level const getArrowMarker = (d: SimulationLink) => { const edge = edges.find(e => e.id === d.id); if (!edge) return 'url(#arrow-grey)'; const level = edge.effectiveTrustLevel || edge.trustLevel; if (level === 'trusted') return 'url(#arrow-trusted)'; if (level === 'connected') return 'url(#arrow-connected)'; return 'url(#arrow-grey)'; }; // Create edges as paths (lines) with arrow markers const link = g.append('g') .attr('class', 'links') .selectAll('line') .data(simLinks) .join('line') .attr('stroke', d => getEdgeColor(d)) .attr('stroke-width', d => d.isMutual ? 2.5 : 1.5) .attr('marker-end', d => getArrowMarker(d)) .style('cursor', 'pointer') .on('click', (event, d) => { event.stopPropagation(); const edge = edges.find(e => e.id === d.id); if (edge && onEdgeClick) { onEdgeClick(edge); } }); // Helper to get node color - uses the user's profile/presence color const getNodeColor = (d: SimulationNode) => { // Use room presence color (user's profile color) if available if (d.roomPresenceColor) { return d.roomPresenceColor; } // Use avatar color as fallback if (d.avatarColor) { return d.avatarColor; } // Default grey for users without a color return '#9ca3af'; }; // Create nodes const node = g.append('g') .attr('class', 'nodes') .selectAll('circle') .data(simNodes) .join('circle') .attr('r', d => d.isCurrentUser ? 8 : 6) .attr('fill', d => getNodeColor(d)) .style('cursor', 'pointer') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); const name = d.displayName || d.username; setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, text: d.isCurrentUser ? `${name} (you)` : name, }); }) .on('mouseleave', () => { setTooltip(null); }) .on('click', (event, d) => { event.stopPropagation(); // Don't show popup for current user if (d.isCurrentUser) { if (onNodeClick) onNodeClick(d); return; } // Show dropdown menu for all other users const rect = svgRef.current!.getBoundingClientRect(); setSelectedNode({ node: d, x: event.clientX - rect.left, y: event.clientY - rect.top, }); }) .call(d3.drag() .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; }) .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }) as any); // Update positions on tick simulation.on('tick', () => { link .attr('x1', d => (d.source as SimulationNode).x!) .attr('y1', d => (d.source as SimulationNode).y!) .attr('x2', d => (d.target as SimulationNode).x!) .attr('y2', d => (d.target as SimulationNode).y!); node .attr('cx', d => Math.max(8, Math.min(width - 8, d.x!))) .attr('cy', d => Math.max(8, Math.min(height - 8, d.y!))); }); // Stop simulation when it stabilizes (alpha reaches alphaMin) simulation.on('end', () => { // Simulation has stabilized, nodes will stay in place unless dragged simulation.stop(); }); return () => { simulation.stop(); }; }, [nodes, edges, width, height, isCollapsed, onNodeClick, onEdgeClick]); // Handle collapsed state click const handleCollapsedClick = useCallback(() => { if (onToggleCollapse) { onToggleCollapse(); } }, [onToggleCollapse]); if (isCollapsed) { return (
πŸ•ΈοΈ
); } return (

Social Network

{onExpandClick && ( )} {onToggleCollapse && ( )}
setSelectedNode(null)}> {tooltip && (
{tooltip.text}
)} {/* User action dropdown menu when clicking a node */} {selectedNode && !selectedNode.node.isCurrentUser && (
e.stopPropagation()} > {/* Connect option - only for non-anonymous users */} {!selectedNode.node.isAnonymous && ( )} {/* Navigate option - only for in-room users */} {selectedNode.node.isInRoom && onGoToUser && ( )} {/* Screenfollow option - only for in-room users */} {selectedNode.node.isInRoom && onFollowUser && ( )} {/* Open profile option - only for non-anonymous users */} {!selectedNode.node.isAnonymous && onOpenProfile && ( )}
)}
setIsSearchOpen(false)} onConnect={onConnect} onDisconnect={onDisconnect ? (userId) => { // Find the connection ID for this user const edge = edges.find(e => (e.source === currentUserId && e.target === userId) || (e.target === currentUserId && e.source === userId) ); if (edge && onDisconnect) { return onDisconnect(edge.id); } return Promise.resolve(); } : undefined} currentUserId={currentUserId} />
); } export default NetworkGraphMinimap;