import React, { useEffect, useRef } from 'react'; import * as d3 from 'd3'; import { ParseResult, MemberMetrics } from '../lib/parser'; interface NetworkGraphProps { data: ParseResult; onNodeClick: (username: string) => void; highlightedNode: string | null; minDegree: number; metrics?: Map; path?: string[]; // Array of usernames representing a highlighted path filter?: 'all' | 'following' | 'follower'; } interface Node extends d3.SimulationNodeDatum { id: string; group: number; radius: number; color: string; } interface Link extends d3.SimulationLinkDatum { source: string | Node; target: string | Node; value: number; type?: "following" | "follower"; } const colors = ["#6366f1", "#0ea5e9", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"]; export default function NetworkGraph({ data, onNodeClick, highlightedNode, minDegree, metrics, path, filter = 'all' }: NetworkGraphProps) { const containerRef = useRef(null); const svgRef = useRef(null); // Define zoom outside to re-use it const zoomRef = useRef | null>(null); useEffect(() => { if (!svgRef.current || !containerRef.current || data.relationships.size === 0) return; const width = containerRef.current.clientWidth; const height = containerRef.current.clientHeight; const nodes: Node[] = []; const links: Link[] = []; const nodeMap = new Map(); // Assign colors to central subjects const centerColors = new Map(); let cIdx = 0; for (const sub of data.relationships.keys()) { centerColors.set(sub, colors[cIdx % colors.length]); cIdx++; } const getOrCreateNode = (username: string, isSubject: boolean) => { if (!nodeMap.has(username)) { let nColor = "#94a3b8"; // default if (isSubject && centerColors.has(username)) { nColor = centerColors.get(username)!; } else if (metrics && metrics.has(username)) { const pc = metrics.get(username)!.primaryCenter; if (pc && centerColors.has(pc)) nColor = centerColors.get(pc)!; } const node: Node = { id: username, group: isSubject ? 1 : 2, radius: isSubject ? 12 : 6, color: nColor, }; nodeMap.set(username, node); nodes.push(node); } return nodeMap.get(username)!; }; // Calculate degrees to apply minDegree filter and pick top nodes if graph is too large const degrees = new Map(); data.relationships.forEach((rels, subject) => { degrees.set(subject, (degrees.get(subject) || 0) + rels.following.length + rels.followers.length); rels.following.forEach(u => degrees.set(u.username, (degrees.get(u.username) || 0) + 1)); rels.followers.forEach(u => degrees.set(u.username, (degrees.get(u.username) || 0) + 1)); }); const MAX_NODES = 800; // Increased max nodes since we render statically let allowedNodes = new Set(); // Filter nodes by minDegree const validDegrees = Array.from(degrees.entries()).filter(d => d[1] >= minDegree); if (validDegrees.length > MAX_NODES) { const sorted = validDegrees.sort((a, b) => b[1] - a[1]); allowedNodes = new Set(sorted.slice(0, MAX_NODES).map(s => s[0])); } else { allowedNodes = new Set(validDegrees.map(s => s[0])); } data.relationships.forEach((rels, subject) => { if (!allowedNodes.has(subject)) return; const sourceNode = getOrCreateNode(subject, true); sourceNode.group = 1; sourceNode.radius = 16; rels.following.forEach((u) => { if (!allowedNodes.has(u.username)) return; if (filter === 'follower') return; // Skip following links if filtering for followers only getOrCreateNode(u.username, data.relationships.has(u.username)); // subject follows u -> subject is source, u is target links.push({ source: subject, target: u.username, value: 1, type: "following" }); }); rels.followers.forEach((u) => { if (!allowedNodes.has(u.username)) return; if (filter === 'following') return; // Skip follower links if filtering for following only getOrCreateNode(u.username, data.relationships.has(u.username)); // u follows subject -> u is source, subject is target links.push({ source: u.username, target: subject, value: 1, type: "follower" }); }); }); const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); svg.attr("viewBox", [0, 0, width, height]); // Add arrow markers for directed edges const defs = svg.append("defs"); ["default", "following", "follower", "path"].forEach(pattern => { // following = blue // follower = green // path = orange // default = gray const color = pattern === "following" ? "#3b82f6" : pattern === "follower" ? "#10b981" : pattern === "path" ? "#f59e0b" : "#cbd5e1"; defs.append("marker") .attr("id", `arrow-${pattern}`) .attr("viewBox", "0 -5 10 10") .attr("refX", 22) // padding for node radius .attr("refY", 0) .attr("markerWidth", 5) .attr("markerHeight", 5) .attr("orient", "auto") .append("path") .attr("d", "M0,-5L10,0L0,5") .attr("fill", color); }); const g = svg.append("g"); const zoom = d3.zoom() .scaleExtent([0.1, 8]) .on("zoom", (event) => { g.attr("transform", event.transform); }); zoomRef.current = zoom; svg.call(zoom as any); // Make simulation static to prevent choppiness const simulation = d3.forceSimulation(nodes) .force("link", d3.forceLink(links).id((d: any) => d.id).distance(60)) .force("charge", d3.forceManyBody().strength(-200)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collide", d3.forceCollide().radius(d => (d as Node).radius + 8)); // Run simulation synchronously (pre-calculate graph layout) simulation.stop(); for (let i = 0; i < 200; ++i) { simulation.tick(); } const link = g.append("g") .attr("stroke", "#cbd5e1") .attr("stroke-opacity", 0.6) .selectAll("line") .data(links) .join("line") .attr("stroke-width", d => Math.min(Math.sqrt(d.value), 4)) .attr("x1", d => (d.source as Node).x!) .attr("y1", d => (d.source as Node).y!) .attr("x2", d => (d.target as Node).x!) .attr("y2", d => (d.target as Node).y!) .attr("marker-end", d => d.type ? `url(#arrow-${d.type})` : "url(#arrow-default)") .attr("stroke", d => { if (d.type === "following") return "#3b82f6"; if (d.type === "follower") return "#10b981"; return "#cbd5e1"; }) .attr("stroke-opacity", 0.3); const node = g.append("g") .attr("stroke", "#fff") .attr("stroke-width", 1.5) .selectAll("circle") .data(nodes) .join("circle") .attr("r", d => d.radius) .attr("fill", d => d.color) .style("cursor", "pointer") .attr("cx", d => d.x!) .attr("cy", d => d.y!) .on("click", (event, d) => onNodeClick(d.id)); node.append("title") .text(d => d.id); // Text labels for nodes const labels = g.append("g") .selectAll("text") .data(nodes) .join("text") .text(d => d.id) .attr("font-size", d => d.group === 1 ? "12px" : "8px") .attr("font-family", "sans-serif") .attr("fill", "#1e293b") .attr("dx", 12) .attr("dy", 4) .attr("x", d => d.x!) .attr("y", d => d.y!) .style("pointer-events", "none") // Only show text for prominent nodes to decrease clutter .attr("opacity", d => degrees.get(d.id)! > 3 || d.group === 1 ? 1 : 0.15); }, [data, onNodeClick, minDegree, filter]); useEffect(() => { if (!svgRef.current) return; const svg = d3.select(svgRef.current); const node = svg.selectAll("circle"); const link = svg.selectAll("line"); const text = svg.selectAll("text"); if (path && path.length > 0) { // Highlight specific path const pathSet = new Set(path); const pathEdges = new Set(); for (let i = 0; i < path.length - 1; i++) { pathEdges.add(`${path[i]}->${path[i+1]}`); pathEdges.add(`${path[i+1]}->${path[i]}`); // Undirected match just in case } node.attr("opacity", (d: any) => pathSet.has(d.id) ? 1 : 0.05) .attr("stroke", (d: any) => pathSet.has(d.id) ? "#f59e0b" : "#fff") .attr("stroke-width", (d: any) => pathSet.has(d.id) ? 3 : 1.5); text.attr("opacity", (d: any) => pathSet.has(d.id) ? 1 : 0.02); link.attr("stroke-opacity", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; return pathEdges.has(`${sourceId}->${targetId}`) ? 1 : 0.02; }).attr("stroke", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; return pathEdges.has(`${sourceId}->${targetId}`) ? "#f59e0b" : "#cbd5e1"; }).attr("marker-end", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; return pathEdges.has(`${sourceId}->${targetId}`) ? "url(#arrow-path)" : "none"; }).attr("stroke-width", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; return pathEdges.has(`${sourceId}->${targetId}`) ? 3 : Math.min(Math.sqrt(d.value), 4); }); return; } if (!highlightedNode) { node.attr("opacity", 1).attr("stroke", "#fff").attr("stroke-width", 1.5); link.attr("stroke-opacity", 0.3) .attr("stroke", (d: any) => { if (d.type === "following") return "#3b82f6"; if (d.type === "follower") return "#10b981"; return "#cbd5e1"; }) .attr("marker-end", (d: any) => d.type ? `url(#arrow-${d.type})` : "url(#arrow-default)") .attr("stroke-width", (d: any) => Math.min(Math.sqrt(d.value), 4)); // reset text opacity based on logical rules from earlier text.attr("opacity", (d: any) => d.group === 1 || (d.radius > 6) ? 1 : 0.15); return; } const connectedSet = new Set(); connectedSet.add(highlightedNode); data.relationships.forEach((rels, subject) => { if (subject === highlightedNode) { rels.following.forEach(u => connectedSet.add(u.username)); rels.followers.forEach(u => connectedSet.add(u.username)); } else { rels.following.forEach(u => { if (u.username === highlightedNode) connectedSet.add(subject); }); rels.followers.forEach(u => { if (u.username === highlightedNode) connectedSet.add(subject); }); } }); node.attr("opacity", (d: any) => connectedSet.has(d.id) ? 1 : 0.05) .attr("stroke", (d: any) => d.id === highlightedNode ? "#94a3b8" : "#fff") .attr("stroke-width", (d: any) => d.id === highlightedNode ? 3 : 1.5); text.attr("opacity", (d: any) => connectedSet.has(d.id) ? 1 : 0.02); link.attr("stroke-opacity", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; return (sourceId === highlightedNode || targetId === highlightedNode) ? 0.9 : 0.02; }).attr("stroke", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; if (sourceId === highlightedNode) return "#3b82f6"; // Following (blue) if (targetId === highlightedNode) return "#10b981"; // Follower (green) return "#cbd5e1"; }).attr("marker-end", (d: any) => { const sourceId = typeof d.source === 'string' ? d.source : (d.source as any).id; const targetId = typeof d.target === 'string' ? d.target : (d.target as any).id; if (sourceId === highlightedNode) return "url(#arrow-following)"; if (targetId === highlightedNode) return "url(#arrow-follower)"; return "none"; }); }, [highlightedNode, path, data]); return (
); }