Files
mission-control-v2/frontend/src/views/GraphView.tsx
T
Hitonabi 88661545b4 Feat: Graph-Knoten verschieben (Drag) + Neu-anordnen-Button
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>
2026-06-28 11:19:53 +02:00

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>
)
}