'use client' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import { Loader2, Network, Filter, X, ExternalLink, Maximize2 } from 'lucide-react' const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number } interface GraphEdge { source: string; target: string; weight: number; type: string } interface Cluster { id: string; name: string } interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] } interface NotePreview { id: string; title: string; content: string; createdAt: string } const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148'] export function NoteGraphView() { const router = useRouter() const containerRef = useRef(null) const graphRef = useRef(null) const [dimensions, setDimensions] = useState({ width: 800, height: 600 }) const [rawData, setRawData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [searchFilter, setSearchFilter] = useState('') const [selectedNode, setSelectedNode] = useState(null) const [notePreview, setNotePreview] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [selectedNotebookId, setSelectedNotebookId] = useState(null) // ─── Resize ─────────────────────────────────────────────────────────────── useEffect(() => { const el = containerRef.current if (!el) return const ro = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect setDimensions({ width: Math.floor(width), height: Math.floor(height) }) }) ro.observe(el) return () => ro.disconnect() }, []) // ─── Fetch data ─────────────────────────────────────────────────────────── useEffect(() => { setLoading(true) fetch('/api/graph') .then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() }) .then(d => setRawData(d)) .catch(e => setError(e.message)) .finally(() => setLoading(false)) }, []) // ─── Configure forces once graph is mounted ─────────────────────────────── const forcesConfigured = useRef(false) useEffect(() => { if (!rawData || forcesConfigured.current) return // Wait for the ForceGraph to mount const timer = setTimeout(() => { const fg = graphRef.current if (!fg) return fg.d3Force('charge')?.strength(-120) fg.d3Force('link')?.distance(55) fg.d3Force('center')?.strength(0.05) forcesConfigured.current = true }, 200) return () => clearTimeout(timer) }, [rawData]) // ─── Note preview ───────────────────────────────────────────────────────── useEffect(() => { if (!selectedNode) { setNotePreview(null); return } setPreviewLoading(true) fetch(`/api/notes/${selectedNode.id}`) .then(r => r.ok ? r.json() : null) .then(res => setNotePreview(res?.data ?? null)) .catch(() => setNotePreview(null)) .finally(() => setPreviewLoading(false)) }, [selectedNode]) // ─── Color map ──────────────────────────────────────────────────────────── const colorMap = useMemo(() => { if (!rawData) return new Map() const map = new Map() const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length])) return map }, [rawData]) // ─── Graph data ─────────────────────────────────────────────────────────── const graphData = useMemo(() => { if (!rawData) return { nodes: [], links: [] } // Filter by notebook let filtered = selectedNotebookId ? rawData.nodes.filter(n => n.notebookId === selectedNotebookId) : rawData.nodes // Filter by text search filtered = searchFilter.trim() ? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase())) : filtered const filteredIds = new Set(filtered.map(n => n.id)) return { nodes: filtered.map(n => ({ id: n.id, name: n.title, val: 1 + Math.min(n.degree, 8) * 0.5, color: colorMap.get(n.notebookId) ?? '#94a3b8', notebookId: n.notebookId, degree: n.degree, })), links: rawData.edges .filter(e => filteredIds.has(e.source) && filteredIds.has(e.target)) .map(e => { let color = '#e2e8f0' let width = 0.6 let dash = false if (e.type === 'explicit_link') { color = '#10b981' // Green width = 2.2 } else if (e.type === 'semantic_echo') { color = '#a78bfa' // Purple width = 1.8 dash = true } else if (e.type === 'title_mention') { color = '#f59e0b' // Amber/Orange width = 1.6 } else if (e.type === 'shared_label') { color = '#3b82f6' // Blue width = 1.2 } return { source: e.source, target: e.target, color, width, dash, type: e.type, } }), } }, [rawData, searchFilter, colorMap, selectedNotebookId]) const selectedNotebookName = useMemo(() => { if (!selectedNode || !rawData) return null return rawData.clusters.find(c => c.id === selectedNode.notebookId)?.name ?? null }, [selectedNode, rawData]) // ─── Handlers (double-click via timer) ────────────────────────────────── const lastClickRef = useRef<{ id: string; time: number } | null>(null) const handleNodeClick = useCallback((node: any) => { if (!rawData) return const now = Date.now() const last = lastClickRef.current if (last && last.id === node.id && now - last.time < 350) { // Double-click → zoom lastClickRef.current = null graphRef.current?.centerAt(node.x, node.y, 600) graphRef.current?.zoom(3, 600) return } lastClickRef.current = { id: node.id, time: now } setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null) }, [rawData]) const handleZoomToFit = useCallback(() => { graphRef.current?.zoomToFit(400, 50) }, []) const plainText = (html: string | null | undefined) => (html ?? '') .replace(/<[^>]+>/g, ' ') .replace(/#{1,6}\s/g, '') .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') .replace(/_{1,2}([^_]+)_{1,2}/g, '$1') .replace(/`[^`]+`/g, '') .replace(/!?\[[^\]]*\]\([^)]*\)/g, '') .replace(/\s+/g, ' ').trim().slice(0, 400) // ─── Cluster painting (stable ref, no deps) ────────────────────────────── const dataRef = useRef<{ nodes: any[]; colorMap: Map; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] }) dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] } const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => { const { nodes, colorMap: cm, clusters } = dataRef.current if (!nodes || nodes.length === 0) return const groups = new Map() for (const node of nodes) { if (!node.notebookId || node.x === undefined || node.y === undefined) continue if (!groups.has(node.notebookId)) groups.set(node.notebookId, []) groups.get(node.notebookId)!.push({ x: node.x, y: node.y }) } for (const [nbId, pts] of groups) { if (pts.length < 3) continue const color = cm.get(nbId) ?? '#94a3b8' const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length let maxR = 0 for (const p of pts) { const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2) if (d > maxR) maxR = d } const r = maxR + 30 ctx.beginPath() ctx.arc(cx, cy, r, 0, 2 * Math.PI) ctx.fillStyle = color + '0A' ctx.fill() ctx.strokeStyle = color + '30' ctx.lineWidth = 1.5 / globalScale ctx.setLineDash([5 / globalScale, 5 / globalScale]) ctx.stroke() ctx.setLineDash([]) // Cluster name if (globalScale > 0.4) { const name = clusters.find(c => c.id === nbId)?.name ?? '' if (name) { const fs = Math.min(12, 9 / globalScale) ctx.font = `600 ${fs}px -apple-system, sans-serif` ctx.fillStyle = color + 'BB' ctx.textAlign = 'center' ctx.textBaseline = 'bottom' ctx.fillText(name, cx, cy - r + 4) } } } }).current // ─── Render ─────────────────────────────────────────────────────────────── return (
{/* Header */}

Vue en graphe

{rawData && ( {rawData.nodes.length} notes · {rawData.edges.length} liens )}
setSearchFilter(e.target.value)} className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40" /> {searchFilter && ( )}
{/* Canvas */}
{loading && (
)} {error && (

{error}

)} {!loading && !error && graphData.nodes.length > 0 && ( link.dash ? [4, 3] : null} onNodeClick={handleNodeClick} onNodeHover={(node: any) => { if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default' }} onRenderFramePre={paintClusters} nodeCanvasObjectMode={() => 'after'} nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { if (globalScale < 0.7) return const name: string = node.name ?? '' const label = name.length > 20 ? name.slice(0, 18) + '…' : name const fontSize = 11 / globalScale if (fontSize > 18) return ctx.font = `${fontSize}px -apple-system, sans-serif` ctx.textAlign = 'center' ctx.textBaseline = 'top' const r = Math.sqrt(node.val ?? 1) * 5 // White background behind label const tw = ctx.measureText(label).width const lx = node.x - tw / 2 - 2 const ly = node.y + r + 2 ctx.fillStyle = 'rgba(250,250,249,0.85)' ctx.fillRect(lx, ly, tw + 4, fontSize + 2) // Label text ctx.fillStyle = '#334155' ctx.fillText(label, node.x, ly + 1) }} cooldownTicks={80} d3AlphaDecay={0.03} d3VelocityDecay={0.4} /> )} {!loading && !error && graphData.nodes.length === 0 && (

Aucune note trouvée

)} {/* Zoom to fit */} {!loading && graphData.nodes.length > 0 && ( )} {/* Cluster legend (Interactive Notebook Filter) */} {rawData && rawData.clusters && rawData.clusters.length > 0 && (
Carnets {selectedNotebookId && ( )}
{rawData.clusters.map(c => { const isSelected = selectedNotebookId === c.id const isAnySelected = selectedNotebookId !== null const color = colorMap.get(c.id) ?? '#94a3b8' return ( ) })}
)} {/* Legend of relationship types */} {!loading && !error && graphData.nodes.length > 0 && (

Types de liaisons

WikiLink (Manuel)
Memory Echo (IA)
Mention de titre
Tags partagés
Similarité sémantique
)} {/* Note detail panel */} {selectedNode && (

{selectedNode.title}

{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })} {' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}

{selectedNotebookName && ( )}
{previewLoading ? ( ) : (

{plainText(notePreview?.content) || Note vide}

)}
)}
) }