'use client' import { useEffect, useRef } from 'react' import * as d3 from 'd3' interface Note { id: string title: string | null clusterId?: string | number isCentral?: boolean } interface NoteCluster { id: string | number name?: string noteIds: string[] color?: string } interface BridgeNote { noteId: string bridgeScore: number clustersConnected?: (string | number)[] clusterNames?: string[] } interface NetworkGraphProps { notes: Note[] clusters: NoteCluster[] bridgeNotes: BridgeNote[] onNoteSelect: (id: string) => void selectedClusterId?: string | null onClusterSelect?: (id: string | null) => void } export function NetworkGraph({ notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId = null, onClusterSelect }: 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 const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null && String(n.clusterId) !== '-1') if (visibleNotes.length === 0) return interface D3Node extends d3.SimulationNodeDatum { id: string title: string | null clusterId: string | number color: string isBridge: boolean isCentral: boolean radius: number } interface D3Link extends d3.SimulationLinkDatum { source: string target: string strength: number type?: 'inner' | 'bridge' } const bridgeSet = new Set(bridgeNotes.map(b => b.noteId)) // 1. Initialisation des nœuds avec rôles et diamètres distincts const nodes: D3Node[] = visibleNotes.map(n => { const cluster = clusters.find(c => String(c.id) === String(n.clusterId)) const isBridge = bridgeSet.has(n.id) const isCentral = !!n.isCentral // Hiérarchie de tailles premium let radius = 6 if (isCentral) radius = 13 else if (isBridge) radius = 10 return { id: n.id, title: n.title, clusterId: n.clusterId!, color: cluster?.color || '#cbd5e1', isBridge, isCentral, radius } }) // Groupement des nœuds par cluster const clusterGroups = new Map() nodes.forEach(node => { const cid = node.clusterId if (!clusterGroups.has(cid)) { clusterGroups.set(cid, []) } clusterGroups.get(cid)!.push(node) }) const links: D3Link[] = [] // 2. Création de la structure en étoile (Star-Network) par cluster clusterGroups.forEach((groupNodes, cid) => { if (groupNodes.length <= 1) return // Trouver le nœud central existant, ou en désigner un par défaut (le premier) let hub = groupNodes.find(n => n.isCentral) if (!hub) { hub = groupNodes[0] hub.isCentral = true hub.radius = 13 // Augmenter sa taille pour la hiérarchie visuelle } // Relier chaque feuille du cluster UNIQUEMENT à son nœud central (Hub) groupNodes.forEach(node => { if (node.id !== hub!.id) { links.push({ source: node.id, target: hub!.id, strength: 0.5, type: 'inner' }) } }) }) // 3. Liaison de ponts dorées reliant les nœuds centraux (Hubs) (Garde-fou D3 contre les nœuds manquants) const nodeSet = new Set(nodes.map(n => n.id)) bridgeNotes.forEach(b => { if (!b.clustersConnected) return if (!nodeSet.has(b.noteId)) return // Évite d'ajouter un lien si la note-pont n'est pas dans les nœuds affichés b.clustersConnected.forEach(cid => { const targetNodes = clusterGroups.get(cid) || [] if (targetNodes.length > 0) { const targetHub = targetNodes.find(n => n.isCentral) || targetNodes[0] if (nodeSet.has(targetHub.id)) { links.push({ source: b.noteId, target: targetHub.id, strength: 0.15, type: 'bridge' }) } } }) }) // 4. Pré-positionnement géométrique des Hubs en cercle pour éviter toute superposition initiale const uniqueClusterIds = Array.from(clusterGroups.keys()) const numClusters = uniqueClusterIds.length const radiusCircle = Math.min(width, height) * 0.28 // Rayon de répartition uniqueClusterIds.forEach((cid, index) => { const angle = (index * 2 * Math.PI) / numClusters const hubX = width / 2 + radiusCircle * Math.cos(angle) const hubY = height / 2 + radiusCircle * Math.sin(angle) const groupNodes = clusterGroups.get(cid) || [] const hub = groupNodes.find(n => n.isCentral) || groupNodes[0] if (hub) { hub.x = hubX hub.y = hubY } // Positionner les feuilles autour de leur propre hub groupNodes.forEach(node => { if (node.id !== hub?.id) { const leafAngle = Math.random() * 2 * Math.PI const leafDist = 25 + Math.random() * 20 node.x = hubX + leafDist * Math.cos(leafAngle) node.y = hubY + leafDist * Math.sin(leafAngle) } }) }) // D3 simulation — Paramètres de physique ultra-stables, centrés et étalés comme des galaxies const simulation = d3.forceSimulation(nodes) .force('link', d3.forceLink(links).id(d => d.id).distance(d => d.type === 'bridge' ? 140 : 35)) // Feuilles proches du Hub (35px) pour des constellations compactes et lisibles .force('charge', d3.forceManyBody().strength(d => (d as D3Node).isCentral ? -500 : -80)) // Répulsion équilibrée pour éviter de projeter les Hubs contre les bords de l'écran .force('center', d3.forceCenter(width / 2, height / 2)) .force('x', d3.forceX(width / 2).strength(0.12)) // Recentrage X renforcé pour l'équilibre central .force('y', d3.forceY(height / 2).strength(0.12)) // Recentrage Y renforcé .force('collision', d3.forceCollide().radius(d => d.radius + 14)) // Collision ajustée pour préserver la compacité // Liens avec couleur et opacité contextuelle const link = g.append('g') .selectAll('line') .data(links) .enter() .append('line') .attr('stroke', (d: any) => d.type === 'bridge' ? '#E2B13C' : '#cbd5e1') .attr('stroke-dasharray', (d: any) => d.type === 'bridge' ? '4,4' : 'none') .attr('stroke-opacity', (d: any) => { if (d.type === 'bridge') return selectedClusterId ? 0.15 : 0.6 if (!selectedClusterId) return 0.4 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 sourceNode = nodes.find(n => n.id === sId) const targetNode = nodes.find(n => n.id === tId) const sCluster = String(sourceNode?.clusterId) const tCluster = String(targetNode?.clusterId) return sCluster === selectedClusterId && tCluster === selectedClusterId ? 0.7 : 0.04 }) .attr('stroke-width', (d: any) => d.type === 'bridge' ? 1.5 : 1) // Nœuds avec opacité focus const node = g.append('g') .selectAll('.node') .data(nodes) .enter() .append('g') .attr('class', 'node cursor-pointer') .attr('opacity', d => { if (!selectedClusterId) return 1 return String(d.clusterId) === selectedClusterId ? 1 : 0.15 }) .on('click', (event, d) => onNoteSelect(d.id)) .call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended) as any) // Cercles avec tailles hiérarchiques et halos node.append('circle') .attr('r', d => d.radius) .attr('fill', d => d.color) .attr('stroke', d => d.isCentral ? 'rgba(255,255,255,0.9)' : d.isBridge ? '#D4AF37' : '#fff') .attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5) .style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none') // Labels de textes ultra-lisibles claire/sombre sans chevauchement node.append('text') .attr('dy', d => d.radius + 13) .attr('text-anchor', 'middle') .attr('font-size', d => d.isCentral ? '10px' : '9px') .attr('font-weight', d => d.isCentral ? '700' : '500') .attr('fill', '#4b5563') .attr('class', 'dark:fill-zinc-300 font-sans pointer-events-none') .text(d => { const title = d.title || 'Sans titre' return title.length > 20 ? title.substring(0, 18) + '...' : 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 => { // Bounding box rigide : maintient à 100% les clusters sur l'écran const padding = 35 d.x = Math.max(padding, Math.min(width - padding, d.x || width / 2)) d.y = Math.max(padding, Math.min(height - padding, d.y || height / 2)) return `translate(${d.x},${d.y})` }) }) // Zoom automatique sur le cluster sélectionné (800ms) if (selectedClusterId && width && height) { const clusterNodes = nodes.filter(n => String(n.clusterId) === selectedClusterId) if (clusterNodes.length > 0) { // Avancer la simulation pour obtenir des coordonnées stabilisées for (let i = 0; i < 60; ++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.3) .translate(-avgX, -avgY) ) } } } else if (!selectedClusterId) { 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 (
{/* Pastilles de cluster — cliquables pour activer le focus */}
{clusters.map(c => { const isSelected = String(c.id) === selectedClusterId return ( ) })} {selectedClusterId && ( )}
) }