Politur: Gedaechnis-Graph auf react-force-graph-2d umgestellt (elastische D3-Physik, fliessende Datenpartikel, 100% lesbare Text-Pillen)

This commit is contained in:
Hitonabi
2026-07-09 12:16:29 +02:00
parent 3ea0e2d540
commit 9375b8eea8
9 changed files with 512 additions and 438 deletions
+181 -120
View File
@@ -1,13 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"
import Graph from "graphology"
import forceAtlas2 from "graphology-layout-forceatlas2"
import Sigma from "sigma"
import ForceGraph2D from "react-force-graph-2d"
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<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
}
@@ -15,129 +10,68 @@ const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const AUTO = new Set(["auto", "agent", "hermes"])
const DIM_NODE = "#1e293b"
const DIM_EDGE = "rgba(15, 23, 42, 0.05)"
const EDGE = "rgba(255, 255, 255, 0.07)"
const DIM_EDGE = "rgba(15, 23, 42, 0.02)"
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<string | null>(null)
const [hovered, setHovered] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const sigmaRef = useRef<Sigma | null>(null)
const graphRef = useRef<Graph | null>(null)
const active = useRef<string | null>(null) // hervorgehobener Knoten (hover oder Auswahl)
const selectedRef = useRef<string | null>(null) // aktuelle Auswahl (für leaveNode ohne Effekt-Neulauf)
const fgRef = useRef<any>(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) {
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<string, number> = {}
data.edges.forEach((e) => { d[e.source] = (d[e.source] || 0) + 1; d[e.target] = (d[e.target] || 0) + 1 })
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.
// D3 Force Engine tunen (Luftige Struktur, kein Überlappen)
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: 5 + Math.min(degree[n.id] || 0, 12) * 1.5,
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.8 + (e.weight || 0) * 0.5, color: EDGE })
})
forceAtlas2.assign(graph, { iterations: 220, settings: forceAtlas2.inferSettings(graph) })
graphRef.current = graph
if (!fgRef.current) return
const fg = fgRef.current
fg.d3Force("charge").strength(-160)
fg.d3Force("link").distance(65)
}, [graphData])
const renderer = new Sigma(graph, containerRef.current, {
renderLabels: true,
labelColor: { color: "#e2e8f0" },
labelSize: 11,
labelWeight: "600",
labelDensity: 0.5,
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
const isSel = id === selectedRef.current
if (!a) {
if (isSel) return { ...attrs, size: attrs.size * 1.35, highlighted: true }
return attrs
}
if (id === a) {
return { ...attrs, size: attrs.size * 1.45, highlighted: true }
}
if (graph.areNeighbors(a, id)) {
if (isSel) return { ...attrs, size: attrs.size * 1.35, highlighted: true }
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) * 2.2 }
return { ...attrs, color: DIM_EDGE }
},
})
sigmaRef.current = renderer
let dragged: string | null = null // gerade gezogener Knoten (für Hover/Drag-Logik)
const activeNode = hovered || selected
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").
// Layout zurücksetzen/neu anschubsen
const relayout = () => {
const g = graphRef.current, s = sigmaRef.current
if (!g || !s) return
forceAtlas2.assign(g, { iterations: 220, settings: forceAtlas2.inferSettings(g) })
s.refresh()
if (!fgRef.current) return
fgRef.current.zoomToFit(400, 40)
}
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
@@ -162,12 +96,139 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3">
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden"
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.06) 0%, #030712 100%)" }}>
<div ref={containerRef} className="absolute inset-0" />
ref={containerRef}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
backgroundColor="rgba(0,0,0,0)"
nodeRelSize={4}
// Hover- und Klick-Logik
onNodeClick={(node: any) => setSelected(node.id)}
onBackgroundClick={() => setSelected(null)}
onNodeHover={(node: any) => {
setHovered(node ? node.id : null)
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 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
const isDimmed = activeNode && node.id !== activeNode && !fgRef.current?.getGraphData()?.links.some((l: any) => {
const sId = typeof l.source === "object" ? l.source.id : l.source
const tId = typeof l.target === "object" ? l.target.id : l.target
return (sId === activeNode && tId === node.id) || (tId === activeNode && sId === 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(node.x, node.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(node.x, node.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 = node.x - textWidth / 2 - bpad
const ry = node.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, node.x, node.y + size + (6 / Math.max(0.6, globalScale)))
}}
/>
<button onClick={relayout} title="Knoten neu anordnen"
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors">
<RefreshCw className="h-3.5 w-3.5" /> Neu anordnen
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button>
{/* Legende */}
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(CAT_LABEL).map(([k, l]) => (
@@ -197,14 +258,14 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
<div className="flex flex-col gap-1.5 mb-4">
{neighbors.length ? neighbors.map((n) => (
<button key={n.id} onClick={() => setSelected(n.id)}
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors">
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
<span className="truncate">{n.content}</span>
</button>
)) : <span className="text-[11px] text-muted-foreground/60"></span>}
</div>
<button onClick={() => { onDelete(sel.id); setSelected(null) }}
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all">
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all cursor-pointer">
<Trash2 className="h-3.5 w-3.5" /> vergessen
</button>
</>