Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
6.8 KiB
TypeScript
245 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import * as d3 from 'd3'
|
|
|
|
// Force to group nodes by cluster
|
|
function forceCluster() {
|
|
let nodes: any[] = []
|
|
let clusters: Map<string | number, { x: number; y: number }> = new Map()
|
|
|
|
function force(alpha: number) {
|
|
// Calculate cluster centers
|
|
clusters.clear()
|
|
for (const node of nodes) {
|
|
const clusterId = node.clusterId
|
|
if (!clusters.has(clusterId)) {
|
|
clusters.set(clusterId, { x: 0, y: 0, count: 0 })
|
|
}
|
|
const center = clusters.get(clusterId)!
|
|
center.x += node.x
|
|
center.y += node.y
|
|
center.count = (center.count || 0) + 1
|
|
}
|
|
|
|
// Average positions
|
|
for (const [clusterId, center] of clusters.entries()) {
|
|
center.x /= center.count || 1
|
|
center.y /= center.count || 1
|
|
}
|
|
|
|
// Move nodes toward their cluster center
|
|
for (const node of nodes) {
|
|
const clusterCenter = clusters.get(node.clusterId)
|
|
if (clusterCenter && clusterCenter.count > 1) {
|
|
const targetX = clusterCenter.x
|
|
const targetY = clusterCenter.y
|
|
node.vx += (targetX - node.x) * alpha * 0.3
|
|
node.vy += (targetY - node.y) * alpha * 0.3
|
|
}
|
|
}
|
|
}
|
|
|
|
force.initialize = function(newNodes: any[]) {
|
|
nodes = newNodes
|
|
return force
|
|
}
|
|
|
|
return force
|
|
}
|
|
|
|
interface Note {
|
|
id: string
|
|
title: string | null
|
|
clusterId?: string | number
|
|
}
|
|
|
|
interface NoteCluster {
|
|
id: string | number
|
|
name: string
|
|
noteIds: string[]
|
|
color: string
|
|
}
|
|
|
|
interface BridgeNote {
|
|
noteId: string
|
|
bridgeScore: number
|
|
clustersConnected?: (string | number)[]
|
|
connectedClusterIds?: (string | number)[]
|
|
}
|
|
|
|
interface NetworkGraphProps {
|
|
notes: Note[]
|
|
clusters: NoteCluster[]
|
|
bridgeNotes: BridgeNote[]
|
|
onNoteSelect: (id: string) => void
|
|
}
|
|
|
|
export function NetworkGraph({
|
|
notes,
|
|
clusters,
|
|
bridgeNotes,
|
|
onNoteSelect
|
|
}: NetworkGraphProps) {
|
|
const svgRef = useRef<SVGSVGElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!svgRef.current || !containerRef.current) return
|
|
|
|
const width = containerRef.current.clientWidth
|
|
const height = containerRef.current.clientHeight
|
|
|
|
const svg = d3.select(svgRef.current)
|
|
svg.selectAll('*').remove()
|
|
|
|
const g = svg.append('g')
|
|
|
|
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform)
|
|
})
|
|
|
|
svg.call(zoom as any)
|
|
|
|
// Filter notes with cluster assignments (properly check for undefined/null)
|
|
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
|
|
|
|
interface D3Node extends d3.SimulationNodeDatum {
|
|
id: string
|
|
title: string | null
|
|
clusterId: string | number
|
|
color: string
|
|
isBridge: boolean
|
|
radius: number
|
|
}
|
|
|
|
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
|
source: string
|
|
target: string
|
|
strength: number
|
|
}
|
|
|
|
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
|
|
|
|
const nodes: D3Node[] = visibleNotes.map(n => {
|
|
const cluster = clusters.find(c => c.id === String(n.clusterId))
|
|
const isBridge = bridgeSet.has(n.id)
|
|
return {
|
|
id: n.id,
|
|
title: n.title,
|
|
clusterId: n.clusterId!,
|
|
color: cluster?.color || '#cbd5e1',
|
|
isBridge,
|
|
radius: isBridge ? 12 : 8
|
|
}
|
|
})
|
|
|
|
const links: D3Link[] = []
|
|
// Connect notes within the same cluster
|
|
for (let i = 0; i < visibleNotes.length; i++) {
|
|
for (let j = i + 1; j < visibleNotes.length; j++) {
|
|
const ni = visibleNotes[i]
|
|
const nj = visibleNotes[j]
|
|
|
|
if (ni.clusterId === nj.clusterId) {
|
|
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
|
|
}
|
|
}
|
|
}
|
|
|
|
const simulation = d3.forceSimulation<D3Node>(nodes)
|
|
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
|
|
.force('charge', d3.forceManyBody().strength(-300))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
|
|
.force('cluster', forceCluster())
|
|
|
|
// Links
|
|
const link = g.append('g')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.enter()
|
|
.append('line')
|
|
.attr('stroke', '#e2e8f0')
|
|
.attr('stroke-opacity', 0.6)
|
|
.attr('stroke-width', 1)
|
|
|
|
// Nodes
|
|
const node = g.append('g')
|
|
.selectAll('.node')
|
|
.data(nodes)
|
|
.enter()
|
|
.append('g')
|
|
.attr('class', 'node cursor-pointer')
|
|
.on('click', (event, d) => onNoteSelect(d.id))
|
|
.call(d3.drag<SVGGElement, D3Node>()
|
|
.on('start', dragstarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragended) as any)
|
|
|
|
node.append('circle')
|
|
.attr('r', d => d.radius)
|
|
.attr('fill', d => d.color)
|
|
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
|
|
.attr('stroke-width', d => d.isBridge ? 3 : 2)
|
|
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
|
|
|
|
node.append('text')
|
|
.attr('dy', d => d.radius + 14)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none')
|
|
.text(d => {
|
|
const title = d.title || 'Untitled'
|
|
return title.length > 20 ? title.substring(0, 20) + '...' : title
|
|
})
|
|
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => (d.source as any).x)
|
|
.attr('y1', d => (d.source as any).y)
|
|
.attr('x2', d => (d.target as any).x)
|
|
.attr('y2', d => (d.target as any).y)
|
|
|
|
node
|
|
.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
})
|
|
|
|
function dragstarted(event: any, d: D3Node) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
d.fx = d.x
|
|
d.fy = d.y
|
|
}
|
|
|
|
function dragged(event: any, d: D3Node) {
|
|
d.fx = event.x
|
|
d.fy = event.y
|
|
}
|
|
|
|
function dragended(event: any, d: D3Node) {
|
|
if (!event.active) simulation.alphaTarget(0)
|
|
d.fx = null
|
|
d.fy = null
|
|
}
|
|
|
|
return () => {
|
|
simulation.stop()
|
|
}
|
|
}, [notes, clusters, bridgeNotes, onNoteSelect])
|
|
|
|
return (
|
|
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
|
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
|
{clusters.map(c => (
|
|
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
|
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<svg ref={svgRef} className="w-full h-full" />
|
|
</div>
|
|
)
|
|
}
|