import { useEffect, useMemo, useRef, useState } from "react" import ForceGraph2D from "react-force-graph-2d" import { forceCollide, forceX, forceY } from "d3-force" import { 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 EDGE_HI = "rgba(99, 102, 241, 0.9)" const DIM_EDGE = "rgba(255, 255, 255, 0.02)" // Hilfsfunktion zur Konvertierung von Hex in RGBA für farbkodierte Verbindungen function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16) const g = parseInt(hex.slice(3, 5), 16) const b = parseInt(hex.slice(5, 7), 16) return `rgba(${r}, ${g}, ${b}, ${alpha})` } export function GraphView({ data, selectedNodeId, onNodeSelect }: { data: MemoryGraph selectedNodeId: string | null onNodeSelect: (id: string | null) => void }) { 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]) // Grad (Degree) berechnen – fängt D3-Objektmutationen robust ab! const degree = useMemo(() => { const d: Record = {} data.edges.forEach((e) => { const sId = typeof e.source === "object" ? (e.source as any).id : e.source const tId = typeof e.target === "object" ? (e.target as any).id : e.target if (sId && tId) { d[sId] = (d[sId] || 0) + 1 d[tId] = (d[tId] || 0) + 1 } }) return d }, [data]) // Quadranten-Fokuskoordinaten für sauberes Clustering (Fokuszentren weiter auseinander gezogen) const getClusterX = (category: string) => { return { identity: -240, knowledge: 240, rules: -240, events: 240, }[category] || 0 } const getClusterY = (category: string) => { return { identity: -240, knowledge: -240, rules: 240, events: 240, }[category] || 0 } // D3 Force Engine tunen (Luftige Struktur, kein Überlappen, Clustering) useEffect(() => { const fg = fgRef.current if (!fg) return // 1. Standard-Zentrumskraft entfernen (erlaubt freies Clustering in Quadranten) fg.d3Force("center", null) // 2. Clustering-Kräfte (Knoten zu ihren jeweiligen Sektions-Foci ziehen) fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.07)) fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.07)) // 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz const charge = fg.d3Force("charge") if (charge) charge.strength(-380) // 4. Kollisionskraft (Sicherer Radius, verhindert Überlappung) fg.d3Force("collide", forceCollide().radius((node: any) => { const isLarge = node.id === hovered || node.id === selectedNodeId return isLarge ? 50 : 25 }).iterations(2)) // 5. Federkraft (Links) const link = fg.d3Force("link") if (link) link.distance(85) // Simulation neu starten (Reheat) fg.d3ReheatSimulation() }, [graphData, hovered, selectedNodeId]) const activeNode = hovered || selectedNodeId // Layout zurücksetzen/neu anschubsen const relayout = () => { if (!fgRef.current) return fgRef.current.zoomToFit(400, 40) } // Nachbarmenge für extrem schnellen Lookup – fängt D3-Objektmutationen robust ab! const connectedNodes = useMemo(() => { if (!activeNode) return new Set() const set = new Set() data.edges.forEach((e) => { const sId = typeof e.source === "object" ? (e.source as any).id : e.source const tId = typeof e.target === "object" ? (e.target as any).id : e.target if (sId === activeNode) set.add(tId) if (tId === activeNode) set.add(sId) }) return set }, [activeNode, data]) return (
{ const x = node.x ?? 0 const y = node.y ?? 0 const nId = typeof node === "object" ? node.id : node const size = 6 + Math.min(degree[nId] || 0, 15) * 0.8 ctx.fillStyle = color ctx.beginPath() ctx.arc(x, y, size + 5, 0, 2 * Math.PI, false) // 5px Puffer für einfaches Treffen ctx.fill() }} // Hover- und Klick-Logik onNodeClick={(node: any) => onNodeSelect(node.id)} onBackgroundClick={() => onNodeSelect(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 if (activeNode) return DIM_EDGE const sourceNode = graphData.nodes.find(n => n.id === sId) const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b" return hexToRgba(catColor, 0.25) }} 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.5 : 1.4 }} // 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 = 6 + Math.min(degree[node.id] || 0, 15) * 0.8 const color = CAT_COLOR[node.category] || "#64748b" const isHovered = node.id === hovered const isSelected = node.id === selectedNodeId const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id) const finalColor = isDimmed ? "#1e293b" : color // 1. Zeichne den farbigen Knotenpunkt (Kreis) if (!isDimmed) { ctx.shadowColor = color ctx.shadowBlur = isHovered || isSelected ? 15 : 6 } ctx.beginPath() ctx.arc(x, y, size, 0, 2 * Math.PI, false) ctx.fillStyle = finalColor ctx.fill() ctx.shadowBlur = 0 if (isHovered || isSelected) { ctx.beginPath() ctx.arc(x, y, size + 2.5, 0, 2 * Math.PI, false) ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)" ctx.lineWidth = 1.5 ctx.stroke() } // 2. Zeichne das Label nur unter bestimmten Bedingungen (Zoom-abhängig) // Verhindert Text-Clutter und macht Knotenpunkte permanent sichtbar. const showLabel = isHovered || isSelected || (globalScale >= 1.25) || (activeNode && connectedNodes.has(node.id)) if (showLabel) { const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content const baseFontSize = isHovered || isSelected ? 10.5 : 9.5 const fontSize = baseFontSize / Math.max(0.5, globalScale) ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif` const textWidth = ctx.measureText(label).width const bpad = 4.5 / Math.max(0.5, globalScale) ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)" ctx.beginPath() const rx = x - textWidth / 2 - bpad const ry = y + size + (6 / Math.max(0.5, globalScale)) const rw = textWidth + bpad * 2 const rh = fontSize + bpad * 1.6 const rad = 4 / Math.max(0.5, 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() ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.08)" ctx.lineWidth = 0.6 / Math.max(0.5, globalScale) ctx.stroke() ctx.textAlign = "center" ctx.textBaseline = "top" ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1") ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale))) } }} /> {/* Legende */}
{Object.entries(CAT_LABEL).map(([k, l]) => ( {l} ))}
) }