'use client' import { useEffect, useRef } from 'react' import * as d3 from 'd3' // Force to group nodes by cluster function forceCluster() { let nodes: any[] = [] let clusters: Map = new Map() function force(alpha: number) { // Calculate cluster centers clusters.clear() for (const node of nodes) { const clusterId = node.clusterId if (!clusters.has(clusterId)) { clusters.set(clusterId, { x: 0, y: 0, count: 0 }) } const center = clusters.get(clusterId)! center.x += node.x center.y += node.y center.count = (center.count || 0) + 1 } // Average positions for (const [clusterId, center] of clusters.entries()) { center.x /= center.count || 1 center.y /= center.count || 1 } // Move nodes toward their cluster center for (const node of nodes) { const clusterCenter = clusters.get(node.clusterId) if (clusterCenter && clusterCenter.count > 1) { const targetX = clusterCenter.x const targetY = clusterCenter.y node.vx += (targetX - node.x) * alpha * 0.3 node.vy += (targetY - node.y) * alpha * 0.3 } } } force.initialize = function(newNodes: any[]) { nodes = newNodes return force } return force } interface Note { id: string title: string | null clusterId?: string | number } interface NoteCluster { id: string | number name: string noteIds: string[] color: string } interface BridgeNote { noteId: string bridgeScore: number clustersConnected?: (string | number)[] connectedClusterIds?: (string | number)[] } interface NetworkGraphProps { notes: Note[] clusters: NoteCluster[] bridgeNotes: BridgeNote[] onNoteSelect: (id: string) => void } export function NetworkGraph({ notes, clusters, bridgeNotes, onNoteSelect }: NetworkGraphProps) { const svgRef = useRef(null) const containerRef = useRef(null) useEffect(() => { if (!svgRef.current || !containerRef.current) return const width = containerRef.current.clientWidth const height = containerRef.current.clientHeight const svg = d3.select(svgRef.current) svg.selectAll('*').remove() const g = svg.append('g') const zoom = d3.zoom() .scaleExtent([0.1, 4]) .on('zoom', (event) => { g.attr('transform', event.transform) }) svg.call(zoom as any) // Filter notes with cluster assignments (properly check for undefined/null) const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null) interface D3Node extends d3.SimulationNodeDatum { id: string title: string | null clusterId: string | number color: string isBridge: boolean radius: number } interface D3Link extends d3.SimulationLinkDatum { source: string target: string strength: number } const bridgeSet = new Set(bridgeNotes.map(b => b.noteId)) const nodes: D3Node[] = visibleNotes.map(n => { const cluster = clusters.find(c => c.id === String(n.clusterId)) const isBridge = bridgeSet.has(n.id) return { id: n.id, title: n.title, clusterId: n.clusterId!, color: cluster?.color || '#cbd5e1', isBridge, radius: isBridge ? 12 : 8 } }) const links: D3Link[] = [] // Connect notes within the same cluster for (let i = 0; i < visibleNotes.length; i++) { for (let j = i + 1; j < visibleNotes.length; j++) { const ni = visibleNotes[i] const nj = visibleNotes[j] if (ni.clusterId === nj.clusterId) { links.push({ source: ni.id, target: nj.id, strength: 0.5 }) } } } const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id(d => d.id).distance(50)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)) .force('collision', d3.forceCollide().radius(d => d.radius + 15)) .force('cluster', forceCluster()) // Links const link = g.append('g') .selectAll('line') .data(links) .enter() .append('line') .attr('stroke', '#e2e8f0') .attr('stroke-opacity', 0.6) .attr('stroke-width', 1) // Nodes const node = g.append('g') .selectAll('.node') .data(nodes) .enter() .append('g') .attr('class', 'node cursor-pointer') .on('click', (event, d) => onNoteSelect(d.id)) .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended) as any) node.append('circle') .attr('r', d => d.radius) .attr('fill', d => d.color) .attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff') .attr('stroke-width', d => d.isBridge ? 3 : 2) .style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none') node.append('text') .attr('dy', d => d.radius + 14) .attr('text-anchor', 'middle') .attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none') .text(d => { const title = d.title || 'Untitled' return title.length > 20 ? title.substring(0, 20) + '...' : title }) simulation.on('tick', () => { link .attr('x1', d => (d.source as any).x) .attr('y1', d => (d.source as any).y) .attr('x2', d => (d.target as any).x) .attr('y2', d => (d.target as any).y) node .attr('transform', d => `translate(${d.x},${d.y})`) }) function dragstarted(event: any, d: D3Node) { if (!event.active) simulation.alphaTarget(0.3).restart() d.fx = d.x d.fy = d.y } function dragged(event: any, d: D3Node) { d.fx = event.x d.fy = event.y } function dragended(event: any, d: D3Node) { if (!event.active) simulation.alphaTarget(0) d.fx = null d.fy = null } return () => { simulation.stop() } }, [notes, clusters, bridgeNotes, onNoteSelect]) return (
{clusters.map(c => (
{c.name}
))}
) }