305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|