import { useEffect, useMemo, useRef, useState } from "react" import Graph from "graphology" import forceAtlas2 from "graphology-layout-forceatlas2" import Sigma from "sigma" import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react" import { type MemoryGraph } from "@/lib/api" // Sigma.js v3 + graphology: graph-optimiertes WebGL (kein three.js-Ballast) → skaliert auf tausende // Fakten flüssig. Im App-Look gestylt; Hover hebt den Knoten + seine Nachbarn hervor, der Rest dimmt. const CAT_COLOR: Record = { identity: "#22d3ee", knowledge: "#6366f1", rules: "#a78bfa", events: "#fbbf24", } const CAT_LABEL: Record = { identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse", } const AUTO = new Set(["auto", "agent", "hermes"]) const DIM_NODE = "#1f2937" const DIM_EDGE = "#0f172a" const EDGE = "#243044" const EDGE_HI = "#4b5563" export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) { const [selected, setSelected] = useState(null) const containerRef = useRef(null) const sigmaRef = useRef(null) const graphRef = useRef(null) const active = useRef(null) // hervorgehobener Knoten (hover oder Auswahl) const selectedRef = useRef(null) // aktuelle Auswahl (für leaveNode ohne Effekt-Neulauf) 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]) // Graph (neu)bauen + Layout + Sigma rendern, wenn sich die Daten ändern. useEffect(() => { if (!containerRef.current || !data.nodes.length) return const graph = new Graph() const N = data.nodes.length data.nodes.forEach((n, i) => { const a = (2 * Math.PI * i) / N graph.addNode(n.id, { x: Math.cos(a), y: Math.sin(a), // Startposition (Kreis) für ForceAtlas2 size: 4 + Math.min(degree[n.id] || 0, 12) * 1.4, color: CAT_COLOR[n.category] || "#64748b", label: n.content.length > 48 ? n.content.slice(0, 47) + "…" : n.content, }) }) data.edges.forEach((e) => { if (graph.hasNode(e.source) && graph.hasNode(e.target) && !graph.hasEdge(e.source, e.target)) graph.addEdge(e.source, e.target, { size: 0.6 + (e.weight || 0), color: EDGE }) }) forceAtlas2.assign(graph, { iterations: 220, settings: forceAtlas2.inferSettings(graph) }) graphRef.current = graph const renderer = new Sigma(graph, containerRef.current, { renderLabels: true, labelColor: { color: "#cbd5e1" }, labelSize: 11, labelWeight: "500", labelDensity: 0.6, labelRenderedSizeThreshold: N > 120 ? 12 : 0, // bei vielen Knoten nur große labeln (entklumpen) defaultEdgeColor: EDGE, minCameraRatio: 0.1, maxCameraRatio: 4, nodeReducer: (id, attrs) => { const a = active.current if (!a) return attrs if (id === a || graph.areNeighbors(a, id)) return attrs return { ...attrs, color: DIM_NODE, label: "" } }, edgeReducer: (edge, attrs) => { const a = active.current if (!a) return attrs const ext = graph.extremities(edge) if (ext[0] === a || ext[1] === a) return { ...attrs, color: EDGE_HI, size: (attrs.size || 1) * 1.6 } return { ...attrs, color: DIM_EDGE } }, }) sigmaRef.current = renderer let dragged: string | null = null // gerade gezogener Knoten (für Hover/Drag-Logik) renderer.on("clickNode", ({ node }) => setSelected(node)) renderer.on("clickStage", () => setSelected(null)) renderer.on("enterNode", ({ node }) => { if (!dragged) { active.current = node; renderer.refresh() } containerRef.current!.style.cursor = dragged ? "grabbing" : "grab" }) renderer.on("leaveNode", () => { if (!dragged) { active.current = selectedRef.current; renderer.refresh(); containerRef.current!.style.cursor = "default" } }) // Knoten ziehen (Sigma hat kein eingebautes Dragging → Standard-Muster mit dem Mouse-Captor). renderer.on("downNode", (e) => { dragged = e.node graph.setNodeAttribute(dragged, "highlighted", true) if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox()) containerRef.current!.style.cursor = "grabbing" }) const mc = renderer.getMouseCaptor() mc.on("mousemovebody", (e) => { if (!dragged) return const pos = renderer.viewportToGraph(e) graph.setNodeAttribute(dragged, "x", pos.x) graph.setNodeAttribute(dragged, "y", pos.y) e.preventSigmaDefault(); e.original.preventDefault(); e.original.stopPropagation() }) mc.on("mouseup", () => { if (dragged) graph.removeNodeAttribute(dragged, "highlighted") dragged = null if (containerRef.current) containerRef.current.style.cursor = "default" }) return () => { renderer.kill(); sigmaRef.current = null; graphRef.current = null } }, [data, degree]) // Auswahl spiegelt die Hervorhebung (auch ohne Hover). useEffect(() => { selectedRef.current = selected active.current = selected sigmaRef.current?.refresh() }, [selected]) // Layout neu berechnen (nach manuellem Verschieben „aufräumen"). const relayout = () => { const g = graphRef.current, s = sigmaRef.current if (!g || !s) return forceAtlas2.assign(g, { iterations: 220, settings: forceAtlas2.inferSettings(g) }) s.refresh() } const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null 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]) if (!data.nodes.length) { return (
Noch keine Fakten — der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
) } return (
{/* 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.
)}
) }