| 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[]; |
| 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); |
|
|
| |
| 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>(); |
| |
| |
| 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"; |
| 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)!; |
| }; |
|
|
| |
| 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; |
| let allowedNodes = new Set<string>(); |
| |
| |
| 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; |
| getOrCreateNode(u.username, data.relationships.has(u.username)); |
| |
| 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; |
| getOrCreateNode(u.username, data.relationships.has(u.username)); |
| |
| 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]); |
|
|
| |
| const defs = svg.append("defs"); |
| ["default", "following", "follower", "path"].forEach(pattern => { |
| |
| |
| |
| |
| 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) |
| .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); |
|
|
| |
| 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)); |
|
|
| |
| 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); |
|
|
| |
| 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") |
| |
| .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) { |
| |
| 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]}`); |
| } |
|
|
| 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)); |
| |
| 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"; |
| if (targetId === highlightedNode) return "#10b981"; |
| 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> |
| ); |
| } |
|
|