Files
mission-control-v2/frontend/src/views/GraphView.tsx
T

304 lines
13 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d"
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react"
import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", 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_EDGE = "rgba(15, 23, 42, 0.01)"
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 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) {
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()
}, [])
// 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
})
return d
}, [data])
// D3 Force Engine tunen (Luftige Struktur, kein Überlappen)
useEffect(() => {
const fg = fgRef.current
if (!fg) return
// Sicherstellen, dass D3-Kräfte existieren, bevor wir aufgerufen werden
const charge = fg.d3Force("charge")
if (charge) charge.strength(-140)
const link = fg.d3Force("link")
if (link) link.distance(60)
}, [graphData])
const activeNode = hovered || selected
// Layout zurücksetzen/neu anschubsen
const relayout = () => {
if (!fgRef.current) return
fgRef.current.zoomToFit(400, 40)
}
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
// Nachbarn über das statische, sichere Props-Array ermitteln
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])
// Nachbarmenge für extrem schnellen Lookup im Render-Loop
const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>()
const set = new Set<string>()
data.edges.forEach((e) => {
if (e.source === activeNode) set.add(e.target)
if (e.target === activeNode) set.add(e.source)
})
return set
}, [activeNode, 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 overflow-hidden"
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}
cooldownTicks={100}
// Hover- und Klick-Logik
onNodeClick={(node: any) => setSelected(node.id)}
onBackgroundClick={() => setSelected(null)}
onNodeHover={(node: any) => {
setHovered(node ? node.id : null)
if (containerRef.current) {
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 x = node.x ?? 0
const y = node.y ?? 0
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 und dieser kein Nachbar ist
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(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(x, 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(x, 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 = x - textWidth / 2 - bpad
const ry = 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, x, 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 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]) => (
<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 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 cursor-pointer">
<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>
)
}