|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import dynamic from 'next/dynamic'; |
| 4 | +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; |
| 5 | +import { X, Star, GitFork, Clock, Box } from 'lucide-react'; |
| 6 | + |
| 7 | +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }); |
| 8 | + |
| 9 | +const FILTER_COLORS = { |
| 10 | + Personal: '#3B82F6', // Blue |
| 11 | + Contributions: '#22C55E', // Green |
| 12 | + Forks: '#F97316', // Orange |
| 13 | +}; |
| 14 | + |
| 15 | +interface GraphNode { |
| 16 | + id: string; |
| 17 | + name: string; |
| 18 | + type: 'User' | 'Repo' | 'Contribution' | 'Fork'; |
| 19 | + val: number; |
| 20 | + color: string; |
| 21 | + stats?: { |
| 22 | + stars?: number; |
| 23 | + forks?: number; |
| 24 | + language?: string | null; |
| 25 | + updatedAt?: string; |
| 26 | + description?: string | null; |
| 27 | + }; |
| 28 | + x?: number; |
| 29 | + y?: number; |
| 30 | +} |
| 31 | + |
| 32 | +interface GraphLink { |
| 33 | + source: string | GraphNode; |
| 34 | + target: string | GraphNode; |
| 35 | +} |
| 36 | + |
| 37 | +interface RepositoryGraphProps { |
| 38 | + data: { |
| 39 | + nodes: GraphNode[]; |
| 40 | + links: GraphLink[]; |
| 41 | + }; |
| 42 | +} |
| 43 | + |
| 44 | +export default function RepositoryGraph({ data }: RepositoryGraphProps) { |
| 45 | + const [filters, setFilters] = useState({ |
| 46 | + Personal: true, |
| 47 | + Contributions: true, |
| 48 | + Forks: true, |
| 49 | + }); |
| 50 | + |
| 51 | + const [hoverNode, setHoverNode] = useState<GraphNode | null>(null); |
| 52 | + const [dimensions, setDimensions] = useState({ width: 800, height: 600 }); |
| 53 | + const containerRef = useRef<HTMLDivElement>(null); |
| 54 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 55 | + const fgRef = useRef<any>(null); |
| 56 | + |
| 57 | + useEffect(() => { |
| 58 | + if (containerRef.current) { |
| 59 | + setDimensions({ |
| 60 | + width: containerRef.current.clientWidth, |
| 61 | + height: Math.max(400, window.innerHeight * 0.45), |
| 62 | + }); |
| 63 | + |
| 64 | + const handleResize = () => { |
| 65 | + if (containerRef.current) { |
| 66 | + setDimensions({ |
| 67 | + width: containerRef.current.clientWidth, |
| 68 | + height: Math.max(400, window.innerHeight * 0.45), |
| 69 | + }); |
| 70 | + } |
| 71 | + }; |
| 72 | + window.addEventListener('resize', handleResize); |
| 73 | + return () => window.removeEventListener('resize', handleResize); |
| 74 | + } |
| 75 | + }, []); |
| 76 | + |
| 77 | + // Filter Data |
| 78 | + const graphData = useMemo(() => { |
| 79 | + const activeTypes = new Set<string>(['User']); |
| 80 | + if (filters.Personal) activeTypes.add('Repo'); |
| 81 | + if (filters.Contributions) activeTypes.add('Contribution'); |
| 82 | + if (filters.Forks) activeTypes.add('Fork'); |
| 83 | + |
| 84 | + const filteredNodes = data.nodes.filter((node) => activeTypes.has(node.type)); |
| 85 | + const validNodeIds = new Set(filteredNodes.map((n) => n.id)); |
| 86 | + |
| 87 | + const filteredLinks = data.links.filter( |
| 88 | + (link) => |
| 89 | + validNodeIds.has( |
| 90 | + typeof link.source === 'object' ? link.source.id : (link.source as string) |
| 91 | + ) && |
| 92 | + validNodeIds.has(typeof link.target === 'object' ? link.target.id : (link.target as string)) |
| 93 | + ); |
| 94 | + |
| 95 | + return { nodes: filteredNodes, links: filteredLinks }; |
| 96 | + }, [data, filters]); |
| 97 | + |
| 98 | + // Insights |
| 99 | + const insights = useMemo(() => { |
| 100 | + if (!data.nodes || data.nodes.length === 0) return null; |
| 101 | + |
| 102 | + let mostStarred = null; |
| 103 | + let highestContribution = null; |
| 104 | + let reposCount = 0; |
| 105 | + |
| 106 | + for (const node of data.nodes) { |
| 107 | + if (node.type === 'Repo' || node.type === 'Fork') reposCount++; |
| 108 | + if (!mostStarred || (node.stats?.stars || 0) > (mostStarred.stats?.stars || 0)) { |
| 109 | + if (node.type !== 'User') mostStarred = node; |
| 110 | + } |
| 111 | + if ( |
| 112 | + node.type === 'Contribution' && |
| 113 | + (!highestContribution || (node.stats?.stars || 0) > (highestContribution.stats?.stars || 0)) |
| 114 | + ) { |
| 115 | + highestContribution = node; |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + return { |
| 120 | + reposCount, |
| 121 | + mostStarred, |
| 122 | + highestContribution, |
| 123 | + }; |
| 124 | + }, [data]); |
| 125 | + |
| 126 | + const handleNodeClick = useCallback((node: GraphNode) => { |
| 127 | + if (fgRef.current) { |
| 128 | + fgRef.current.centerAt(node.x, node.y, 1000); |
| 129 | + fgRef.current.zoom(2, 1000); |
| 130 | + } |
| 131 | + }, []); |
| 132 | + |
| 133 | + const toggleFilter = (key: keyof typeof filters) => { |
| 134 | + setFilters((prev) => ({ ...prev, [key]: !prev[key] })); |
| 135 | + }; |
| 136 | + |
| 137 | + if (!data || data.nodes.length <= 1) { |
| 138 | + return ( |
| 139 | + <div className="flex flex-col items-center justify-center p-12 border border-dashed border-gray-300 dark:border-zinc-800 rounded-xl text-gray-500"> |
| 140 | + <Box className="w-12 h-12 mb-4 opacity-50" /> |
| 141 | + <p>No repository relationship data available yet.</p> |
| 142 | + </div> |
| 143 | + ); |
| 144 | + } |
| 145 | + |
| 146 | + return ( |
| 147 | + <div className="flex flex-col gap-6" id="repository-graph"> |
| 148 | + {/* Header & Filters */} |
| 149 | + <div className="flex flex-col md:flex-row md:items-end justify-between gap-4"> |
| 150 | + <div> |
| 151 | + <h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2"> |
| 152 | + 🌐 Repository Dependency Graph |
| 153 | + </h2> |
| 154 | + <p className="text-sm text-gray-500 dark:text-zinc-400 mt-1"> |
| 155 | + Visualize your GitHub ecosystem and contribution network. |
| 156 | + </p> |
| 157 | + </div> |
| 158 | + |
| 159 | + <div className="flex flex-wrap gap-2"> |
| 160 | + {Object.entries(filters).map(([key, value]) => ( |
| 161 | + <button |
| 162 | + key={key} |
| 163 | + onClick={() => toggleFilter(key as keyof typeof filters)} |
| 164 | + className={`px-3 py-1.5 text-xs font-semibold rounded-full border transition-all ${ |
| 165 | + value |
| 166 | + ? 'text-black border-transparent shadow-sm' |
| 167 | + : 'bg-transparent text-gray-500 border-gray-300 dark:border-zinc-700 hover:border-gray-500' |
| 168 | + }`} |
| 169 | + style={{ |
| 170 | + backgroundColor: value |
| 171 | + ? FILTER_COLORS[key as keyof typeof FILTER_COLORS] |
| 172 | + : undefined, |
| 173 | + }} |
| 174 | + > |
| 175 | + {key} |
| 176 | + </button> |
| 177 | + ))} |
| 178 | + </div> |
| 179 | + </div> |
| 180 | + |
| 181 | + {/* Main Layout */} |
| 182 | + <div className="flex flex-col lg:flex-row gap-6 lg:gap-8 relative"> |
| 183 | + {/* Graph Container */} |
| 184 | + <div |
| 185 | + ref={containerRef} |
| 186 | + className="flex-grow bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] rounded-xl overflow-hidden relative shadow-sm" |
| 187 | + style={{ height: dimensions.height }} |
| 188 | + > |
| 189 | + <ForceGraph2D |
| 190 | + ref={fgRef} |
| 191 | + width={dimensions.width} |
| 192 | + height={dimensions.height} |
| 193 | + graphData={graphData} |
| 194 | + nodeLabel="name" |
| 195 | + nodeColor={(node: Record<string, unknown>) => String(node.color || '')} |
| 196 | + nodeRelSize={1} |
| 197 | + nodeVal={(node: Record<string, unknown>) => Number(node.val || 0)} |
| 198 | + onNodeClick={(node: Record<string, unknown>) => |
| 199 | + handleNodeClick(node as unknown as GraphNode) |
| 200 | + } |
| 201 | + onNodeHover={(node: Record<string, unknown> | null) => |
| 202 | + setHoverNode((node as unknown as GraphNode) || null) |
| 203 | + } |
| 204 | + linkDirectionalParticles={2} |
| 205 | + linkDirectionalParticleSpeed={() => 0.005} |
| 206 | + linkColor={() => 'rgba(150, 150, 150, 0.2)'} |
| 207 | + backgroundColor="transparent" |
| 208 | + /> |
| 209 | + |
| 210 | + {/* Custom Hover Tooltip overlay to keep it out of canvas for better styling */} |
| 211 | + {hoverNode && ( |
| 212 | + <div className="absolute top-4 left-4 pointer-events-none bg-black/90 dark:bg-white/90 backdrop-blur-sm text-white dark:text-black p-4 rounded-xl shadow-xl z-10 w-64 border border-white/10 dark:border-black/10 transition-opacity"> |
| 213 | + <h4 className="font-bold text-base mb-1 truncate">{hoverNode.name}</h4> |
| 214 | + <p className="text-xs uppercase tracking-wider opacity-70 mb-3">{hoverNode.type}</p> |
| 215 | + |
| 216 | + {hoverNode.stats && ( |
| 217 | + <div className="space-y-2 text-sm"> |
| 218 | + {hoverNode.stats.stars !== undefined && ( |
| 219 | + <div className="flex items-center gap-2"> |
| 220 | + <Star className="w-4 h-4 text-yellow-400" /> |
| 221 | + <span>{hoverNode.stats.stars} Stars</span> |
| 222 | + </div> |
| 223 | + )} |
| 224 | + {hoverNode.stats.forks !== undefined && ( |
| 225 | + <div className="flex items-center gap-2"> |
| 226 | + <GitFork className="w-4 h-4 opacity-70" /> |
| 227 | + <span>{hoverNode.stats.forks} Forks</span> |
| 228 | + </div> |
| 229 | + )} |
| 230 | + {hoverNode.stats.language && ( |
| 231 | + <div className="flex items-center gap-2"> |
| 232 | + <div className="w-3 h-3 rounded-full bg-blue-400" /> |
| 233 | + <span>{hoverNode.stats.language}</span> |
| 234 | + </div> |
| 235 | + )} |
| 236 | + {hoverNode.stats.updatedAt && ( |
| 237 | + <div className="flex items-center gap-2 mt-2 pt-2 border-t border-white/20 dark:border-black/20 text-xs opacity-70"> |
| 238 | + <Clock className="w-3 h-3" /> |
| 239 | + <span> |
| 240 | + Updated: {new Date(hoverNode.stats.updatedAt).toLocaleDateString()} |
| 241 | + </span> |
| 242 | + </div> |
| 243 | + )} |
| 244 | + </div> |
| 245 | + )} |
| 246 | + </div> |
| 247 | + )} |
| 248 | + </div> |
| 249 | + |
| 250 | + {/* Side Panel (Desktop) */} |
| 251 | + <div className="lg:w-80 flex flex-col gap-6 hidden lg:flex"> |
| 252 | + <div className="bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] rounded-xl p-6 shadow-sm hidden lg:block"> |
| 253 | + <div className="flex items-center gap-3 mb-6"> |
| 254 | + <div className="p-2 bg-blue-500/10 text-blue-500 rounded-lg"> |
| 255 | + <Box className="w-5 h-5" /> |
| 256 | + </div> |
| 257 | + <h3 className="font-semibold text-gray-900 dark:text-white">Graph Insights</h3> |
| 258 | + </div> |
| 259 | + |
| 260 | + <div className="space-y-5"> |
| 261 | + <div> |
| 262 | + <p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1"> |
| 263 | + Ecosystem Size |
| 264 | + </p> |
| 265 | + <p className="text-sm font-medium text-gray-900 dark:text-white"> |
| 266 | + {insights?.reposCount} Repositories connected |
| 267 | + </p> |
| 268 | + </div> |
| 269 | + |
| 270 | + {insights?.mostStarred && ( |
| 271 | + <div> |
| 272 | + <p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1"> |
| 273 | + 🌟 Most Starred |
| 274 | + </p> |
| 275 | + <p className="text-sm font-medium text-gray-900 dark:text-white truncate"> |
| 276 | + {insights.mostStarred.name} ({insights.mostStarred.stats?.stars} stars) |
| 277 | + </p> |
| 278 | + </div> |
| 279 | + )} |
| 280 | + |
| 281 | + {insights?.highestContribution && ( |
| 282 | + <div> |
| 283 | + <p className="text-xs text-gray-500 uppercase tracking-wider font-semibold mb-1"> |
| 284 | + ⚡ Top Contribution |
| 285 | + </p> |
| 286 | + <p className="text-sm font-medium text-gray-900 dark:text-white truncate"> |
| 287 | + {insights.highestContribution.name} |
| 288 | + </p> |
| 289 | + </div> |
| 290 | + )} |
| 291 | + |
| 292 | + <div className="pt-4 mt-2 border-t border-gray-100 dark:border-zinc-800"> |
| 293 | + <p className="text-xs text-gray-500 text-center"> |
| 294 | + Hover over any node to view detailed statistics, and click to zoom. |
| 295 | + </p> |
| 296 | + </div> |
| 297 | + </div> |
| 298 | + </div> |
| 299 | + </div> |
| 300 | + </div> |
| 301 | + </div> |
| 302 | + ); |
| 303 | +} |
0 commit comments