637 lines
21 KiB
TypeScript
637 lines
21 KiB
TypeScript
/**
|
||
* 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<void>;
|
||
onDisconnect?: (connectionId: string) => Promise<void>;
|
||
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<SimulationNode> {
|
||
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(255, 255, 255, 0.95)',
|
||
borderRadius: '12px',
|
||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.15)',
|
||
overflow: 'hidden',
|
||
transition: 'all 0.2s ease',
|
||
},
|
||
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(0, 0, 0, 0.1)',
|
||
backgroundColor: 'rgba(0, 0, 0, 0.02)',
|
||
},
|
||
title: {
|
||
fontSize: '12px',
|
||
fontWeight: 600,
|
||
color: '#1a1a2e',
|
||
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: '#666',
|
||
transition: 'background-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(0, 0, 0, 0.1)',
|
||
fontSize: '11px',
|
||
color: '#666',
|
||
},
|
||
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<SVGSVGElement>(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<d3.Simulation<SimulationNode, SimulationLink> | 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<SimulationNode>(simNodes)
|
||
.force('link', d3.forceLink<SimulationNode, SimulationLink>(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');
|
||
|
||
// 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(0, 0, 0, 0.15)';
|
||
|
||
// 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.6)'; // green
|
||
} else if (level === 'connected') {
|
||
return 'rgba(234, 179, 8, 0.6)'; // yellow
|
||
}
|
||
return 'rgba(0, 0, 0, 0.15)';
|
||
};
|
||
|
||
// Create edges
|
||
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)
|
||
.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<SVGCircleElement, SimulationNode>()
|
||
.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 (
|
||
<div style={styles.container}>
|
||
<div
|
||
style={{ ...styles.panel, ...styles.panelCollapsed }}
|
||
onClick={handleCollapsedClick}
|
||
title="Show network graph"
|
||
>
|
||
<span style={styles.collapsedIcon}>🕸️</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={styles.container}>
|
||
<div style={styles.panel}>
|
||
<div style={styles.header}>
|
||
<h3 style={styles.title}>Social Network</h3>
|
||
<div style={styles.headerButtons}>
|
||
<button
|
||
style={styles.iconButton}
|
||
onClick={() => setIsSearchOpen(true)}
|
||
title="Find people"
|
||
>
|
||
🔍
|
||
</button>
|
||
{onExpandClick && (
|
||
<button
|
||
style={styles.iconButton}
|
||
onClick={onExpandClick}
|
||
title="Open full view"
|
||
>
|
||
⛶
|
||
</button>
|
||
)}
|
||
{onToggleCollapse && (
|
||
<button
|
||
style={styles.iconButton}
|
||
onClick={onToggleCollapse}
|
||
title="Collapse"
|
||
>
|
||
−
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ position: 'relative' }} onClick={() => setSelectedNode(null)}>
|
||
<svg
|
||
ref={svgRef}
|
||
width={width}
|
||
height={height}
|
||
style={styles.canvas}
|
||
/>
|
||
|
||
{tooltip && (
|
||
<div
|
||
style={{
|
||
...styles.tooltip,
|
||
left: tooltip.x,
|
||
top: tooltip.y,
|
||
}}
|
||
>
|
||
{tooltip.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Connection popup when clicking a node */}
|
||
{selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
left: Math.min(selectedNode.x, width - 140),
|
||
top: Math.max(selectedNode.y - 80, 10),
|
||
backgroundColor: 'white',
|
||
borderRadius: '8px',
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||
padding: '8px',
|
||
zIndex: 1002,
|
||
minWidth: '130px',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div style={{ fontSize: '11px', fontWeight: 600, marginBottom: '6px', color: '#1a1a2e' }}>
|
||
{selectedNode.node.displayName || selectedNode.node.username}
|
||
</div>
|
||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '8px' }}>
|
||
@{selectedNode.node.username}
|
||
</div>
|
||
|
||
{/* Connection actions */}
|
||
{selectedNode.node.trustLevelTo ? (
|
||
// Already connected - show trust level options
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<button
|
||
onClick={async () => {
|
||
// Toggle trust level
|
||
const newLevel = selectedNode.node.trustLevelTo === 'trusted' ? 'connected' : 'trusted';
|
||
setIsConnecting(true);
|
||
// This would need updateTrustLevel function passed as prop
|
||
// For now, just close the popup
|
||
setSelectedNode(null);
|
||
setIsConnecting(false);
|
||
}}
|
||
disabled={isConnecting}
|
||
style={{
|
||
padding: '6px 10px',
|
||
fontSize: '10px',
|
||
backgroundColor: selectedNode.node.trustLevelTo === 'trusted' ? '#fef3c7' : '#d1fae5',
|
||
color: selectedNode.node.trustLevelTo === 'trusted' ? '#92400e' : '#065f46',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{selectedNode.node.trustLevelTo === 'trusted' ? 'Downgrade to Connected' : 'Upgrade to Trusted'}
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
setIsConnecting(true);
|
||
try {
|
||
// Find connection ID and disconnect
|
||
const edge = edges.find(e =>
|
||
(e.source === currentUserId && e.target === selectedNode.node.id) ||
|
||
(e.target === currentUserId && e.source === selectedNode.node.id)
|
||
);
|
||
if (edge && onDisconnect) {
|
||
await onDisconnect(edge.id);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to disconnect:', err);
|
||
}
|
||
setSelectedNode(null);
|
||
setIsConnecting(false);
|
||
}}
|
||
disabled={isConnecting}
|
||
style={{
|
||
padding: '6px 10px',
|
||
fontSize: '10px',
|
||
backgroundColor: '#fee2e2',
|
||
color: '#dc2626',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{isConnecting ? 'Removing...' : 'Remove Connection'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
// Not connected - show connect options
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<button
|
||
onClick={async () => {
|
||
setIsConnecting(true);
|
||
try {
|
||
await onConnect(selectedNode.node.id);
|
||
} catch (err) {
|
||
console.error('Failed to connect:', err);
|
||
}
|
||
setSelectedNode(null);
|
||
setIsConnecting(false);
|
||
}}
|
||
disabled={isConnecting}
|
||
style={{
|
||
padding: '6px 10px',
|
||
fontSize: '10px',
|
||
backgroundColor: '#fef3c7',
|
||
color: '#92400e',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{isConnecting ? 'Connecting...' : 'Connect (View)'}
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
setIsConnecting(true);
|
||
try {
|
||
// Connect with trusted level
|
||
await onConnect(selectedNode.node.id);
|
||
// Then upgrade - would need separate call
|
||
} catch (err) {
|
||
console.error('Failed to connect:', err);
|
||
}
|
||
setSelectedNode(null);
|
||
setIsConnecting(false);
|
||
}}
|
||
disabled={isConnecting}
|
||
style={{
|
||
padding: '6px 10px',
|
||
fontSize: '10px',
|
||
backgroundColor: '#d1fae5',
|
||
color: '#065f46',
|
||
border: 'none',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{isConnecting ? 'Connecting...' : 'Trust (Edit)'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => setSelectedNode(null)}
|
||
style={{
|
||
marginTop: '6px',
|
||
width: '100%',
|
||
padding: '4px',
|
||
fontSize: '9px',
|
||
backgroundColor: 'transparent',
|
||
color: '#666',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={styles.stats}>
|
||
<div style={styles.stat} title="Users in this room">
|
||
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
|
||
<span>{inRoomCount}</span>
|
||
</div>
|
||
<div style={styles.stat} title="Trusted (edit access)">
|
||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
|
||
<span>{trustedCount}</span>
|
||
</div>
|
||
<div style={styles.stat} title="Connected (view access)">
|
||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
|
||
<span>{connectedCount}</span>
|
||
</div>
|
||
<div style={styles.stat} title="Unconnected">
|
||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected, border: '1px solid #e5e7eb' }} />
|
||
<span>{unconnectedCount}</span>
|
||
</div>
|
||
{anonymousCount > 0 && (
|
||
<div style={styles.stat} title="Anonymous">
|
||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.anonymous }} />
|
||
<span>{anonymousCount}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<UserSearchModal
|
||
isOpen={isSearchOpen}
|
||
onClose={() => 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}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NetworkGraphMinimap;
|