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>
This commit is contained in:
874
architectural-grid/src/components/GraphKnowledgeMap.tsx
Normal file
874
architectural-grid/src/components/GraphKnowledgeMap.tsx
Normal file
@@ -0,0 +1,874 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user