import React, { useEffect, useRef, useState, useMemo } from 'react'; import * as d3 from 'd3'; import { motion, AnimatePresence } from 'motion/react'; import { Note, Carnet, Tag } from '../types'; import { Network, Search, Sliders, HelpCircle, X, Filter, Compass, BookOpen, Eye, Sparkles, RefreshCw, Plus, Minus, Maximize2, ChevronLeft, Calendar, Layers, FileText } from 'lucide-react'; interface GraphKnowledgeMapProps { notes: Note[]; carnets: Carnet[]; onOpenNote: (noteId: string) => void; onClose?: () => void; } // 7 Gorgeous colors corresponding to the carnets palette const CARNET_COLOR_PALETTE: { [key: string]: string } = { '1': '#D97706', // Daily Notes - Warm Amber '2': '#059669', // Project: Neo - Soft Emerald '3': '#4F46E5', // Shared Docs - Rich Indigo '4': '#0891B2', // Architecture Research - Clean Cyan '5': '#EA580C', // History of Architecture - Deep Orange '6': '#DB2777', // Modernism - Vibrant Rose '7': '#65A30D', // Sustainable Design - Cool Lime }; const DEFAULT_CARNET_COLOR = '#71717A'; // Zinc interface D3Node extends d3.SimulationNodeDatum { id: string; title: string; carnetId: string; carnetName: string; color: string; date: string; snippet: string; tags: Tag[]; degree: number; } interface D3Link extends d3.SimulationLinkDatum { source: string | D3Node; target: string | D3Node; type: 'wikilink' | 'semantic'; strength: number; } export const GraphKnowledgeMap: React.FC = ({ notes, carnets, onOpenNote, onClose }) => { const containerRef = useRef(null); const svgRef = useRef(null); // Settings & Toggles const [showSemanticLinks, setShowSemanticLinks] = useState(true); const [minSemanticStrength, setMinSemanticStrength] = useState(0.40); // threshold const [selectedCarnetIds, setSelectedCarnetIds] = useState([]); // Interaction States const [searchQuery, setSearchQuery] = useState(''); const [hoveredNode, setHoveredNode] = useState(null); const [activeLocalNode, setActiveLocalNode] = useState(null); const [nodeConnections, setNodeConnections] = useState>(new Set()); // D3 Zoom controller ref to trigger programmatically const d3ZoomRef = useRef | null>(null); // Initialize carnet filters with all carnets on mount useEffect(() => { setSelectedCarnetIds(carnets.map(c => c.id)); }, [carnets]); // Static list of explicit links (Wikilinks) const explicitWikiLinks = useMemo(() => { return [ { source: 'n1', target: 'n1-b' }, { source: 'n3', target: 'n3-b' }, { source: 'bridge-1', target: 'n1' }, { source: 'bridge-1', target: 'n2' }, ]; }, []); // Filter and process notes and carnets const filteredNotes = useMemo(() => { return notes.filter(n => { // Exclude trashed/deleted notes if (n.isDeleted) return false; // Filter by selected carnets return selectedCarnetIds.includes(n.carnetId); }); }, [notes, selectedCarnetIds]); // Compute all links based on state (Wikilinks + Semantic if enabled) const graphData = useMemo(() => { const noteMap = new Map(); filteredNotes.forEach(n => noteMap.set(n.id, n)); const nodes: D3Node[] = filteredNotes.map(n => { const carnet = carnets.find(c => c.id === n.carnetId); return { id: n.id, title: n.title, carnetId: n.carnetId, carnetName: carnet?.name || 'Carnet Inconnu', color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR, date: n.date, snippet: n.content.split('.').slice(0, 3).join('.') + '.', tags: n.tags || [], degree: 0, // calculated below x: undefined, y: undefined }; }); const links: D3Link[] = []; // 1. Add Explicit Wikilinks if both target and source are inside filtered list explicitWikiLinks.forEach(link => { if (noteMap.has(link.source) && noteMap.has(link.target)) { links.push({ source: link.source, target: link.target, type: 'wikilink', strength: 1.0 }); } }); // 2. Add Semantic Connections (Memory Echo) based on embedding similarities if (showSemanticLinks) { for (let i = 0; i < filteredNotes.length; i++) { for (let j = i + 1; j < filteredNotes.length; j++) { const ni = filteredNotes[i]; const nj = filteredNotes[j]; if (ni.embedding && nj.embedding) { // Cosine vector similarity approximation / Euclidean inverse mapping const dist = Math.sqrt( Math.pow(ni.embedding[0] - nj.embedding[0], 2) + Math.pow(ni.embedding[1] - nj.embedding[1], 2) ); // Translate distance into similarity standard (0.0 - 1.0) const similarity = Math.max(0, 1 - dist * 0.7); if (similarity >= minSemanticStrength) { // Avoid duplicate links with explicit ones to keep display clean const hasExplicit = explicitWikiLinks.some( ex => (ex.source === ni.id && ex.target === nj.id) || (ex.source === nj.id && ex.target === ni.id) ); if (!hasExplicit) { links.push({ source: ni.id, target: nj.id, type: 'semantic', strength: similarity }); } } } } } } // Calculate node connectivity degrees nodes.forEach(node => { const connectionsCount = links.filter(l => l.source === node.id || l.target === node.id || (typeof l.source === 'object' && (l.source as any).id === node.id) || (typeof l.target === 'object' && (l.target as any).id === node.id) ).length; node.degree = connectionsCount; }); return { nodes, links }; }, [filteredNotes, carnets, showSemanticLinks, minSemanticStrength, selectedCarnetIds, explicitWikiLinks]); // Handle Note connection highlights during hover useEffect(() => { if (!hoveredNode) { setNodeConnections(new Set()); return; } const connected = new Set(); connected.add(hoveredNode.id); graphData.links.forEach((l: any) => { const srcId = typeof l.source === 'object' ? l.source.id : l.source; const tgtId = typeof l.target === 'object' ? l.target.id : l.target; if (srcId === hoveredNode.id) { connected.add(tgtId); } else if (tgtId === hoveredNode.id) { connected.add(srcId); } }); setNodeConnections(connected); }, [hoveredNode, graphData.links]); // Main D3 force layout rendering loop 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(); // Base containment group const mainGroup = svg.append("g"); // Configure zooming behaviors const zoomBehavior = d3.zoom() .scaleExtent([0.15, 5]) .on("zoom", (event) => { mainGroup.attr("transform", event.transform); }); d3ZoomRef.current = zoomBehavior; svg.call(zoomBehavior); // D3 nodes and links references mapped to copyable arrays const simulationNodes = JSON.parse(JSON.stringify(graphData.nodes)) as D3Node[]; const simulationLinks = graphData.links.map(l => ({ source: l.source, target: l.target, type: l.type, strength: l.strength })) as D3Link[]; // Build the force simulation const simulation = d3.forceSimulation(simulationNodes) .force("link", d3.forceLink(simulationLinks) .id(d => d.id) .distance(d => d.type === 'wikilink' ? 100 : 140) ) .force("charge", d3.forceManyBody().strength(-240)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("collision", d3.forceCollide().radius(d => { // Size proportional to connections: min 8px, max 20px const rad = 8 + Math.min(d.degree * 2.5, 12); return rad + 24; })); // Draw Links const linkGroup = mainGroup.append("g") .attr("class", "links-layer"); const link = linkGroup.selectAll("line") .data(simulationLinks) .enter() .append("line") .attr("stroke", d => d.type === 'semantic' ? '#4f46e5' : '#18181b') .attr("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18) .attr("stroke-width", d => d.type === 'semantic' ? 1.2 : 1.5) .attr("stroke-dasharray", d => d.type === 'semantic' ? '4,4' : 'none'); // Draw Nodes const nodeGroup = mainGroup.append("g") .attr("class", "nodes-layer"); const node = nodeGroup.selectAll(".node") .data(simulationNodes) .enter() .append("g") .attr("class", "node cursor-pointer") .on("click", (event, d) => { event.stopPropagation(); handleSelectNode(d); }) .on("mouseenter", (event, d) => { setHoveredNode(d); }) .on("mouseleave", () => { setHoveredNode(null); }) .call(d3.drag() .on("start", dragStarted) .on("drag", dragged) .on("end", dragEnded) as any); // Create central circles node.append("circle") .attr("r", d => 6 + Math.min(d.degree * 1.5, 9)) .attr("fill", d => d.color) .attr("stroke", "rgba(255,255,255,0.95)") .attr("stroke-width", 2) .attr("class", "transition-all duration-300 dark:stroke-zinc-950") .style("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.1))"); // Text labels overlay node.append("text") .attr("dy", d => 14 + Math.min(d.degree * 1.5, 9) + 4) .attr("text-anchor", "middle") .attr("class", "text-[10px] sm:text-[11px] font-sans font-semibold tracking-tight fill-zinc-850 dark:fill-zinc-300 select-none pointer-events-none") .text(d => d.title.length > 22 ? d.title.substring(0, 20) + "..." : d.title) .style("opacity", d => (d.degree > 2 || d.title.toLowerCase().includes(searchQuery.toLowerCase()) && searchQuery) ? 1 : 0.65); // Search query search highlight ring if (searchQuery) { node.filter(d => d.title.toLowerCase().includes(searchQuery.toLowerCase())) .append("circle") .attr("r", d => 14 + Math.min(d.degree * 1.5, 9)) .attr("fill", "none") .attr("stroke", "#06b6d4") .attr("stroke-width", 2) .attr("stroke-dasharray", "3,1") .attr("class", "animate-[spin_20s_linear_infinite]"); } // Node active local neighbor rings if (activeLocalNode) { const activeConns = getLocalNodeNeighbors(activeLocalNode.id); node.style("opacity", d => { return activeConns.has(d.id) ? 1.0 : 0.15; }); link.style("stroke-opacity", (l: any) => { const srcId = l.source.id; const tgtId = l.target.id; return (activeConns.has(srcId) && activeConns.has(tgtId)) ? 0.75 : 0.05; }); // Highlight the focused local hub node with a neat accent circle node.filter(d => d.id === activeLocalNode.id) .append("circle") .attr("r", d => 16 + Math.min(d.degree * 1.5, 9)) .attr("fill", "none") .attr("stroke", "rgba(79, 70, 229, 0.4)") .attr("stroke-width", 1.5) .attr("stroke-opacity", 0.9); } // Node hover lighting state else if (hoveredNode) { const hoveredConns = new Set(); hoveredConns.add(hoveredNode.id); graphData.links.forEach((l: any) => { const srcId = typeof l.source === 'object' ? l.source.id : l.source; const tgtId = typeof l.target === 'object' ? l.target.id : l.target; if (srcId === hoveredNode.id) { hoveredConns.add(tgtId); } else if (tgtId === hoveredNode.id) { hoveredConns.add(srcId); } }); // Subdue unconnected elements to 20% opacity node.style("opacity", d => hoveredConns.has(d.id) ? 1.0 : 0.20); link.style("stroke-opacity", (l: any) => { const srcId = l.source.id; const tgtId = l.target.id; return (srcId === hoveredNode.id || tgtId === hoveredNode.id) ? 0.8 : 0.05; }); // Hover scale update for primary node.filter(d => d.id === hoveredNode.id) .select("circle") .attr("transform", "scale(1.3)"); } // Normal / Base state else { node.style("opacity", 1.0); link.style("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18); } // Run ticks 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 on local node view trigger if (activeLocalNode && width && height) { const targetNodeCopy = simulationNodes.find(n => n.id === activeLocalNode.id); if (targetNodeCopy) { // Step ticker synchronously to finalize force state layout for (let i = 0; i < 40; ++i) simulation.tick(); const x = targetNodeCopy.x || width / 2; const y = targetNodeCopy.y || height / 2; svg.transition() .duration(850) .ease(d3.easeCubicOut) .call( zoomBehavior.transform, d3.zoomIdentity.translate(width / 2, height / 2).scale(1.65).translate(-x, -y) ); } } else { // Re-center whole graph svg.transition() .duration(800) .ease(d3.easeCubicOut) .call(zoomBehavior.transform, d3.zoomIdentity); } function dragStarted(event: any, d: any) { if (!event.active) simulation.alphaTarget(0.25).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event: any, d: any) { d.fx = event.x; d.fy = event.y; } function dragEnded(event: any, d: any) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } return () => { simulation.stop(); }; }, [graphData, showSemanticLinks, minSemanticStrength, searchQuery, activeLocalNode, hoveredNode]); // Compute local neighbors const getLocalNodeNeighbors = (nodeId: string): Set => { const list = new Set(); list.add(nodeId); graphData.links.forEach(l => { if (l.source === nodeId) { list.add(typeof l.target === 'object' ? (l.target as any).id : l.target); } else if (l.target === nodeId) { list.add(typeof l.source === 'object' ? (l.source as any).id : l.source); } }); return list; }; const handleSelectNode = (node: D3Node) => { setActiveLocalNode(node); }; const handleResetLocalView = () => { setActiveLocalNode(null); }; const handleZoom = (direction: 'in' | 'out' | 'fit') => { if (!svgRef.current || !d3ZoomRef.current) return; const svg = d3.select(svgRef.current); if (direction === 'fit') { svg.transition().duration(500).call(d3ZoomRef.current.transform, d3.zoomIdentity); } else { const factor = direction === 'in' ? 1.3 : 1 / 1.3; svg.transition().duration(400).call(d3ZoomRef.current.scaleBy, factor); } }; const toggleCarnetSelector = (carnetId: string) => { setSelectedCarnetIds(prev => prev.includes(carnetId) ? prev.filter(id => id !== carnetId) : [...prev, carnetId] ); }; const selectAllCarnets = () => { setSelectedCarnetIds(carnets.map(c => c.id)); }; const clearAllCarnets = () => { setSelectedCarnetIds([]); }; return (
{/* Dynamic Header Overlay */}
{activeLocalNode ? ( ) : onClose ? ( ) : (
Carte Sémantique
)}
{graphData.nodes.length} Nœuds | {graphData.links.length} Relations
{/* Global Hub Search Bar */}
setSearchQuery(e.target.value)} placeholder="Chercher une note dans le graphe sémantique..." className="w-full text-xs pl-9 pr-8 py-2.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/95 dark:bg-zinc-950/95 placeholder-concrete/60 shadow-lg outline-none focus:border-accent focus:ring-1 focus:ring-accent/10 transition-all text-ink dark:text-dark-ink font-medium" /> {searchQuery && ( )}
{/* Zoom controls (bottom right) */}
{/* Floating Controls Panel (top right) */}
Paramètres du Graphe
{/* Semantic Link Toggle Details */}
setShowSemanticLinks(e.target.checked)} className="w-4 h-4 text-accent border-gray-300 rounded focus:ring-accent" />

Visualiser la couche d'affinité IA générée par embeddings sémantiques (Memory Echo).

{/* Slider for semantic filtering threshold - Displayed only if activated */} {showSemanticLinks && (
Force minimum sémantique {(minSemanticStrength * 100).toFixed(0)}%
0.2 setMinSemanticStrength(parseFloat(e.target.value))} className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-indigo-600" /> 0.85
)} {/* Filter by Carnets with Checkboxes */}
Filtrer par Carnet ({selectedCarnetIds.length})
{carnets.map(c => { const isChecked = selectedCarnetIds.includes(c.id); const carnetColor = CARNET_COLOR_PALETTE[c.id] || DEFAULT_CARNET_COLOR; return ( ); })}
{/* Dynamic Tooltip Hover UI Card (In case of node hovering) */} {hoveredNode && !activeLocalNode && (
{hoveredNode.carnetName} Modifié le : {hoveredNode.date}

{hoveredNode.title}

{/* Micro Metrics stats */}
Connexions

{hoveredNode.degree}

Tags détectés

{hoveredNode.tags.length || 0}

Cliquez pour isoler / modifier
)}
{/* SVG Core Render canvas */}
{/* State D: Note focus right panel slider (280px width) */} {activeLocalNode && ( {/* Panel header and close button */}
Aperçu de Note
{/* Note details */}
{activeLocalNode.carnetName}

{activeLocalNode.title}

Dernier update : {activeLocalNode.date}

{/* Snippet body content */}
Résumé / Extrait

"{activeLocalNode.snippet}"

{/* Relationship listing */}
Éléments connectés ({getLocalNodeNeighbors(activeLocalNode.id).size - 1})
{notes .filter(n => n.id !== activeLocalNode.id && getLocalNodeNeighbors(activeLocalNode.id).has(n.id)) .map(neighbor => { return (
{ const foundNode = graphData.nodes.find(v => v.id === neighbor.id); if (foundNode) handleSelectNode(foundNode); }} className="flex items-center justify-between text-[10px] p-2 bg-neutral-50 dark:bg-neutral-900/60 rounded-xl hover:bg-neutral-100 cursor-pointer border border-transparent hover:border-border transition-colors group" > {neighbor.title} Séléctionner
); })}
{/* Tags panel detail */} {activeLocalNode.tags && activeLocalNode.tags.length > 0 && (
Index de tags
{activeLocalNode.tags.map((t, idx) => ( {t.label} ))}
)}
{/* CTA action bottom block */}
)}
); };