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

305 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d"
import { forceCollide, forceX, forceY } from "d3-force"
import { 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 EDGE_HI = "rgba(99, 102, 241, 0.9)"
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
// Hilfsfunktion zur Konvertierung von Hex in RGBA für farbkodierte Verbindungen
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
data: MemoryGraph
selectedNodeId: string | null
onNodeSelect: (id: string | null) => void
}) {
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])
// Grad (Degree) berechnen fängt D3-Objektmutationen robust ab!
const degree = useMemo(() => {
const d: Record<string, number> = {}
data.edges.forEach((e) => {
const sId = typeof e.source === "object" ? (e.source as any).id : e.source
const tId = typeof e.target === "object" ? (e.target as any).id : e.target
if (sId && tId) {
d[sId] = (d[sId] || 0) + 1
d[tId] = (d[tId] || 0) + 1
}
})
return d
}, [data])
// Quadranten-Fokuskoordinaten für sauberes Clustering (Fokuszentren weiter auseinander gezogen)
const getClusterX = (category: string) => {
return {
identity: -240,
knowledge: 240,
rules: -240,
events: 240,
}[category] || 0
}
const getClusterY = (category: string) => {
return {
identity: -240,
knowledge: -240,
rules: 240,
events: 240,
}[category] || 0
}
// D3 Force Engine tunen (Luftige Struktur, kein Überlappen, Clustering)
useEffect(() => {
const fg = fgRef.current
if (!fg) return
// 1. Standard-Zentrumskraft entfernen (erlaubt freies Clustering in Quadranten)
fg.d3Force("center", null)
// 2. Clustering-Kräfte (Knoten zu ihren jeweiligen Sektions-Foci ziehen)
fg.d3Force("x", forceX().x((node: any) => getClusterX(node.category)).strength(0.07))
fg.d3Force("y", forceY().y((node: any) => getClusterY(node.category)).strength(0.07))
// 3. Starke Abstoßung (Charge) für ein weit gefächertes, luftiges Netz
const charge = fg.d3Force("charge")
if (charge) charge.strength(-380)
// 4. Kollisionskraft (Sicherer Radius, verhindert Überlappung)
fg.d3Force("collide", forceCollide().radius((node: any) => {
const isLarge = node.id === hovered || node.id === selectedNodeId
return isLarge ? 50 : 25
}).iterations(2))
// 5. Federkraft (Links)
const link = fg.d3Force("link")
if (link) link.distance(85)
// Simulation neu starten (Reheat)
fg.d3ReheatSimulation()
}, [graphData, hovered, selectedNodeId])
const activeNode = hovered || selectedNodeId
// Layout zurücksetzen/neu anschubsen
const relayout = () => {
if (!fgRef.current) return
fgRef.current.zoomToFit(400, 40)
}
// Nachbarmenge für extrem schnellen Lookup fängt D3-Objektmutationen robust ab!
const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>()
const set = new Set<string>()
data.edges.forEach((e) => {
const sId = typeof e.source === "object" ? (e.source as any).id : e.source
const tId = typeof e.target === "object" ? (e.target as any).id : e.target
if (sId === activeNode) set.add(tId)
if (tId === activeNode) set.add(sId)
})
return set
}, [activeNode, data])
return (
<div 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)"
cooldownTicks={120}
// Exakte Hit-Map auf unsichtbarem Canvas zeichnen fängt D3-Objektmutationen robust ab!
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
const x = node.x ?? 0
const y = node.y ?? 0
const nId = typeof node === "object" ? node.id : node
const size = 6 + Math.min(degree[nId] || 0, 15) * 0.8
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(x, y, size + 5, 0, 2 * Math.PI, false) // 5px Puffer für einfaches Treffen
ctx.fill()
}}
// Hover- und Klick-Logik
onNodeClick={(node: any) => onNodeSelect(node.id)}
onBackgroundClick={() => onNodeSelect(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
if (activeNode) return DIM_EDGE
const sourceNode = graphData.nodes.find(n => n.id === sId)
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
return hexToRgba(catColor, 0.25)
}}
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.5 : 1.4
}}
// 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 = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered
const isSelected = node.id === selectedNodeId
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color
// 1. Zeichne den farbigen Knotenpunkt (Kreis)
if (!isDimmed) {
ctx.shadowColor = color
ctx.shadowBlur = isHovered || isSelected ? 15 : 6
}
ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor
ctx.fill()
ctx.shadowBlur = 0
if (isHovered || isSelected) {
ctx.beginPath()
ctx.arc(x, y, size + 2.5, 0, 2 * Math.PI, false)
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.lineWidth = 1.5
ctx.stroke()
}
// 2. Zeichne das Label nur unter bestimmten Bedingungen (Zoom-abhängig)
// Verhindert Text-Clutter und macht Knotenpunkte permanent sichtbar.
const showLabel = isHovered || isSelected || (globalScale >= 1.25) || (activeNode && connectedNodes.has(node.id))
if (showLabel) {
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
const baseFontSize = isHovered || isSelected ? 10.5 : 9.5
const fontSize = baseFontSize / Math.max(0.5, globalScale)
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
const textWidth = ctx.measureText(label).width
const bpad = 4.5 / Math.max(0.5, globalScale)
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)"
ctx.beginPath()
const rx = x - textWidth / 2 - bpad
const ry = y + size + (6 / Math.max(0.5, globalScale))
const rw = textWidth + bpad * 2
const rh = fontSize + bpad * 1.6
const rad = 4 / Math.max(0.5, 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()
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.08)"
ctx.lineWidth = 0.6 / Math.max(0.5, globalScale)
ctx.stroke()
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (7 / Math.max(0.5, globalScale)))
}
}}
/>
<button onClick={relayout} title="Zentrieren"
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>
)
}