'use client' import { useEffect, useState, useMemo, useCallback, useRef } from 'react' import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import { useNotebooks } from '@/context/notebooks-context' import { openNotePath } from '@/lib/navigation/open-note' import { Loader2, Network, Filter, X, ExternalLink, Maximize2, Calendar, Clock, Link2, FileText, Check, Tag, Sparkles, ChevronRight, BookOpen } from 'lucide-react' import DOMPurify from 'isomorphic-dompurify' import { markdownToHtml } from '@/lib/markdown-to-html' import { useLanguage } from '@/lib/i18n' import { LabelBadge } from './label-badge' import { NoteChecklist } from './note-checklist' const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), { ssr: false, loading: () =>
}) 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 | null content: string createdAt: string | Date updatedAt?: string | Date labels?: string[] | null type?: 'text' | 'markdown' | 'richtext' | 'checklist' checkItems?: { id: string; text: string; checked: boolean }[] | null isMarkdown?: boolean } const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148'] type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard' const DEFAULT_EDGE_FILTERS: Record = { explicit_link: true, semantic_echo: true, title_mention: true, shared_label: true, jaccard: false, } export function NoteGraphView({ embedded = false }: { embedded?: boolean }) { const router = useRouter() const { notebooks } = useNotebooks() 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) const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS) const [semanticMinWeight, setSemanticMinWeight] = useState(0.45) const [focusNodeId, setFocusNodeId] = useState(null) const [controlsOpen, setControlsOpen] = useState(!embedded) const { t } = useLanguage() const plainText = useCallback((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), []) const htmlContent = useMemo(() => { if (!notePreview?.content) return '' const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes(' { if (!notePreview?.content) return 0 const text = plainText(notePreview.content) return text.split(/\s+/).filter(Boolean).length }, [notePreview, plainText]) const charCount = useMemo(() => { if (!notePreview?.content) return 0 return plainText(notePreview.content).length }, [notePreview, plainText]) // ─── 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))] as string[] ids.forEach((id, i) => { const nb = notebooks.find(n => n.id === id) map.set(id, nb?.color || PALETTE[i % PALETTE.length]) }) return map }, [rawData, notebooks]) const neighborIds = useMemo(() => { if (!focusNodeId || !rawData) return null const ids = new Set([focusNodeId]) for (const edge of rawData.edges) { if (edge.source === focusNodeId) ids.add(edge.target) if (edge.target === focusNodeId) ids.add(edge.source) } return ids }, [focusNodeId, rawData]) // ─── Graph data ─────────────────────────────────────────────────────────── const graphData = useMemo(() => { if (!rawData) return { nodes: [], links: [] } let filtered = selectedNotebookId ? rawData.nodes.filter(n => n.notebookId === selectedNotebookId) : rawData.nodes if (neighborIds) { filtered = filtered.filter(n => neighborIds.has(n.id)) } filtered = searchFilter.trim() ? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase())) : filtered const filteredIds = new Set(filtered.map(n => n.id)) const visibleEdges = rawData.edges.filter(e => { const type = e.type as EdgeTypeKey if (!(type in edgeFilters) || !edgeFilters[type]) return false if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false return filteredIds.has(e.source) && filteredIds.has(e.target) }) 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: visibleEdges.map(e => { let color = '#cbd5e1' let width = 2.5 let dash = false if (e.type === 'explicit_link') { color = '#10b981' width = 4.5 } else if (e.type === 'semantic_echo') { color = '#8b5cf6' width = 3.5 dash = true } else if (e.type === 'title_mention') { color = '#f59e0b' width = 3.2 } else if (e.type === 'shared_label') { color = '#3b82f6' width = 2.8 } return { source: e.source, target: e.target, color, width, dash, type: e.type, } }), } }, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds]) 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) { lastClickRef.current = null router.push(openNotePath(node.id)) return } lastClickRef.current = { id: node.id, time: now } setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null) }, [rawData, router]) const handleZoomToFit = useCallback(() => { graphRef.current?.zoomToFit(400, 50) }, []) const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => { setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] })) }, []) // Zoom vers le premier nœud correspondant à la recherche useEffect(() => { if (!searchFilter.trim() || graphData.nodes.length === 0) return const timer = window.setTimeout(() => { const fg = graphRef.current if (!fg) return const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) => (n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase()) ) if (match?.x != null && match?.y != null) { fg.centerAt(match.x, match.y, 500) fg.zoom(2.2, 500) } }, 600) return () => window.clearTimeout(timer) }, [searchFilter, graphData.nodes.length]) // ─── 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 (
{!embedded && (

{t('graphView.title')}

{rawData && ( {t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })} {graphData.links.length !== rawData.edges.length && ( <> · {t('graphView.visibleConnections', { count: graphData.links.length })} )} )}
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 ? [6, 4] : 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 && (

{t('graphView.noNotesFound')}

)} {/* Zoom to fit */} {!loading && graphData.nodes.length > 0 && ( )} {/* Cluster legend (Interactive Notebook Filter) */} {rawData && rawData.clusters && rawData.clusters.length > 0 && (
{t('graphView.notebooks')} {(selectedNotebookId || focusNodeId) && (
{selectedNotebookId && ( )} {focusNodeId && ( )}
)}
{rawData.clusters.map(c => { const isSelected = selectedNotebookId === c.id const isAnySelected = selectedNotebookId !== null const color = colorMap.get(c.id) ?? '#94a3b8' return ( ) })}
)} {/* Filtres de liens + seuil sémantique */} {!loading && !error && rawData && (
{controlsOpen && (

{t('graphView.relationshipTypes')}

{([ ['explicit_link', t('graphView.edgeTypes.explicitLink')], ['semantic_echo', t('graphView.edgeTypes.semanticEcho')], ['title_mention', t('graphView.edgeTypes.titleMention')], ['shared_label', t('graphView.edgeTypes.sharedLabel')], ['jaccard', t('graphView.edgeTypes.jaccard')], ] as [EdgeTypeKey, string][]).map(([key, label]) => ( ))} {edgeFilters.semantic_echo && (
{t('graphView.semanticThreshold')} {Math.round(semanticMinWeight * 100)}%
setSemanticMinWeight(Number(e.target.value))} className="w-full h-1 accent-indigo-500" />
)}
)}
)} {/* Legend of relationship types (compact) */} {!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
{t('graphView.edgeTypes.explicitLink')}
{t('graphView.edgeTypes.semanticEcho')}
)} {/* Note detail panel */} {selectedNode && (
{/* Header */}
{selectedNotebookName && ( )}

{selectedNode.title || {t('notes.untitled')}}

{/* Quick Metadata Info */}
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
{!previewLoading && notePreview && ( <>
{t('graphView.preview.words', { count: wordCount })}
{notePreview.updatedAt && (
{t('graphView.preview.updated')}{' '} {new Date(notePreview.updatedAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
)} )}
{/* Scrollable Content */}
{previewLoading ? ( /* Sleek Skeleton Loader */
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (

{t('graphView.preview.emptyNote')}

) : ( <> {/* Note Content Renderer */} {notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
{}} />
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
) : (
)} {/* Refined Tags list */} {Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
{t('graphView.preview.tags')}
{notePreview.labels.map((label: string) => ( ))}
)} )}
{/* Premium Action Footer */}
)}
) }