- zoomRef stocke le behavior d3.zoom pour accès externe au useEffect - handleFitView: d3.zoomIdentity reset (600ms transition) + clear selectedClusterId - Bouton Maximize2 en haut à droite du graphe avec aria-label - cursor-pointer + focus-visible:ring pour a11y
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import * as d3 from 'd3'
|
|
import { Maximize2 } from 'lucide-react'
|
|
|
|
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
|
|
untitledLabel?: string
|
|
resetFocusLabel?: string
|
|
fitViewLabel?: string
|
|
}
|
|
|
|
export function NetworkGraph({
|
|
notes,
|
|
clusters,
|
|
bridgeNotes,
|
|
onNoteSelect,
|
|
selectedClusterId = null,
|
|
onClusterSelect,
|
|
untitledLabel = 'Untitled',
|
|
resetFocusLabel = 'Reset focus',
|
|
fitViewLabel = 'Fit view',
|
|
}: NetworkGraphProps) {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const zoomRef = useRef<any>(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<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform)
|
|
})
|
|
|
|
zoomRef.current = zoom
|
|
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<D3Node> {
|
|
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<string | number, D3Node[]>()
|
|
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<D3Node>(nodes)
|
|
.force('link', d3.forceLink<D3Node, D3Link>(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<D3Node>().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))
|
|
.on('dblclick', (event, d) => {
|
|
d.fx = null
|
|
d.fy = null
|
|
simulation.alphaTarget(0.1).restart()
|
|
})
|
|
.call(d3.drag<SVGGElement, D3Node>()
|
|
.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 || untitledLabel
|
|
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)
|
|
// Garder le nœud épinglé à sa position après drag (fx/fy non remis à null)
|
|
// Double-clic pour désépingler → géré via dblclick ci-dessous
|
|
}
|
|
|
|
return () => {
|
|
simulation.stop()
|
|
}
|
|
}, [notes, clusters, bridgeNotes, onNoteSelect, selectedClusterId])
|
|
|
|
const handleFitView = () => {
|
|
if (!svgRef.current || !zoomRef.current) return
|
|
d3.select(svgRef.current)
|
|
.transition()
|
|
.duration(600)
|
|
.call(zoomRef.current.transform, d3.zoomIdentity)
|
|
onClusterSelect?.(null)
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
|
{/* Pastilles de cluster — cliquables pour activer le focus */}
|
|
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-2 max-w-[90%]">
|
|
{clusters.map(c => {
|
|
const isSelected = String(c.id) === selectedClusterId
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider ${
|
|
isSelected
|
|
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
|
|
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
|
|
}`}
|
|
>
|
|
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
|
|
<span>{c.name ?? String(c.id)}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
{selectedClusterId && (
|
|
<button
|
|
onClick={() => onClusterSelect?.(null)}
|
|
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
|
|
>
|
|
{resetFocusLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Fit view button */}
|
|
<button
|
|
onClick={handleFitView}
|
|
className="absolute top-6 right-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
|
|
aria-label={fitViewLabel}
|
|
>
|
|
<Maximize2 size={11} />
|
|
{fitViewLabel}
|
|
</button>
|
|
<svg ref={svgRef} className="w-full h-full" />
|
|
</div>
|
|
)
|
|
}
|