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>
875 lines
35 KiB
TypeScript
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>
|
|
);
|
|
};
|