304 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|