Files
Momento/architectural-grid/src/components/GraphKnowledgeMap.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

875 lines
35 KiB
TypeScript

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<D3Node> {
source: string | D3Node;
target: string | D3Node;
type: 'wikilink' | 'semantic';
strength: number;
}
export const GraphKnowledgeMap: React.FC<GraphKnowledgeMapProps> = ({
notes,
carnets,
onOpenNote,
onClose
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
// Settings & Toggles
const [showSemanticLinks, setShowSemanticLinks] = useState(true);
const [minSemanticStrength, setMinSemanticStrength] = useState(0.40); // threshold
const [selectedCarnetIds, setSelectedCarnetIds] = useState<string[]>([]);
// Interaction States
const [searchQuery, setSearchQuery] = useState('');
const [hoveredNode, setHoveredNode] = useState<D3Node | null>(null);
const [activeLocalNode, setActiveLocalNode] = useState<D3Node | null>(null);
const [nodeConnections, setNodeConnections] = useState<Set<string>>(new Set());
// D3 Zoom controller ref to trigger programmatically
const d3ZoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | 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<string, Note>();
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<string>();
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<SVGSVGElement, unknown>()
.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<D3Node>(simulationNodes)
.force("link", d3.forceLink<D3Node, any>(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<D3Node>().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<SVGGElement, D3Node>()
.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<string>();
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<string> => {
const list = new Set<string>();
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 (
<div className="flex-1 h-full flex flex-row overflow-hidden relative">
<div
ref={containerRef}
className="flex-1 h-full relative overflow-hidden bg-paper dark:bg-[#0E0E0E]"
style={{
backgroundImage: 'radial-gradient(rgba(120, 119, 198, 0.04) 1px, transparent 1.5px)',
backgroundSize: '24px 24px'
}}
>
{/* Dynamic Header Overlay */}
<div className="absolute top-5 left-5 z-20 flex items-center gap-3">
{activeLocalNode ? (
<button
onClick={handleResetLocalView}
className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/80 hover:border-accent text-accent rounded-xl text-xs font-bold uppercase tracking-wider transition-all shadow-md"
>
<ChevronLeft size={14} className="stroke-[2.5]" />
Graphe Global
</button>
) : onClose ? (
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/80 hover:border-black text-ink rounded-xl text-xs font-bold uppercase tracking-wider transition-all shadow-md"
>
<BookOpen size={14} />
Retour Notes
</button>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/60 rounded-xl">
<Compass size={14} className="text-accent" />
<span className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">Carte Sémantique</span>
</div>
)}
<div className="hidden md:flex items-center bg-zinc-950/5 dark:bg-white/5 border border-border px-3 py-1.5 rounded-xl text-[11px] text-concrete font-medium gap-1.5 shadow-sm">
<span className="font-bold text-ink dark:text-dark-ink">{graphData.nodes.length} Nœuds</span>
<span className="opacity-30">|</span>
<span>{graphData.links.length} Relations</span>
</div>
</div>
{/* Global Hub Search Bar */}
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-20 w-[90%] max-w-[360px]">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
<Search size={14} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-concrete" />
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 rounded-full"
>
<X size={13} />
</button>
)}
</div>
</div>
{/* Zoom controls (bottom right) */}
<div className="absolute bottom-6 right-6 z-20 flex flex-col gap-1.5 bg-white/90 dark:bg-zinc-900/90 backdrop-blur p-1.5 rounded-xl border border-border/60 shadow-xl">
<button
onClick={() => handleZoom('in')}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-ink transition-colors"
title="Zoomer (+)"
>
<Plus size={15} />
</button>
<button
onClick={() => handleZoom('out')}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-ink transition-colors"
title="Dézoomer (-)"
>
<Minus size={15} />
</button>
<div className="h-[1px] bg-border mx-1 my-0.5" />
<button
onClick={() => handleZoom('fit')}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-accent transition-colors"
title="Ajuster la vue"
>
<Maximize2 size={13} />
</button>
</div>
{/* Floating Controls Panel (top right) */}
<div className="absolute top-5 right-5 z-20 w-[300px] hidden lg:block">
<div className="bg-white/95 dark:bg-zinc-950/95 backdrop-blur border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-xl overflow-hidden">
<div className="px-4.5 py-3 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/10 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete flex items-center gap-1.5">
<Sliders size={11} className="text-secondary" />
Paramètres du Graphe
</span>
<button
onClick={() => {
setShowSemanticLinks(true);
setMinSemanticStrength(0.40);
selectAllCarnets();
}}
className="text-[9px] font-bold uppercase text-accent hover:text-accent/80 transition-colors"
title="Rétablir par défaut"
>
Reset
</button>
</div>
<div className="p-4 space-y-4">
{/* Semantic Link Toggle Details */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="semantic-links-toggle" className="text-[11px] font-bold text-ink dark:text-dark-ink flex items-center gap-1.5">
<Sparkles size={12} className="text-indigo-500" />
Liens sémantiques
</label>
<input
id="semantic-links-toggle"
type="checkbox"
checked={showSemanticLinks}
onChange={(e) => setShowSemanticLinks(e.target.checked)}
className="w-4 h-4 text-accent border-gray-300 rounded focus:ring-accent"
/>
</div>
<p className="text-[10px] text-concrete leading-normal pl-5">
Visualiser la couche d'affinité IA générée par embeddings sémantiques (Memory Echo).
</p>
</div>
{/* Slider for semantic filtering threshold - Displayed only if activated */}
{showSemanticLinks && (
<div className="pt-1.5 pb-0.5 space-y-2.5 border-t border-neutral-100 dark:border-neutral-800">
<div className="flex justify-between items-center text-[10px] font-bold text-concrete">
<span>Force minimum sémantique</span>
<span className="font-mono text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 px-1.5 py-0.5 rounded">
{(minSemanticStrength * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] font-mono text-concrete">0.2</span>
<input
type="range"
min="0.20"
max="0.85"
step="0.05"
value={minSemanticStrength}
onChange={(e) => 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"
/>
<span className="text-[9px] font-mono text-concrete font-bold">0.85</span>
</div>
</div>
)}
{/* Filter by Carnets with Checkboxes */}
<div className="pt-3 border-t border-neutral-100 dark:border-neutral-800 space-y-2.5">
<div className="flex items-center justify-between text-[11px] font-bold text-ink dark:text-dark-ink">
<span className="flex items-center gap-1.5">
<Layers size={11} className="text-emerald-500" />
Filtrer par Carnet ({selectedCarnetIds.length})
</span>
<div className="flex items-center gap-2 text-[9px] text-concrete">
<button onClick={selectAllCarnets} className="hover:underline">Tous</button>
<span>•</span>
<button onClick={clearAllCarnets} className="hover:underline">Aucun</button>
</div>
</div>
<div className="space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
{carnets.map(c => {
const isChecked = selectedCarnetIds.includes(c.id);
const carnetColor = CARNET_COLOR_PALETTE[c.id] || DEFAULT_CARNET_COLOR;
return (
<label
key={c.id}
className="flex items-center justify-between text-[10.5px] text-concrete hover:text-ink cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900/40 py-1 px-1.5 rounded transition-colors"
>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: carnetColor }} />
<span className="truncate max-w-[150px]">{c.name}</span>
</span>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleCarnetSelector(c.id)}
className="w-3.5 h-3.5 text-accent border-gray-300 rounded focus:ring-accent"
/>
</label>
);
})}
</div>
</div>
</div>
</div>
</div>
{/* Dynamic Tooltip Hover UI Card (In case of node hovering) */}
<AnimatePresence>
{hoveredNode && !activeLocalNode && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className="absolute bottom-8 left-8 z-30 w-[280px] bg-zinc-950 text-white rounded-xl shadow-2xl p-4.5 border border-zinc-800 space-y-3.5"
>
<div className="space-y-1.5">
<div className="flex items-center gap-2 justify-between">
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded text-white font-mono" style={{ backgroundColor: hoveredNode.color }}>
{hoveredNode.carnetName}
</span>
<span className="text-[9.5px] font-mono text-zinc-400">
Modifié le : {hoveredNode.date}
</span>
</div>
<h4 className="text-xs font-bold leading-tight line-clamp-2 text-zinc-100 font-serif">
{hoveredNode.title}
</h4>
</div>
{/* Micro Metrics stats */}
<div className="grid grid-cols-2 gap-2 border-t border-zinc-900 pt-3">
<div className="bg-zinc-900/50 p-2 rounded-lg text-center">
<span className="text-[9px] block text-zinc-500 uppercase tracking-wider">Connexions</span>
<p className="text-xs font-black text-indigo-400">{hoveredNode.degree}</p>
</div>
<div className="bg-zinc-900/50 p-2 rounded-lg text-center">
<span className="text-[9px] block text-zinc-500 uppercase tracking-wider">Tags détectés</span>
<p className="text-xs font-black text-cyan-400">{hoveredNode.tags.length || 0}</p>
</div>
</div>
<div className="text-[9.5px] text-zinc-400 font-medium italic flex items-center justify-center gap-1">
<span>Cliquez pour isoler / modifier</span>
</div>
</motion.div>
)}
</AnimatePresence>
{/* SVG Core Render canvas */}
<svg ref={svgRef} className="w-full h-full" />
</div>
{/* State D: Note focus right panel slider (280px width) */}
<AnimatePresence>
{activeLocalNode && (
<motion.div
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 180 }}
className="w-[320px] bg-white dark:bg-neutral-950 border-l border-neutral-200 dark:border-neutral-800 shadow-2xl z-20 flex flex-col justify-between"
>
{/* Panel header and close button */}
<div className="p-5 border-b border-neutral-100 dark:border-neutral-800">
<div className="flex items-center justify-between mb-4.5">
<div className="flex items-center gap-2 text-[10px] uppercase font-bold tracking-widest text-[#4f46e5]">
<Sparkles size={12} className="text-indigo-500 animate-[pulse_3s_infinite]" />
Aperçu de Note
</div>
<button
onClick={handleResetLocalView}
className="p-1 px-2.5 rounded hover:bg-neutral-50 dark:hover:bg-neutral-900 text-[10.5px] font-bold tracking-tight text-concrete hover:text-ink select-none border border-neutral-200 dark:border-neutral-800"
>
Fermer
</button>
</div>
{/* Note details */}
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[9.5px] font-bold text-zinc-400">
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: activeLocalNode.color }} />
<span className="uppercase tracking-wider truncate max-w-[200px]">{activeLocalNode.carnetName}</span>
</div>
<h3 className="text-sm font-black text-ink dark:text-dark-ink font-serif leading-tight">
{activeLocalNode.title}
</h3>
<p className="text-[10px] text-concrete font-mono flex items-center gap-1">
<Calendar size={10} />
Dernier update : {activeLocalNode.date}
</p>
</div>
</div>
{/* Snippet body content */}
<div className="flex-1 p-5 overflow-y-auto space-y-4">
<div className="space-y-1">
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">Résumé / Extrait</span>
<p className="text-xs text-ink/80 dark:text-dark-ink/80 italic leading-relaxed bg-[#FAF9F5]/40 dark:bg-neutral-900 p-3.5 rounded-xl border border-[#FAF9F5] dark:border-neutral-900 select-all">
"{activeLocalNode.snippet}"
</p>
</div>
{/* Relationship listing */}
<div className="space-y-2">
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">
Éléments connectés ({getLocalNodeNeighbors(activeLocalNode.id).size - 1})
</span>
<div className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1">
{notes
.filter(n => n.id !== activeLocalNode.id && getLocalNodeNeighbors(activeLocalNode.id).has(n.id))
.map(neighbor => {
return (
<div
key={neighbor.id}
onClick={() => {
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"
>
<span className="font-semibold text-ink dark:text-dark-ink truncate max-w-[170px] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: CARNET_COLOR_PALETTE[neighbor.carnetId] || DEFAULT_CARNET_COLOR }} />
{neighbor.title}
</span>
<span className="text-[8px] font-bold uppercase tracking-wider text-concrete group-hover:text-accent group-hover:underline">
Séléctionner
</span>
</div>
);
})}
</div>
</div>
{/* Tags panel detail */}
{activeLocalNode.tags && activeLocalNode.tags.length > 0 && (
<div className="space-y-2">
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">Index de tags</span>
<div className="flex flex-wrap gap-1">
{activeLocalNode.tags.map((t, idx) => (
<span
key={idx}
className="text-[9px] font-semibold uppercase tracking-wider border border-border bg-neutral-50/40 text-concrete px-2 py-0.5 rounded-full"
>
{t.label}
</span>
))}
</div>
</div>
)}
</div>
{/* CTA action bottom block */}
<div className="p-5 border-t border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/10 flex flex-col gap-2.5">
<button
onClick={() => onOpenNote(activeLocalNode.id)}
className="w-full py-3.5 bg-ink text-paper dark:bg-neutral-50 dark:text-zinc-950 rounded-xl text-xs font-bold uppercase tracking-widest hover:opacity-95 transition-all text-center flex items-center justify-center gap-1.5 shadow-xl shadow-black/10 scale-100 active:scale-95"
>
<FileText size={13} />
Ouvrir la note
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};