88661545b4
Sigma-Drag-Muster (downNode/mousemovebody/mouseup) → Knoten frei ziehen; 'Neu anordnen' rechnet das ForceAtlas2-Layout neu (aufräumen nach manuellem Verschieben). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
9.9 KiB
TypeScript
208 lines
9.9 KiB
TypeScript
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<string, string> = {
|
|
identity: "#22d3ee", knowledge: "#6366f1", rules: "#a78bfa", events: "#fbbf24",
|
|
}
|
|
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 = "#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<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 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 })
|
|
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<string>()
|
|
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 (
|
|
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
|
|
Noch keine Fakten — der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 bg-[#070a0f] overflow-hidden">
|
|
<div ref={containerRef} className="absolute inset-0" />
|
|
<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
|
|
</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]) => (
|
|
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 h-[calc(100vh-13rem)] min-h-[560px] overflow-y-auto scrollbar-thin">
|
|
{sel ? (
|
|
<>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="w-3 h-3 rounded-full" style={{ background: CAT_COLOR[sel.category], boxShadow: `0 0 0 2px ${AUTO.has(sel.source) ? "#34d399" : "#475569"}` }} />
|
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}>
|
|
{CAT_LABEL[sel.category] || sel.category}
|
|
</span>
|
|
<span className={`ml-auto text-[10px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
|
|
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}{sel.source}
|
|
</span>
|
|
</div>
|
|
<div className="text-sm text-foreground leading-relaxed mb-4 break-words">{sel.content}</div>
|
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 mb-2 flex items-center gap-1">
|
|
<Share2 className="h-3 w-3" /> verwandte Fakten
|
|
</div>
|
|
<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">
|
|
<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">
|
|
<Trash2 className="h-3.5 w-3.5" /> vergessen
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
|
|
<Share2 className="h-6 w-6" />
|
|
<div className="text-xs max-w-[180px] leading-relaxed">
|
|
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|