import React, { useEffect, useRef } from 'react'; import * as d3 from 'd3'; import { Note, NoteCluster, BridgeNote } from '../types'; interface NetworkGraphProps { notes: Note[]; clusters: NoteCluster[]; bridgeNotes: BridgeNote[]; onNoteSelect: (id: string) => void; selectedClusterId: string | null; onClusterSelect: (id: string | null) => void; } export const NetworkGraph: React.FC = ({ notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId, onClusterSelect }) => { 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); // Filter notes with embeddings and cluster assignments const visibleNotes = notes.filter(n => n.embedding && n.clusterId); interface D3Node extends d3.SimulationNodeDatum { id: string; title: string; clusterId: string; 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 === 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 ? 13 : 8 }; }); const links: D3Link[] = []; // Only connect strong links 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(110)) .force("charge", d3.forceManyBody().strength(-220)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collision", d3.forceCollide().radius(d => d.radius + 12)); // Links const link = g.append("g") .selectAll("line") .data(links) .enter() .append("line") .attr("stroke", "#e2e8f0") .attr("stroke-opacity", (d: any) => { if (!selectedClusterId) return 0.6; const sId = typeof d.source === 'string' ? d.source : (d.source as any).id; const tId = typeof d.target === 'string' ? d.target : (d.target as any).id; const sourceNote = nodes.find(n => n.id === sId); const targetNote = nodes.find(n => n.id === tId); return (sourceNote?.clusterId === selectedClusterId && targetNote?.clusterId === selectedClusterId) ? 0.8 : 0.05; }) .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 opacities based on focus node.attr("opacity", d => { if (!selectedClusterId) return 1; return d.clusterId === selectedClusterId ? 1 : 0.15; }); 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.5 : 2) .style("filter", d => d.isBridge ? "drop-shadow(0 0 6px rgba(212, 175, 55, 0.6))" : "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 => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.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})`); }); // Zoom transition on cluster highlight if (selectedClusterId && width && height) { const clusterNodes = nodes.filter(n => n.clusterId === selectedClusterId); if (clusterNodes.length > 0) { // Run a small tick count synchronously to find coordinates quickly if layout is starting for (let i = 0; i < 50; ++i) simulation.tick(); const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined); const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined); if (xCoords.length > 0 && yCoords.length > 0) { const avgX = d3.mean(xCoords) || width / 2; const avgY = d3.mean(yCoords) || height / 2; svg.transition() .duration(800) .call( zoom.transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1.4).translate(-avgX, -avgY) ); } } } else { svg.transition() .duration(800) .call(zoom.transform, d3.zoomIdentity); } 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, selectedClusterId]); return (
{clusters.map(c => { const isSelected = selectedClusterId === c.id; return ( ); })} {selectedClusterId && ( )}
); };