feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-05-24 18:57:33 +00:00
parent e2672cd2c2
commit e881004c77
63 changed files with 5729 additions and 563 deletions

View File

@@ -3,6 +3,8 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { openNotePath } from '@/lib/navigation/open-note'
import {
Loader2,
Network,
@@ -50,8 +52,19 @@ interface NotePreview {
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
export function NoteGraphView() {
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
explicit_link: true,
semantic_echo: true,
title_mention: true,
shared_label: true,
jaccard: false,
}
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
const router = useRouter()
const { notebooks } = useNotebooks()
const containerRef = useRef<HTMLDivElement>(null)
const graphRef = useRef<any>(null)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
@@ -63,6 +76,10 @@ export function NoteGraphView() {
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
const [controlsOpen, setControlsOpen] = useState(!embedded)
const { t } = useLanguage()
@@ -150,26 +167,48 @@ export function NoteGraphView() {
const colorMap = useMemo(() => {
if (!rawData) return new Map<string | null, string>()
const map = new Map<string | null, string>()
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
ids.forEach((id, i) => {
const nb = notebooks.find(n => n.id === id)
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
})
return map
}, [rawData])
}, [rawData, notebooks])
const neighborIds = useMemo(() => {
if (!focusNodeId || !rawData) return null
const ids = new Set<string>([focusNodeId])
for (const edge of rawData.edges) {
if (edge.source === focusNodeId) ids.add(edge.target)
if (edge.target === focusNodeId) ids.add(edge.source)
}
return ids
}, [focusNodeId, rawData])
// ─── Graph data ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
// Filter by notebook
let filtered = selectedNotebookId
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
: rawData.nodes
// Filter by text search
if (neighborIds) {
filtered = filtered.filter(n => neighborIds.has(n.id))
}
filtered = searchFilter.trim()
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: filtered
const filteredIds = new Set(filtered.map(n => n.id))
const visibleEdges = rawData.edges.filter(e => {
const type = e.type as EdgeTypeKey
if (!(type in edgeFilters) || !edgeFilters[type]) return false
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
return filteredIds.has(e.source) && filteredIds.has(e.target)
})
return {
nodes: filtered.map(n => ({
id: n.id,
@@ -179,39 +218,37 @@ export function NoteGraphView() {
notebookId: n.notebookId,
degree: n.degree,
})),
links: rawData.edges
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
.map(e => {
let color = '#e2e8f0'
let width = 0.6
let dash = false
links: visibleEdges.map(e => {
let color = '#cbd5e1'
let width = 2.5
let dash = false
if (e.type === 'explicit_link') {
color = '#10b981' // Green
width = 2.2
} else if (e.type === 'semantic_echo') {
color = '#a78bfa' // Purple
width = 1.8
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b' // Amber/Orange
width = 1.6
} else if (e.type === 'shared_label') {
color = '#3b82f6' // Blue
width = 1.2
}
if (e.type === 'explicit_link') {
color = '#10b981'
width = 4.5
} else if (e.type === 'semantic_echo') {
color = '#8b5cf6'
width = 3.5
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b'
width = 3.2
} else if (e.type === 'shared_label') {
color = '#3b82f6'
width = 2.8
}
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
}
}, [rawData, searchFilter, colorMap, selectedNotebookId])
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
const selectedNotebookName = useMemo(() => {
if (!selectedNode || !rawData) return null
@@ -226,20 +263,39 @@ export function NoteGraphView() {
const now = Date.now()
const last = lastClickRef.current
if (last && last.id === node.id && now - last.time < 350) {
// Double-click → zoom
lastClickRef.current = null
graphRef.current?.centerAt(node.x, node.y, 600)
graphRef.current?.zoom(3, 600)
router.push(openNotePath(node.id))
return
}
lastClickRef.current = { id: node.id, time: now }
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
}, [rawData])
}, [rawData, router])
const handleZoomToFit = useCallback(() => {
graphRef.current?.zoomToFit(400, 50)
}, [])
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
}, [])
// Zoom vers le premier nœud correspondant à la recherche
useEffect(() => {
if (!searchFilter.trim() || graphData.nodes.length === 0) return
const timer = window.setTimeout(() => {
const fg = graphRef.current
if (!fg) return
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
)
if (match?.x != null && match?.y != null) {
fg.centerAt(match.x, match.y, 500)
fg.zoom(2.2, 500)
}
}, 600)
return () => window.clearTimeout(timer)
}, [searchFilter, graphData.nodes.length])
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
@@ -296,14 +352,17 @@ export function NoteGraphView() {
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full bg-[#FAFAF9]">
{/* Header */}
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
{!embedded && (
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
{graphData.links.length !== rawData.edges.length && (
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
)}
</span>
)}
<div className="flex-1" />
@@ -323,6 +382,7 @@ export function NoteGraphView() {
)}
</div>
</div>
)}
{/* Canvas */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
@@ -349,7 +409,10 @@ export function NoteGraphView() {
nodeLabel="name"
linkColor="color"
linkWidth="width"
linkLineDash={(link: any) => link.dash ? [4, 3] : null}
linkOpacity={0.92}
linkDirectionalParticles={2}
linkDirectionalParticleWidth={2.5}
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
onNodeClick={handleNodeClick}
onNodeHover={(node: any) => {
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
@@ -401,16 +464,29 @@ export function NoteGraphView() {
{/* 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-2 max-h-[50vh] overflow-y-auto pr-1">
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1">
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</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} />
{t('graphView.resetFilter')}
</button>
{(selectedNotebookId || focusNodeId) && (
<div className="flex flex-col gap-1">
{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} />
{t('graphView.resetFilter')}
</button>
)}
{focusNodeId && (
<button
onClick={() => setFocusNodeId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFocus')}
</button>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
{rawData.clusters.map(c => {
@@ -439,31 +515,69 @@ export function NoteGraphView() {
</div>
)}
{/* Legend of relationship types */}
{!loading && !error && graphData.nodes.length > 0 && (
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[10px] font-medium text-concrete/70">WikiLink (Manuel)</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
<span className="text-[10px] font-medium text-concrete/70">Memory Echo (IA)</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#f59e0b]" />
<span className="text-[10px] font-medium text-concrete/70">Mention de titre</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#3b82f6]" />
<span className="text-[10px] font-medium text-concrete/70">Tags partagés</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 h-[1px] rounded shrink-0 bg-[#e2e8f0]" />
<span className="text-[10px] font-medium text-concrete/70">Similarité sémantique</span>
{/* Filtres de liens + seuil sémantique */}
{!loading && !error && rawData && (
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]">
<button
type="button"
onClick={() => setControlsOpen(v => !v)}
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
>
<Filter size={12} />
{t('graphView.linkFilters')}
</button>
{controlsOpen && (
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
{([
['explicit_link', t('graphView.edgeTypes.explicitLink')],
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
['title_mention', t('graphView.edgeTypes.titleMention')],
['shared_label', t('graphView.edgeTypes.sharedLabel')],
['jaccard', t('graphView.edgeTypes.jaccard')],
] as [EdgeTypeKey, string][]).map(([key, label]) => (
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
<input
type="checkbox"
checked={edgeFilters[key]}
onChange={() => toggleEdgeFilter(key)}
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
/>
<span>{label}</span>
</label>
))}
{edgeFilters.semantic_echo && (
<div className="pt-1 border-t border-border/30 space-y-1">
<div className="flex items-center justify-between text-[9px] text-concrete/70">
<span>{t('graphView.semanticThreshold')}</span>
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
</div>
<input
type="range"
min={0.3}
max={0.9}
step={0.05}
value={semanticMinWeight}
onChange={e => setSemanticMinWeight(Number(e.target.value))}
className="w-full h-1 accent-indigo-500"
/>
</div>
)}
</div>
)}
</div>
)}
{/* Legend of relationship types (compact) */}
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
</div>
</div>
)}
@@ -605,9 +719,17 @@ export function NoteGraphView() {
</div>
{/* Premium Action Footer */}
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20">
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
<button
onClick={() => router.push(`/notes/${selectedNode.id}`)}
type="button"
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
>
<Sparkles size={12} className="text-indigo-500" />
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
</button>
<button
onClick={() => router.push(openNotePath(selectedNode.id))}
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
>
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />