Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
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} · {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}