Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 179 lines 5.4 kB view raw
1'use client'; 2 3import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; 4import { useTheme } from 'next-themes'; 5import dynamic from 'next/dynamic'; 6import type { NodeObject, LinkObject } from 'react-force-graph-2d'; 7import { cn } from '@/lib/utils'; 8import { CHART_COLORS } from './chart-colors'; 9 10const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }); 11 12interface NetworkGraphProps { 13 nodes: { 14 did: string; 15 handle: string; 16 displayName: string; 17 avatar: string | null; 18 degree: number; 19 }[]; 20 edges: { source: string; target: string; mutual: boolean }[]; 21 className?: string; 22} 23 24type GNode = NodeObject; 25type GLink = LinkObject; 26 27interface HoveredInfo { 28 handle: string; 29 displayName: string; 30 degree: number; 31} 32 33export function NetworkGraph({ nodes, edges, className }: NetworkGraphProps) { 34 const { resolvedTheme } = useTheme(); 35 const mounted = useSyncExternalStore( 36 () => () => {}, 37 () => true, 38 () => false, 39 ); 40 const [hoveredNode, setHoveredNode] = useState<HoveredInfo | null>(null); 41 const containerRef = useRef<HTMLDivElement>(null); 42 // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 const graphRef = useRef<any>(null); 44 const [dimensions, setDimensions] = useState({ width: 600, height: 500 }); 45 46 useEffect(() => { 47 if (!containerRef.current) return; 48 const observer = new ResizeObserver((entries) => { 49 const entry = entries[0]; 50 if (entry) { 51 setDimensions({ 52 width: entry.contentRect.width, 53 height: Math.max(320, Math.min(500, entry.contentRect.width * 0.7)), 54 }); 55 } 56 }); 57 observer.observe(containerRef.current); 58 return () => observer.disconnect(); 59 }, [mounted]); 60 61 // Configure d3 forces for better spread 62 useEffect(() => { 63 const fg = graphRef.current; 64 if (!fg) return; 65 fg.d3Force('charge')?.strength(-120); 66 fg.d3Force('link')?.distance(40); 67 fg.d3Force('center')?.strength(0.05); 68 }); 69 70 const isDark = resolvedTheme === 'dark'; 71 const colors = isDark ? CHART_COLORS.dark : CHART_COLORS.light; 72 73 const graphData = { 74 nodes: nodes.map((n) => ({ 75 id: n.did, 76 handle: n.handle, 77 displayName: n.displayName, 78 degree: n.degree, 79 })), 80 links: edges.map((e) => ({ 81 source: e.source, 82 target: e.target, 83 mutual: e.mutual, 84 })), 85 }; 86 87 const nodeCanvasObject = useCallback( 88 (node: GNode, ctx: CanvasRenderingContext2D) => { 89 const degree = (node.degree as number) ?? 1; 90 const size = Math.max(2, Math.min(8, 1 + Math.sqrt(degree))); 91 const color = colors[Math.abs(hashCode(String(node.id ?? ''))) % colors.length]; 92 ctx.beginPath(); 93 ctx.arc(node.x ?? 0, node.y ?? 0, size, 0, 2 * Math.PI); 94 ctx.fillStyle = color as string; 95 ctx.fill(); 96 }, 97 [colors], 98 ); 99 100 const linkColor = useCallback( 101 (link: GLink) => { 102 const base = isDark ? 'rgba(255,255,255,' : 'rgba(0,0,0,'; 103 return link.mutual ? `${base}0.3)` : `${base}0.12)`; 104 }, 105 [isDark], 106 ); 107 108 const linkWidth = useCallback((link: GLink) => (link.mutual ? 1.5 : 0.5), []); 109 110 if (!nodes.length) return null; 111 112 if (!mounted) { 113 return <div ref={containerRef} className={cn('min-h-[320px] md:min-h-[500px]', className)} />; 114 } 115 116 return ( 117 <div ref={containerRef} className={cn('relative min-h-[320px] md:min-h-[500px]', className)}> 118 <div aria-hidden="true"> 119 <ForceGraph2D 120 ref={graphRef} 121 graphData={graphData} 122 width={dimensions.width} 123 height={dimensions.height} 124 nodeCanvasObject={nodeCanvasObject} 125 nodePointerAreaPaint={( 126 node: GNode, 127 paintColor: string, 128 ctx: CanvasRenderingContext2D, 129 ) => { 130 const degree = (node.degree as number) ?? 1; 131 const size = Math.max(2, Math.min(8, 1 + Math.sqrt(degree))); 132 ctx.beginPath(); 133 ctx.arc(node.x ?? 0, node.y ?? 0, size + 3, 0, 2 * Math.PI); 134 ctx.fillStyle = paintColor; 135 ctx.fill(); 136 }} 137 linkColor={linkColor} 138 linkWidth={linkWidth} 139 onNodeHover={(node: GNode | null) => { 140 if (node) { 141 setHoveredNode({ 142 handle: node.handle as string, 143 displayName: node.displayName as string, 144 degree: node.degree as number, 145 }); 146 } else { 147 setHoveredNode(null); 148 } 149 }} 150 d3AlphaDecay={0.02} 151 d3VelocityDecay={0.3} 152 cooldownTicks={200} 153 warmupTicks={100} 154 enableZoomInteraction={true} 155 enablePanInteraction={true} 156 /> 157 </div> 158 {hoveredNode && ( 159 <div className="pointer-events-none absolute bottom-3 left-3 rounded-md border border-border bg-secondary px-3 py-2 text-sm shadow-md"> 160 <p className="font-medium text-foreground"> 161 {hoveredNode.displayName || hoveredNode.handle} 162 </p> 163 <p className="text-muted-foreground"> 164 @{hoveredNode.handle} &middot; {hoveredNode.degree} connections 165 </p> 166 </div> 167 )} 168 </div> 169 ); 170} 171 172function hashCode(str: string): number { 173 let hash = 0; 174 for (let i = 0; i < str.length; i++) { 175 hash = (hash << 5) - hash + str.charCodeAt(i); 176 hash |= 0; 177 } 178 return hash; 179}