import { useEffect, useMemo, useRef, useState } from "react" import ForceGraph2D from "react-force-graph-2d" import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react" import { type MemoryGraph } from "@/lib/api" const CAT_COLOR: Record = { identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24", } const CAT_LABEL: Record = { identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse", } const AUTO = new Set(["auto", "agent", "hermes"]) const DIM_EDGE = "rgba(15, 23, 42, 0.01)" const EDGE = "rgba(255, 255, 255, 0.05)" const EDGE_HI = "rgba(99, 102, 241, 0.85)" export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) { const [selected, setSelected] = useState(null) const [hovered, setHovered] = useState(null) const containerRef = useRef(null) const fgRef = useRef(null) const [dimensions, setDimensions] = useState({ width: 500, height: 560 }) // ResizeObserver für präzise Canvas-Dimensionen im Layout useEffect(() => { if (!containerRef.current) return const resizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { if (entry.contentRect.width > 0 && entry.contentRect.height > 0) { setDimensions({ width: Math.floor(entry.contentRect.width), height: Math.floor(entry.contentRect.height), }) } } }) resizeObserver.observe(containerRef.current) return () => resizeObserver.disconnect() }, []) // Graphology-Daten in react-force-graph-2d-kompatibles Format konvertieren const graphData = useMemo(() => { return { nodes: data.nodes.map((n) => ({ ...n })), links: data.edges.map((e) => ({ source: e.source, target: e.target, weight: e.weight || 0, })), } }, [data]) const degree = useMemo(() => { const d: Record = {} data.edges.forEach((e) => { d[e.source] = (d[e.source] || 0) + 1 d[e.target] = (d[e.target] || 0) + 1 }) return d }, [data]) // D3 Force Engine tunen (Luftige Struktur, kein Überlappen) useEffect(() => { const fg = fgRef.current if (!fg) return // Sicherstellen, dass D3-Kräfte existieren, bevor wir aufgerufen werden const charge = fg.d3Force("charge") if (charge) charge.strength(-140) const link = fg.d3Force("link") if (link) link.distance(60) }, [graphData]) const activeNode = hovered || selected // Layout zurücksetzen/neu anschubsen const relayout = () => { if (!fgRef.current) return fgRef.current.zoomToFit(400, 40) } const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null // Nachbarn über das statische, sichere Props-Array ermitteln const neighbors = useMemo(() => { if (!selected) return [] const ids = new Set() data.edges.forEach((e) => { if (e.source === selected) ids.add(e.target) if (e.target === selected) ids.add(e.source) }) return data.nodes.filter((n) => ids.has(n.id)) }, [selected, data]) // Nachbarmenge für extrem schnellen Lookup im Render-Loop const connectedNodes = useMemo(() => { if (!activeNode) return new Set() const set = new Set() data.edges.forEach((e) => { if (e.source === activeNode) set.add(e.target) if (e.target === activeNode) set.add(e.source) }) return set }, [activeNode, data]) if (!data.nodes.length) { return (
Noch keine Fakten — der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
) } return (
setSelected(node.id)} onBackgroundClick={() => setSelected(null)} onNodeHover={(node: any) => { setHovered(node ? node.id : null) if (containerRef.current) { containerRef.current.style.cursor = node ? "pointer" : "default" } }} // Kanten-Animationen & Partikelfluss linkColor={(link: any) => { const sId = typeof link.source === "object" ? link.source.id : link.source const tId = typeof link.target === "object" ? link.target.id : link.target if (activeNode === sId || activeNode === tId) return EDGE_HI return activeNode ? DIM_EDGE : EDGE }} linkWidth={(link: any) => { const sId = typeof link.source === "object" ? link.source.id : link.source const tId = typeof link.target === "object" ? link.target.id : link.target return (activeNode === sId || activeNode === tId) ? 2.2 : 0.8 }} // Daten-Partikel fließen auf den aktiven Verbindungen linkDirectionalParticles={(link: any) => { const sId = typeof link.source === "object" ? link.source.id : link.source const tId = typeof link.target === "object" ? link.target.id : link.target return (activeNode === sId || activeNode === tId) ? 4 : 0 }} linkDirectionalParticleWidth={2.2} linkDirectionalParticleSpeed={0.006} // Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const x = node.x ?? 0 const y = node.y ?? 0 const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9 const color = CAT_COLOR[node.category] || "#64748b" const isHovered = node.id === hovered const isSelected = node.id === selected // Abgedunkelter Zustand, wenn ein anderer Knoten aktiv ist und dieser kein Nachbar ist const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id) const finalColor = isDimmed ? "#1e293b" : color // 1. Zeichne weichen Glow-Schatten if (!isDimmed) { ctx.shadowColor = color ctx.shadowBlur = isHovered || isSelected ? 12 : 6 } // 2. Zeichne den Knoten-Kreis ctx.beginPath() ctx.arc(x, y, size, 0, 2 * Math.PI, false) ctx.fillStyle = finalColor ctx.fill() ctx.shadowBlur = 0 // Schatten zurücksetzen // 3. Zeichne weißen Außenring bei Interaktivität if (isHovered || isSelected) { ctx.beginPath() ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false) ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)" ctx.lineWidth = 1.5 ctx.stroke() } // 4. Typografie (Immer scharf & mitskalierend gezeichnet) const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content const baseFontSize = isHovered || isSelected ? 10 : 9 const fontSize = baseFontSize / Math.max(0.6, globalScale) // Verhindert Riesen-Schriften beim Rauszoomen ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif` const textWidth = ctx.measureText(label).width const bpad = 4 / Math.max(0.6, globalScale) // Render Label-Hintergrund für absolute Lesbarkeit ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)" ctx.beginPath() // Zeichne abgerundete Pille unter dem Knoten const rx = x - textWidth / 2 - bpad const ry = y + size + (5 / Math.max(0.6, globalScale)) const rw = textWidth + bpad * 2 const rh = fontSize + bpad * 1.5 const rad = 4 / Math.max(0.6, globalScale) ctx.beginPath() ctx.moveTo(rx + rad, ry) ctx.lineTo(rx + rw - rad, ry) ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad) ctx.lineTo(rx + rw, ry + rh - rad) ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh) ctx.lineTo(rx + rad, ry + rh) ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad) ctx.lineTo(rx, ry + rad) ctx.quadraticCurveTo(rx, ry, rx + rad, ry) ctx.closePath() ctx.fill() // Feine Kontur für die Text-Pille ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)" ctx.lineWidth = 0.5 / Math.max(0.6, globalScale) ctx.stroke() // Text zeichnen ctx.textAlign = "center" ctx.textBaseline = "top" ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1") ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale))) }} /> {/* Legende */}
{Object.entries(CAT_LABEL).map(([k, l]) => ( {l} ))}
{sel ? ( <>
{CAT_LABEL[sel.category] || sel.category} {AUTO.has(sel.source) && }{sel.source}
{sel.content}
verwandte Fakten
{neighbors.length ? neighbors.map((n) => ( )) : }
) : (
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
)}
) }