analyze / src /components /NetworkGraph.tsx
wuhp's picture
Upload 14 files
d614256 verified
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<string, MemberMetrics>;
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<Node> {
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<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
// Define zoom outside to re-use it
const zoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | 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<string, Node>();
// Assign colors to central subjects
const centerColors = new Map<string, string>();
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<string, number>();
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<string>();
// 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<SVGSVGElement, unknown>()
.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<string>();
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<string>();
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 (
<div ref={containerRef} className="w-full h-full relative cursor-move">
<svg ref={svgRef} className="w-full h-full" />
</div>
);
}