import { useCallback, 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)" const FALLBACK_EDGE = "rgba(100, 116, 139, 0.25)" // Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen. const CAT_EDGE_COLOR: Record = { identity: "rgba(0, 245, 255, 0.25)", knowledge: "rgba(59, 130, 246, 0.25)", rules: "rgba(217, 70, 239, 0.25)", events: "rgba(251, 191, 36, 0.25)", } // Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist) const CAT_HALO_COLOR: Record = { identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)", rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)", } 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() }, []) // Knoten-Objekte über Daten-Updates hinweg WIEDERVERWENDEN: D3 behält x/y auf dem // Objekt — nur so überleben die Positionen Refetches und Suchfilter, statt dass der // ganze Graph bei jedem Update neu explodiert (und Knoten unterm Cursor wegwandern). const nodeCacheRef = useRef(new Map()) const graphData = useMemo(() => { const cache = nodeCacheRef.current const seen = new Set() const nodes = data.nodes.map((n) => { seen.add(n.id) let node = cache.get(n.id) if (!node) { node = { ...n } cache.set(n.id, node) } else if (node.content !== n.content || node.category !== n.category) { node.content = n.content node.category = n.category node.__baseTextWidth = undefined } return node }) for (const id of Array.from(cache.keys())) { if (!seen.has(id)) cache.delete(id) } const catById = new Map(data.nodes.map((n) => [n.id, n.category])) const links = data.edges.map((e) => ({ source: e.source, target: e.target, weight: e.weight || 0, // Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen) __sid: e.source, __tid: e.target, __restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE, })) return { nodes, links } }, [data]) // Grad (Degree) berechnen 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]) // Quadranten-Fokuskoordinaten für sauberes Clustering 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 } // Reheat nur, wenn sich die STRUKTUR ändert (Knoten-/Kantenmenge) — nicht bei jeder // neuen Objekt-Identität der Daten. Verhindert Layout-Neustarts bei blossen Refetches. const structureKey = useMemo(() => { return `${data.nodes.map((n) => n.id).sort().join(",")}#${data.edges.length}` }, [data]) useEffect(() => { const fg = fgRef.current if (!fg) return // 1. Standard-Zentrumskraft entfernen fg.d3Force("center", null) // 2. Clustering-Kräfte 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. Konstanter, stabiler Kollisionsradius fg.d3Force("collide", forceCollide().radius(28).iterations(2)) // 5. Federkraft (Links) const link = fg.d3Force("link") if (link) link.distance(85) // Simulation einmalig heiss starten für die neue Struktur fg.d3ReheatSimulation() }, [structureKey]) 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 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]) // Stabile Accessor-Identitäten: nur neu, wenn sich der aktive Knoten ändert. // Nutzt die vorberechneten __sid/__tid/__restColor — KEIN nodes.find() pro Frame mehr. const linkColor = useCallback((link: any) => { if (activeNode) { return (link.__sid === activeNode || link.__tid === activeNode) ? EDGE_HI : DIM_EDGE } return link.__restColor }, [activeNode]) const linkWidth = useCallback((link: any) => { return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 2.5 : 1.4 }, [activeNode]) const linkParticles = useCallback((link: any) => { return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0 }, [activeNode]) // EIGENES Picking statt der Bibliotheks-Hit-Map: force-graph erkennt Hover/Klick // über eine Schatten-Canvas, die nur alle 800 ms aktualisiert wird — solange die // Simulation läuft oder gezoomt wird, zeigt die Erkennung auf Positionen von vor // fast einer Sekunde. Wir rechnen stattdessen exakt gegen die LIVE-Koordinaten // der Knoten (319 Distanzchecks pro Mausbewegung sind vernachlässigbar). const pickNode = useCallback((clientX: number, clientY: number) => { const fg = fgRef.current const el = containerRef.current if (!fg || !el) return null const rect = el.getBoundingClientRect() const { x: gx, y: gy } = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top) const k = Math.max(fg.zoom() || 1, 0.01) let best: any = null let bestD2 = Infinity for (const n of graphData.nodes as any[]) { if (n.x == null || n.y == null) continue const size = 6 + Math.min(degree[n.id] || 0, 15) * 0.8 // Trefferradius nie kleiner als ~16 Bildschirm-Pixel, egal wie weit rausgezoomt const hitR = Math.max(size + 3, 16 / k) const dx = n.x - gx const dy = n.y - gy const d2 = dx * dx + dy * dy if (d2 <= hitR * hitR && d2 < bestD2) { bestD2 = d2; best = n } } return best }, [graphData, degree]) const handlePointerMove = useCallback((ev: React.PointerEvent) => { // Nur auf der Canvas picken — über Buttons/Legende gilt: kein Hover const node = ev.target instanceof HTMLCanvasElement ? pickNode(ev.clientX, ev.clientY) : null setHovered(node ? node.id : null) if (containerRef.current) { containerRef.current.style.cursor = node ? "pointer" : "default" } }, [pickNode]) const handlePointerLeave = useCallback(() => { setHovered(null) if (containerRef.current) containerRef.current.style.cursor = "default" }, []) // Klick selbst auswerten — aber nicht nach einem Pan (Mausweg > 6 px zählt nicht als Klick) const downPosRef = useRef<{ x: number; y: number } | null>(null) const handlePointerDown = useCallback((ev: React.PointerEvent) => { downPosRef.current = { x: ev.clientX, y: ev.clientY } }, []) const handleClick = useCallback((ev: React.MouseEvent) => { if (!(ev.target instanceof HTMLCanvasElement)) return // Buttons/Legende nicht abfangen const down = downPosRef.current if (down && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > 6) return const node = pickNode(ev.clientX, ev.clientY) onNodeSelect(node ? node.id : null) }, [pickNode, onNodeSelect]) const nodeCanvasObject = useCallback((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. Billiger Glow: transparenter Halo-Kreis statt ctx.shadowBlur (das kostet bei // hunderten Knoten pro Frame die gesamte Framerate). Echter Blur nur für den // EINEN aktiven Knoten. if (!isDimmed) { ctx.beginPath() ctx.arc(x, y, size * 1.9, 0, 2 * Math.PI, false) ctx.fillStyle = CAT_HALO_COLOR[node.category] || "rgba(100, 116, 139, 0.14)" ctx.fill() } if (isHovered || isSelected) { ctx.shadowColor = color ctx.shadowBlur = 15 } 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. Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn) const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id)) if (showLabel) { const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content // Textbreite EINMALIG auf dem Objekt cachen (ctx.measureText ist teuer) if (node.__baseTextWidth === undefined) { const prevFont = ctx.font ctx.font = '500 10px "Space Grotesk", sans-serif' node.__baseTextWidth = ctx.measureText(label).width ctx.font = prevFont } 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` // Breite linear zur geänderten FontSize skalieren — kein measureText pro Frame const textWidth = node.__baseTextWidth * (fontSize / 10) const bpad = 4.5 / Math.max(0.5, globalScale) 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 ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)" ctx.fillRect(rx, ry, rw, rh) 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.strokeRect(rx, ry, rw, rh) 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))) } }, [degree, hovered, selectedNodeId, activeNode, connectedNodes]) return (
{/* Legende */}
{Object.entries(CAT_LABEL).map(([k, l]) => ( {l} ))}
) }