feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user