/** * 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, TRUST_LEVEL_COLORS } from '../../lib/networking'; import { UserSearchModal } from './UserSearchModal'; // ============================================================================= // Types // ============================================================================= interface NetworkGraphMinimapProps { nodes: GraphNode[]; edges: GraphEdge[]; myConnections: string[]; currentUserId?: string; onConnect: (userId: string) => Promise; onDisconnect?: (connectionId: string) => Promise; onNodeClick?: (node: GraphNode) => void; onEdgeClick?: (edge: GraphEdge) => void; onExpandClick?: () => void; width?: number; height?: number; isCollapsed?: boolean; onToggleCollapse?: () => void; } interface SimulationNode extends d3.SimulationNodeDatum, GraphNode {} interface SimulationLink extends d3.SimulationLinkDatum { id: string; isMutual: boolean; } // ============================================================================= // Styles // ============================================================================= const styles = { container: { position: 'fixed' as const, bottom: '60px', right: '10px', zIndex: 1000, display: 'flex', flexDirection: 'column' as const, alignItems: 'flex-end', gap: '8px', }, panel: { backgroundColor: 'rgba(20, 20, 25, 0.95)', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)', overflow: 'hidden', transition: 'all 0.2s ease', border: '1px solid rgba(255, 255, 255, 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: '1px solid rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.03)', }, title: { fontSize: '12px', fontWeight: 600, color: '#e0e0e0', 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: '#a0a0a0', transition: 'background-color 0.15s, color 0.15s', }, canvas: { display: 'block', }, tooltip: { position: 'absolute' as const, backgroundColor: 'rgba(0, 0, 0, 0.8)', color: '#fff', padding: '6px 10px', borderRadius: '6px', fontSize: '12px', pointerEvents: 'none' as const, whiteSpace: 'nowrap' as const, zIndex: 1001, transform: 'translate(-50%, -100%)', marginTop: '-8px', }, collapsedIcon: { fontSize: '20px', }, stats: { display: 'flex', gap: '12px', padding: '6px 12px', borderTop: '1px solid rgba(255, 255, 255, 0.1)', fontSize: '11px', color: '#888', backgroundColor: 'rgba(0, 0, 0, 0.2)', }, stat: { display: 'flex', alignItems: 'center', gap: '4px', }, statDot: { width: '8px', height: '8px', borderRadius: '50%', }, }; // ============================================================================= // Component // ============================================================================= export function NetworkGraphMinimap({ nodes, edges, myConnections: _myConnections, currentUserId, onConnect, onDisconnect, onNodeClick, onEdgeClick, onExpandClick, width = 240, height = 180, isCollapsed = false, onToggleCollapse, }: 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); // Count stats const inRoomCount = nodes.filter(n => n.isInRoom).length; const anonymousCount = nodes.filter(n => n.isAnonymous).length; const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length; const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length; const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser && !n.isAnonymous).length; // 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 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)); 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 based on trust level and room status // Priority: current user (purple) > anonymous (grey) > trust level > unconnected (white) const getNodeColor = (d: SimulationNode) => { if (d.isCurrentUser) { return '#4f46e5'; // Current user is always purple } // Anonymous users are grey if (d.isAnonymous) { return TRUST_LEVEL_COLORS.anonymous; } // If in room and has presence color, use it for the stroke/ring instead // (we still use trust level for fill to maintain visual consistency) // Otherwise use trust level color if (d.trustLevelTo) { return TRUST_LEVEL_COLORS[d.trustLevelTo]; } // Authenticated but unconnected = white return TRUST_LEVEL_COLORS.unconnected; }; // Helper to get node stroke color (for in-room presence indicator) const getNodeStroke = (d: SimulationNode) => { if (d.isCurrentUser) return '#fff'; // Show room presence color as a ring around the node if (d.isInRoom && d.roomPresenceColor) return d.roomPresenceColor; // White nodes need a subtle border to be visible if (!d.isAnonymous && !d.trustLevelTo) return '#e5e7eb'; return 'none'; }; const getNodeStrokeWidth = (d: SimulationNode) => { if (d.isCurrentUser) return 2; if (d.isInRoom && d.roomPresenceColor) return 2; if (!d.isAnonymous && !d.trustLevelTo) return 1; return 0; }; // 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)) .attr('stroke', d => getNodeStroke(d)) .attr('stroke-width', d => getNodeStrokeWidth(d)) .style('cursor', 'pointer') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, text: `${d.displayName || d.username}${d.isAnonymous ? ' (anonymous)' : ''}`, }); }) .on('mouseleave', () => { setTooltip(null); }) .on('click', (event, d) => { event.stopPropagation(); // Don't show popup for current user or anonymous users if (d.isCurrentUser || d.isAnonymous) { if (onNodeClick) onNodeClick(d); return; } // Show connection popup 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!))); }); 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}
)} {/* Connection popup when clicking a node */} {selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && (
e.stopPropagation()} >
{selectedNode.node.displayName || selectedNode.node.username}
@{selectedNode.node.username}
{/* Connection actions */} {selectedNode.node.trustLevelTo ? ( // Already connected - show trust level options
) : ( // Not connected - show connect options
)}
)}
{inRoomCount}
{trustedCount}
{connectedCount}
{unconnectedCount}
{anonymousCount > 0 && (
{anonymousCount}
)}
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;