feat(graph): add interactive notebook filters and detail panel tags to graph view

This commit is contained in:
Antigravity
2026-05-23 08:34:39 +00:00
parent d589b8aa7e
commit 4e8f45deae
2 changed files with 72 additions and 11 deletions

View File

@@ -55,6 +55,8 @@ Cela donne à la vue en graphe un aspect incomplet et décousu, limitant grandem
**Execution:**
- [x] `memento-note/app/api/graph/route.ts` -- Modifier la route pour interroger en parallèle `prisma.noteLink` et `prisma.memoryEchoInsight`, filtrer les éléments invalides/corrompus/supprimés, et les injecter avec les types `explicit_link` et `semantic_echo`.
- [x] `memento-note/components/note-graph-view.tsx` -- Enrichir la logique de mappage des arêtes du graphe pour attribuer des styles visuels premium à chaque type de lien (WikiLinks en vert émeraude épais et plein, Échos sémantiques IA en violet pointillés, etc.).
- [x] `memento-note/components/note-graph-view.tsx` -- Rendre la légende des carnets (clusters) interactive : ajouter un état `selectedNotebookId` pour filtrer à la volée les nœuds et arêtes du graphe. Styliser les boutons actifs/inactifs de manière premium avec des micro-animations.
- [x] `memento-note/components/note-graph-view.tsx` -- Afficher le nom du carnet dans le panneau latéral de détail de la note sous forme de tag interactif cliquable pour isoler instantanément le carnet sélectionné.
**Acceptance Criteria:**
- **Given** une note A contenant un WikiLink vers une note B (`NoteLink` enregistré) et une note C sémantiquement proche de la note A (`MemoryEchoInsight` enregistré)
@@ -106,3 +108,11 @@ Pour les pointillés (dash) dans `react-force-graph-2d`, nous adapterons l'objet
- Rendu du panneau de légende des liaisons dans le coin inférieur gauche pour une expérience utilisateur premium.
[`note-graph-view.tsx:340`](../../memento-note/components/note-graph-view.tsx#L340)
**Interactive Filtering & Notebook Navigation**
- Boutons de légende interactifs pour isoler ou réinitialiser le filtre par carnet à la volée.
[`note-graph-view.tsx:342`](../../memento-note/components/note-graph-view.tsx#L342)
- Tag cliquable dans le panneau latéral de détails pour isoler instantanément le carnet associé.
[`note-graph-view.tsx:421`](../../memento-note/components/note-graph-view.tsx#L421)

View File

@@ -27,6 +27,7 @@ export function NoteGraphView() {
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
@@ -89,9 +90,17 @@ export function NoteGraphView() {
// ─── Graph data ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
const filtered = searchFilter.trim()
? rawData.nodes.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
// Filter by notebook
let filtered = selectedNotebookId
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
: rawData.nodes
// Filter by text search
filtered = searchFilter.trim()
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: filtered
const filteredIds = new Set(filtered.map(n => n.id))
return {
nodes: filtered.map(n => ({
@@ -134,7 +143,12 @@ export function NoteGraphView() {
}
}),
}
}, [rawData, searchFilter, colorMap])
}, [rawData, searchFilter, colorMap, selectedNotebookId])
const selectedNotebookName = useMemo(() => {
if (!selectedNode || !rawData) return null
return rawData.clusters.find(c => c.id === selectedNode.notebookId)?.name ?? null
}, [selectedNode, rawData])
// ─── Handlers (double-click via timer) ──────────────────────────────────
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
@@ -325,15 +339,43 @@ export function NoteGraphView() {
</button>
)}
{/* Cluster legend */}
{/* Cluster legend (Interactive Notebook Filter) */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1.5 max-h-[50vh] overflow-y-auto pr-1">
{rawData.clusters.map(c => (
<div key={c.id} className="flex items-center gap-1.5 px-2 py-0.5 bg-white/90 border border-border/30 rounded-full shadow-sm">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(c.id) ?? '#94a3b8' }} />
<span className="text-[9px] font-medium text-concrete/70 whitespace-nowrap">{c.name}</span>
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">Carnets</span>
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
Réinitialiser
</button>
)}
<div className="flex flex-col gap-1.5">
{rawData.clusters.map(c => {
const isSelected = selectedNotebookId === c.id
const isAnySelected = selectedNotebookId !== null
const color = colorMap.get(c.id) ?? '#94a3b8'
return (
<button
key={c.id}
onClick={() => setSelectedNotebookId(isSelected ? null : c.id)}
className={`flex items-center gap-2 px-3 py-1 bg-white border rounded-full shadow-sm transition-all duration-200 hover:scale-105 w-fit text-left ${
isSelected
? 'border-indigo-500 ring-2 ring-indigo-500/20 font-semibold'
: isAnySelected
? 'border-border/30 opacity-40 hover:opacity-100'
: 'border-border/30 hover:border-concrete/40'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] text-concrete/80 whitespace-nowrap">{c.name}</span>
{isSelected && <X size={10} className="text-concrete/40 ml-0.5 shrink-0" />}
</button>
)
})}
</div>
))}
</div>
)}
@@ -376,6 +418,15 @@ export function NoteGraphView() {
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
{' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}
</p>
{selectedNotebookName && (
<button
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
className="mt-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-slate-100 hover:bg-slate-200 text-concrete transition-colors border border-border/30 hover:scale-105 transition-all"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
{selectedNotebookName}
</button>
)}
</div>
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
<X size={14} />