Add automatic note clustering using density-based algorithm (DBSCAN variant) and bridge notes detection for connecting different thematic clusters. Features: - NoteCluster, ClusterMember, BridgeNote, BridgeSuggestion models - Clustering service with pgvector cosine similarity - Bridge notes detection (notes connecting >=2 clusters) - AI-powered suggestions for missing cluster connections - /insights page with React Flow visualization - Cron endpoint for automatic recalculation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
7.0 KiB
TypeScript
226 lines
7.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
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 [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 || `Cluster ${cluster.clusterId}`}
|
|
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} 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>No clusters to display</p>
|
|
<p className="text-sm mt-2">Create more notes to generate clusters</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 || `Cluster ${selectedCluster}`}
|
|
</h3>
|
|
<p className="text-sm text-gray-600">
|
|
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} 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>Bridge note</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
|
<span>Regular note</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|