/ src / components / memory / memory-graph-view.tsx
memory-graph-view.tsx
  1  'use client'
  2  
  3  import { useEffect, useRef, useState, useCallback } from 'react'
  4  import { api } from '@/lib/app/api-client'
  5  import { useAppStore } from '@/stores/use-app-store'
  6  
  7  interface Node {
  8    id: string
  9    title: string
 10    category: string
 11    agentId?: string | null
 12    x: number
 13    y: number
 14    vx: number
 15    vy: number
 16  }
 17  
 18  interface Link {
 19    source: string
 20    target: string
 21    type: string
 22  }
 23  
 24  /** Kinetic energy threshold — stop simulation when total energy drops below this. */
 25  const SETTLE_THRESHOLD = 0.5
 26  
 27  export function MemoryGraphView() {
 28    const [initialData, setInitialData] = useState<{ nodes: Node[]; links: Link[] } | null>(null)
 29    const [loading, setLoading] = useState(true)
 30    const [hoveredNode, setHoveredNode] = useState<string | null>(null)
 31    const containerRef = useRef<HTMLDivElement>(null)
 32    const svgRef = useRef<SVGSVGElement>(null)
 33    const requestRef = useRef<number>(null)
 34    const nodesRef = useRef<Node[]>([])
 35    const linksRef = useRef<Link[]>([])
 36  
 37    const selectedMemoryId = useAppStore((s) => s.selectedMemoryId)
 38    const setSelectedMemoryId = useAppStore((s) => s.setSelectedMemoryId)
 39    const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter)
 40  
 41    useEffect(() => {
 42      async function load() {
 43        setLoading(true)
 44        try {
 45          const url = `/memory/graph${memoryAgentFilter ? `?agentId=${memoryAgentFilter}` : ''}`
 46          const res = await api<{ nodes: Node[]; links: Link[] }>('GET', url)
 47  
 48          // Initialize positions
 49          const nodes = res.nodes.map(n => ({
 50            ...n,
 51            x: Math.random() * 800,
 52            y: Math.random() * 600,
 53            vx: 0,
 54            vy: 0
 55          }))
 56  
 57          nodesRef.current = nodes
 58          linksRef.current = res.links
 59          setInitialData({ nodes, links: res.links })
 60        } catch (err) {
 61          console.error('Failed to load memory graph', err)
 62        } finally {
 63          setLoading(false)
 64        }
 65      }
 66      load()
 67    }, [memoryAgentFilter])
 68  
 69    // Write positions directly to SVG DOM — no React state updates per frame
 70    const updateDOM = useCallback(() => {
 71      const svg = svgRef.current
 72      if (!svg) return
 73      const nodes = nodesRef.current
 74  
 75      // Update link positions
 76      const lineElements = svg.querySelectorAll<SVGLineElement>('[data-link]')
 77      lineElements.forEach((el) => {
 78        const srcId = el.getAttribute('data-src')
 79        const tgtId = el.getAttribute('data-tgt')
 80        const s = nodes.find(n => n.id === srcId)
 81        const t = nodes.find(n => n.id === tgtId)
 82        if (s && t) {
 83          el.setAttribute('x1', String(s.x))
 84          el.setAttribute('y1', String(s.y))
 85          el.setAttribute('x2', String(t.x))
 86          el.setAttribute('y2', String(t.y))
 87        }
 88      })
 89  
 90      // Update node positions
 91      const gElements = svg.querySelectorAll<SVGGElement>('[data-node-id]')
 92      gElements.forEach((el) => {
 93        const id = el.getAttribute('data-node-id')
 94        const node = nodes.find(n => n.id === id)
 95        if (node) {
 96          el.setAttribute('transform', `translate(${node.x},${node.y})`)
 97        }
 98      })
 99    }, [])
100  
101    // Force-directed simulation running in refs, writing to DOM imperatively
102    useEffect(() => {
103      const nodes = nodesRef.current
104      const links = linksRef.current
105      if (nodes.length === 0) return
106  
107      const animate = () => {
108        let totalEnergy = 0
109  
110        // 1. Repulsion between all nodes
111        for (let i = 0; i < nodes.length; i++) {
112          for (let j = i + 1; j < nodes.length; j++) {
113            const dx = nodes[i].x - nodes[j].x
114            const dy = nodes[i].y - nodes[j].y
115            const distSq = dx * dx + dy * dy + 0.1
116            const force = 400 / distSq
117            const fx = dx * force
118            const fy = dy * force
119            nodes[i].vx += fx
120            nodes[i].vy += fy
121            nodes[j].vx -= fx
122            nodes[j].vy -= fy
123          }
124        }
125  
126        // 2. Attraction along links
127        for (const link of links) {
128          const source = nodes.find(n => n.id === link.source)
129          const target = nodes.find(n => n.id === link.target)
130          if (source && target) {
131            const dx = target.x - source.x
132            const dy = target.y - source.y
133            const dist = Math.sqrt(dx * dx + dy * dy) + 0.1
134            const force = (dist - 100) * 0.02
135            const fx = (dx / dist) * force
136            const fy = (dy / dist) * force
137            source.vx += fx
138            source.vy += fy
139            target.vx -= fx
140            target.vy -= fy
141          }
142        }
143  
144        // 3. Centering force
145        const cx = 400
146        const cy = 300
147        for (const node of nodes) {
148          node.vx += (cx - node.x) * 0.01
149          node.vy += (cy - node.y) * 0.01
150        }
151  
152        // 4. Update positions with damping
153        for (const node of nodes) {
154          node.x += node.vx
155          node.y += node.vy
156          node.vx *= 0.8
157          node.vy *= 0.8
158          totalEnergy += node.vx * node.vx + node.vy * node.vy
159        }
160  
161        // Write positions to DOM imperatively
162        updateDOM()
163  
164        // Stop when settled
165        if (totalEnergy > SETTLE_THRESHOLD) {
166          requestRef.current = requestAnimationFrame(animate)
167        }
168      }
169  
170      requestRef.current = requestAnimationFrame(animate)
171      return () => {
172        if (requestRef.current) cancelAnimationFrame(requestRef.current)
173      }
174    }, [initialData, updateDOM])
175  
176    if (loading) {
177      return (
178        <div className="flex-1 flex items-center justify-center">
179          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-bright"></div>
180        </div>
181      )
182    }
183  
184    const nodes = nodesRef.current
185    const links = linksRef.current
186  
187    return (
188      <div ref={containerRef} className="flex-1 relative overflow-hidden bg-black/20 rounded-[16px] border border-white/[0.06]">
189        <svg ref={svgRef} width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
190          {/* Links */}
191          {links.map((link, i) => {
192            const s = nodes.find(n => n.id === link.source)
193            const t = nodes.find(n => n.id === link.target)
194            if (!s || !t) return null
195            return (
196              <line
197                key={i}
198                data-link=""
199                data-src={link.source}
200                data-tgt={link.target}
201                x1={s.x} y1={s.y}
202                x2={t.x} y2={t.y}
203                stroke="white"
204                strokeOpacity="0.1"
205                strokeWidth="1"
206              />
207            )
208          })}
209  
210          {/* Nodes */}
211          {nodes.map(node => (
212            <g
213              key={node.id}
214              data-node-id={node.id}
215              transform={`translate(${node.x},${node.y})`}
216              onMouseEnter={() => setHoveredNode(node.id)}
217              onMouseLeave={() => setHoveredNode(null)}
218              onClick={() => setSelectedMemoryId(node.id)}
219              className="cursor-pointer"
220            >
221              <circle
222                r={selectedMemoryId === node.id ? 8 : 5}
223                fill={node.category === 'knowledge' ? '#10B981' : '#6366F1'}
224                stroke="white"
225                strokeWidth={selectedMemoryId === node.id ? 2 : 0}
226                className="transition-all"
227              />
228              {(hoveredNode === node.id || selectedMemoryId === node.id) && (
229                <text
230                  y="-12"
231                  textAnchor="middle"
232                  className="text-[10px] fill-text font-600 pointer-events-none"
233                  style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}
234                >
235                  {node.title}
236                </text>
237              )}
238            </g>
239          ))}
240        </svg>
241  
242        {/* Legend */}
243        <div className="absolute bottom-4 left-4 p-3 bg-surface/80 backdrop-blur rounded-[12px] border border-white/[0.06] flex flex-col gap-2">
244          <div className="flex items-center gap-2">
245            <div className="w-3 h-3 rounded-full bg-[#10B981]" />
246            <span className="text-[11px] text-text-3">Knowledge</span>
247          </div>
248          <div className="flex items-center gap-2">
249            <div className="w-3 h-3 rounded-full bg-[#6366F1]" />
250            <span className="text-[11px] text-text-3">Note / Working</span>
251          </div>
252        </div>
253      </div>
254    )
255  }