Files
Momento/memento-note/components/cluster-visualization.tsx
Antigravity 30da592ba2
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00

228 lines
7.1 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import ReactFlow, {
Node,
Edge,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
ConnectionMode,
} from 'reactflow'
import 'reactflow/dist/style.css'
interface Cluster {
clusterId: number
name?: string
noteIds: string[]
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected: number[]
note?: {
id: string
title: string | null
content: string
}
}
interface ClusterVisualizationProps {
clusters: Cluster[]
bridgeNotes: BridgeNote[]
onNodeClick?: (nodeId: string, type: 'note' | 'cluster') => void
}
// Generate HSL colors for clusters
function generateClusterColor(clusterId: number, totalClusters: number): string {
const hue = (clusterId * 360 / totalClusters) % 360
return `hsl(${hue}, 70%, 60%)`
}
export function ClusterVisualization({
clusters,
bridgeNotes,
onNodeClick
}: ClusterVisualizationProps) {
const { t } = useLanguage()
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
// Build graph from clusters and bridge notes
useEffect(() => {
if (clusters.length === 0) return
const newNodes: Node[] = []
const newEdges: Edge[] = []
const noteToCluster = new Map<string, number>()
const clusterColor = generateClusterColor
// Create cluster nodes and position notes around them
const clusterCenterX = 200
const clusterCenterY = 200
const clusterSpacing = 400
clusters.forEach((cluster, clusterIndex) => {
const cx = clusterCenterX + (clusterIndex % 4) * clusterSpacing
const cy = clusterCenterY + Math.floor(clusterIndex / 4) * clusterSpacing
const color = clusterColor(cluster.clusterId, clusters.length)
// Add cluster label node
newNodes.push({
id: `cluster-${cluster.clusterId}`,
type: 'default',
position: { x: cx, y: cy - 150 },
data: {
label: (
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
{cluster.name || t('clusters.clusterLabel', { id: cluster.clusterId })}
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} {t('clusters.notes')})</span>
</div>
)
},
style: {
background: 'transparent',
border: 'none',
},
className: 'cursor-pointer'
})
// Add note nodes for this cluster
const noteRadius = 120
cluster.noteIds.forEach((noteId, noteIndex) => {
const angle = (noteIndex / cluster.noteIds.length) * 2 * Math.PI
const nx = cx + Math.cos(angle) * noteRadius
const ny = cy + Math.sin(angle) * noteRadius
noteToCluster.set(noteId, cluster.clusterId)
// Check if this is a bridge note
const bridge = bridgeNotes.find(b => b.noteId === noteId)
const isBridge = !!bridge
newNodes.push({
id: noteId,
type: 'default',
position: { x: nx, y: ny },
data: {
label: bridge?.note?.title || noteId.slice(0, 8)
},
style: {
background: isBridge ? '#FFD700' : color,
border: isBridge ? '3px solid #B8860B' : '2px solid rgba(0,0,0,0.1)',
borderRadius: '50%',
width: isBridge ? 50 : 35,
height: isBridge ? 50 : 35,
fontSize: '10px',
fontWeight: isBridge ? 'bold' : 'normal'
},
className: `cursor-pointer transition-transform hover:scale-110 ${isBridge ? 'shadow-lg' : ''}`
})
})
})
// Create edges between bridge notes and their connected clusters
bridgeNotes.forEach(bridge => {
const bridgeNodeId = bridge.noteId
const bridgeNode = newNodes.find(n => n.id === bridgeNodeId)
if (!bridgeNode) return
bridge.clustersConnected.forEach(clusterId => {
const clusterLabelId = `cluster-${clusterId}`
if (clusterLabelId !== bridgeNodeId) {
newEdges.push({
id: `${bridgeNodeId}-${clusterId}`,
source: bridgeNodeId,
target: clusterLabelId,
type: 'smoothstep',
animated: true,
style: {
stroke: '#FFD700',
strokeWidth: 2
},
label: 'bridge'
})
}
})
})
setNodes(newNodes)
setEdges(newEdges)
}, [clusters, bridgeNotes, setNodes, setEdges])
const onNodeClickHandler = useCallback((event: React.MouseEvent, node: Node) => {
const id = node.id
if (id.startsWith('cluster-')) {
const clusterId = parseInt(id.replace('cluster-', ''))
setSelectedCluster(clusterId === selectedCluster ? null : clusterId)
onNodeClick?.(id, 'cluster')
} else {
onNodeClick?.(id, 'note')
}
}, [selectedCluster, onNodeClick])
if (clusters.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
<div className="text-center">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p>{t('clusters.empty')}</p>
<p className="text-sm mt-2">{t('clusters.emptyHint')}</p>
</div>
</div>
)
}
return (
<div className="w-full h-[600px] border rounded-lg overflow-hidden bg-gray-50">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClickHandler}
connectionMode={ConnectionMode.Loose}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
{selectedCluster !== null && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
<h3 className="font-semibold mb-2">
{clusters.find(c => c.clusterId === selectedCluster)?.name || t('clusters.clusterLabel', { id: selectedCluster })}
</h3>
<p className="text-sm text-gray-600">
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} {t('clusters.notes')}
</p>
</div>
)}
<div className="absolute top-4 right-4 bg-white rounded-lg shadow-lg p-3">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
<span>{t('clusters.bridgeNote')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
<span>{t('clusters.regularNote')}</span>
</div>
</div>
</div>
</div>
)
}