/** * NetworkGraphMinimap Component * * A force-directed social graph visualization positioned above the minimap. * Shows: * - User's full network with trust-level coloring * - Room participants in their presence colors * - Connections as edges between nodes * - Mutual connections as thicker lines * * Features: * - Three display modes: minimized (icon), normal (small window), maximized (modal) * - Click node to view profile / connect * - Click edge to edit metadata * - Hover for tooltips * - Stable simulation that doesn't constantly reinitialize * * Positioned in bottom-left, above the tldraw minimap. */ import React, { useEffect, useRef, useState, useCallback, useMemo, Suspense, lazy } from 'react'; import * as d3 from 'd3'; import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking'; import { UserSearchModal } from './UserSearchModal'; // Lazy load the 3D component to avoid loading Three.js unless needed const NetworkGraph3D = lazy(() => import('./NetworkGraph3D')); // ============================================================================= // Types // ============================================================================= type DisplayMode = 'minimized' | 'normal' | 'maximized'; 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 // ============================================================================= // Match tldraw minimap dimensions const MINIMAP_WIDTH = 200; const MINIMAP_HEIGHT = 100; const MINIMAP_BOTTOM = 40; // tldraw minimap position from bottom const MINIMAP_LEFT = 8; // tldraw minimap position from left const STACK_GAP = 8; // gap between network panel and minimap const getStyles = (isDarkMode: boolean, displayMode: DisplayMode) => ({ // Container - positioned bottom LEFT, directly above the tldraw minimap container: { position: 'fixed' as const, // Stack directly above tldraw minimap: minimap_bottom + minimap_height + gap bottom: displayMode === 'maximized' ? '0' : `${MINIMAP_BOTTOM + MINIMAP_HEIGHT + STACK_GAP}px`, left: displayMode === 'maximized' ? '0' : `${MINIMAP_LEFT}px`, right: displayMode === 'maximized' ? '0' : 'auto', top: displayMode === 'maximized' ? '0' : 'auto', zIndex: displayMode === 'maximized' ? 10000 : 1000, display: 'flex', flexDirection: 'column' as const, alignItems: displayMode === 'maximized' ? 'center' : 'flex-start', justifyContent: displayMode === 'maximized' ? 'center' : 'flex-start', gap: '8px', backgroundColor: displayMode === 'maximized' ? 'rgba(0, 0, 0, 0.5)' : 'transparent', pointerEvents: displayMode === 'maximized' ? 'auto' as const : 'none' as const, }, // Main panel panel: { backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)', borderRadius: displayMode === 'maximized' ? '16px' : '12px', boxShadow: isDarkMode ? '0 4px 20px rgba(0, 0, 0, 0.4)' : displayMode === 'maximized' ? '0 8px 40px rgba(0, 0, 0, 0.3)' : '0 4px 20px rgba(0, 0, 0, 0.15)', overflow: 'hidden', transition: 'all 0.3s ease', border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', pointerEvents: 'auto' as const, // Sphere-like gradient background for the graph area background: isDarkMode ? 'radial-gradient(ellipse at center, rgba(40, 40, 50, 0.95) 0%, rgba(20, 20, 25, 0.98) 100%)' : 'radial-gradient(ellipse at center, rgba(255, 255, 255, 0.98) 0%, rgba(245, 245, 250, 0.98) 100%)', }, // Minimized state - small icon panelMinimized: { width: '40px', height: '40px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: isDarkMode ? 'radial-gradient(circle, rgba(60, 60, 80, 0.9) 0%, rgba(30, 30, 40, 0.95) 100%)' : 'radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(240, 240, 245, 0.98) 100%)', boxShadow: isDarkMode ? '0 2px 12px rgba(100, 100, 255, 0.2), inset 0 0 20px rgba(100, 100, 255, 0.1)' : '0 2px 12px rgba(0, 0, 0, 0.15), inset 0 0 20px rgba(100, 100, 255, 0.05)', border: isDarkMode ? '1px solid rgba(100, 100, 255, 0.3)' : '1px solid rgba(100, 100, 255, 0.2)', }, // Normal state dimensions - match tldraw minimap width panelNormal: { width: `${MINIMAP_WIDTH}px`, maxHeight: '200px', }, // Maximized state dimensions panelMaximized: { width: '90vw', maxWidth: '800px', maxHeight: '80vh', }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: displayMode === 'maximized' ? '12px 16px' : '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: displayMode === 'maximized' ? '14px' : '12px', fontWeight: 600, color: isDarkMode ? '#e0e0e0' : '#374151', margin: 0, display: 'flex', alignItems: 'center', gap: '8px', }, headerButtons: { display: 'flex', gap: '4px', }, iconButton: { width: displayMode === 'maximized' ? '32px' : '28px', height: displayMode === 'maximized' ? '32px' : '28px', border: 'none', background: 'none', borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: displayMode === 'maximized' ? '16px' : '14px', color: isDarkMode ? '#a0a0a0' : '#6b7280', transition: 'background-color 0.15s, color 0.15s', }, canvas: { display: 'block', // Sphere-like inner gradient background: isDarkMode ? 'radial-gradient(ellipse at center, rgba(50, 50, 70, 0.3) 0%, transparent 70%)' : 'radial-gradient(ellipse at center, rgba(200, 200, 255, 0.15) 0%, transparent 70%)', }, tooltip: { position: 'absolute' as const, backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.98)', color: isDarkMode ? '#fff' : '#1f2937', padding: '8px 12px', borderRadius: '8px', fontSize: '12px', pointerEvents: 'none' as const, whiteSpace: 'nowrap' as const, zIndex: 1001, transform: 'translate(-50%, -100%)', marginTop: '-10px', boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.15)', border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', }, minimizedIcon: { fontSize: '18px', filter: 'drop-shadow(0 0 4px rgba(100, 100, 255, 0.4))', }, // Network stats in maximized view statsBar: { display: 'flex', gap: '16px', padding: '8px 16px', 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.02)' : 'rgba(0, 0, 0, 0.01)', fontSize: '11px', color: isDarkMode ? '#888' : '#666', }, statItem: { display: 'flex', alignItems: 'center', gap: '4px', }, statValue: { fontWeight: 600, color: isDarkMode ? '#a0a0ff' : '#4f46e5', }, }); // ============================================================================= // Component // ============================================================================= export function NetworkGraphMinimap({ nodes, edges, myConnections: _myConnections, currentUserId, onConnect, onDisconnect, onNodeClick, onGoToUser, onFollowUser, onOpenProfile, onEdgeClick, onExpandClick, width: propWidth = MINIMAP_WIDTH - 16, // Account for padding height: propHeight = 120, // Compact height to match minimap proportions isCollapsed = false, onToggleCollapse, isDarkMode = false, }: NetworkGraphMinimapProps) { const svgRef = useRef(null); const simulationRef = useRef | null>(null); const simNodesRef = useRef([]); const simLinksRef = useRef([]); const isInitializedRef = useRef(false); 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); // Three-state display mode: minimized, normal, maximized const [displayMode, setDisplayMode] = useState(isCollapsed ? 'minimized' : 'normal'); // Sync with legacy isCollapsed prop useEffect(() => { if (isCollapsed && displayMode !== 'minimized') { setDisplayMode('minimized'); } }, [isCollapsed]); // Calculate dimensions based on display mode const { width, height } = useMemo(() => { switch (displayMode) { case 'minimized': return { width: 0, height: 0 }; case 'normal': return { width: propWidth, height: propHeight }; case 'maximized': return { width: Math.min(700, window.innerWidth * 0.85), height: Math.min(500, window.innerHeight * 0.6) }; default: return { width: propWidth, height: propHeight }; } }, [displayMode, propWidth, propHeight]); // Get theme-aware styles const styles = useMemo(() => getStyles(isDarkMode, displayMode), [isDarkMode, displayMode]); // Network stats for maximized view const networkStats = useMemo(() => { const inRoomCount = nodes.filter(n => n.isInRoom).length; const connectionCount = edges.length; const mutualCount = edges.filter(e => e.isMutual).length; const trustedCount = edges.filter(e => e.trustLevel === 'trusted').length; return { inRoomCount, connectionCount, mutualCount, trustedCount, totalNodes: nodes.length }; }, [nodes, edges]); // Cleanup simulation on unmount useEffect(() => { return () => { if (simulationRef.current) { simulationRef.current.stop(); simulationRef.current = null; } isInitializedRef.current = false; simNodesRef.current = []; simLinksRef.current = []; }; }, []); // Initialize and update the D3 simulation - STABLE VERSION // This effect uses refs to persist simulation state and only updates incrementally useEffect(() => { if (!svgRef.current || displayMode === 'minimized' || nodes.length === 0) return; const svg = d3.select(svgRef.current); // Check if we need to initialize or just update const needsInit = !isInitializedRef.current || !simulationRef.current; const nodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); if (needsInit) { // Full initialization - only happens once svg.selectAll('*').remove(); // Create simulation nodes, preserving existing positions if available simNodesRef.current = nodes.map(n => { const existing = nodeMap.get(n.id); return { ...n, x: existing?.x ?? width / 2 + (Math.random() - 0.5) * 100, y: existing?.y ?? height / 2 + (Math.random() - 0.5) * 100, vx: existing?.vx ?? 0, vy: existing?.vy ?? 0, }; }); const newNodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); simLinksRef.current = edges .filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target)) .map(e => ({ source: newNodeMap.get(e.source)!, target: newNodeMap.get(e.target)!, id: e.id, isMutual: e.isMutual, })); // Create the simulation with smooth, stable parameters const simulation = d3.forceSimulation(simNodesRef.current) .force('link', d3.forceLink(simLinksRef.current) .id(d => d.id) .distance(displayMode === 'maximized' ? 80 : 50) .strength(0.5)) .force('charge', d3.forceManyBody() .strength(displayMode === 'maximized' ? -150 : -100) .distanceMax(displayMode === 'maximized' ? 300 : 200)) .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)) .force('collision', d3.forceCollide().radius(d => (d as SimulationNode).isCurrentUser ? 14 : 10)) // Gentler alpha decay for smoother settling .alphaDecay(0.02) // Higher alpha min so it stops sooner .alphaMin(0.05) // Add velocity decay for smoother movement .velocityDecay(0.4); simulationRef.current = simulation; isInitializedRef.current = true; } else { // Incremental update - preserve existing node positions const existingNodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); // Update existing nodes and add new ones const newNodes: SimulationNode[] = nodes.map(n => { const existing = existingNodeMap.get(n.id); if (existing) { // Update properties but keep position return { ...existing, ...n, x: existing.x, y: existing.y, vx: existing.vx, vy: existing.vy }; } // New node - place near center with slight randomness return { ...n, x: width / 2 + (Math.random() - 0.5) * 50, y: height / 2 + (Math.random() - 0.5) * 50, }; }); simNodesRef.current = newNodes; const newNodeMap = new Map(newNodes.map(n => [n.id, n])); simLinksRef.current = edges .filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target)) .map(e => ({ source: newNodeMap.get(e.source)!, target: newNodeMap.get(e.target)!, id: e.id, isMutual: e.isMutual, })); // Update simulation with new nodes/links simulationRef.current! .nodes(simNodesRef.current) .force('link', d3.forceLink(simLinksRef.current) .id(d => d.id) .distance(displayMode === 'maximized' ? 80 : 50) .strength(0.5)) .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)); // Gentle reheat to settle new nodes simulationRef.current!.alpha(0.3).restart(); // Re-render the graph with updated data svg.selectAll('*').remove(); } const simulation = simulationRef.current!; // 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(simLinksRef.current) .join('line') .attr('stroke', d => getEdgeColor(d)) .attr('stroke-width', d => d.isMutual ? (displayMode === 'maximized' ? 3 : 2.5) : (displayMode === 'maximized' ? 2 : 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'; }; // Node sizes based on display mode const nodeRadius = displayMode === 'maximized' ? 10 : 6; const currentUserRadius = displayMode === 'maximized' ? 14 : 8; // Create nodes const node = g.append('g') .attr('class', 'nodes') .selectAll('circle') .data(simNodesRef.current) .join('circle') .attr('r', d => d.isCurrentUser ? currentUserRadius : nodeRadius) .attr('fill', d => getNodeColor(d)) .attr('stroke', d => d.isInRoom ? 'rgba(100, 200, 255, 0.8)' : 'transparent') .attr('stroke-width', d => d.isInRoom ? 2 : 0) .style('cursor', 'pointer') .style('filter', d => d.isInRoom ? 'drop-shadow(0 0 4px rgba(100, 200, 255, 0.5))' : 'none') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); const name = d.displayName || d.username; const status = d.isInRoom ? ' (in room)' : ''; setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, text: d.isCurrentUser ? `${name} (you)` : `${name}${status}`, }); }) .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!); const margin = displayMode === 'maximized' ? 12 : 8; node .attr('cx', d => Math.max(margin, Math.min(width - margin, d.x!))) .attr('cy', d => Math.max(margin, Math.min(height - margin, d.y!))); }); // Stop simulation when it stabilizes (alpha reaches alphaMin) simulation.on('end', () => { // Simulation has stabilized, nodes will stay in place unless dragged // Don't call stop() - let it stay ready for interactions }); // Cleanup function - only stop if component unmounts return () => { // Don't reset simulation on every re-render // Only cleanup on actual unmount }; }, [nodes, edges, width, height, displayMode, onNodeClick, onEdgeClick]); // Handle display mode changes const handleMinimize = useCallback(() => { setDisplayMode('minimized'); onToggleCollapse?.(); }, [onToggleCollapse]); const handleNormal = useCallback(() => { setDisplayMode('normal'); }, []); const handleMaximize = useCallback(() => { setDisplayMode('maximized'); onExpandClick?.(); }, [onExpandClick]); const handleMinimizedClick = useCallback(() => { setDisplayMode('normal'); onToggleCollapse?.(); }, [onToggleCollapse]); // Handle ESC to close maximized view useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && displayMode === 'maximized') { setDisplayMode('normal'); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [displayMode]); // Minimized state - small circular icon if (displayMode === 'minimized') { return (
πŸ•ΈοΈ
); } // Get panel size styles based on display mode const panelSizeStyle = displayMode === 'maximized' ? styles.panelMaximized : styles.panelNormal; return (
e.stopPropagation()} > {/* Header */}

πŸ•ΈοΈ Social Network {displayMode === 'maximized' && ( {networkStats.totalNodes} people )}

{/* Search button */} {/* Maximize/Restore button */} {displayMode === 'normal' && ( )} {displayMode === 'maximized' && ( )} {/* Minimize button */} {/* Close button (maximized only) */} {displayMode === 'maximized' && ( )}
{/* Stats bar (maximized only) */} {displayMode === 'maximized' && (
πŸ‘₯ In room: {networkStats.inRoomCount}
πŸ”— Connections: {networkStats.connectionCount}
🀝 Mutual: {networkStats.mutualCount}
⭐ Trusted: {networkStats.trustedCount}
)} {/* 3D View for maximized mode */} {displayMode === 'maximized' ? ( Loading 3D view...
} >
) : ( /* 2D SVG View for normal mode */
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;