Politur: Verschmelzung von Liste, Graph und Details zu einem einheitlichen, schwebenden Glassmorphism-Dashboard (Graph im Hintergrund, Panels schweben elegant davor)
This commit is contained in:
+130
-207
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import ForceGraph2D from "react-force-graph-2d"
|
||||
import { forceCollide, forceX, forceY } from "d3-force"
|
||||
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { type MemoryGraph } from "@/lib/api"
|
||||
|
||||
const CAT_COLOR: Record<string, string> = {
|
||||
@@ -10,7 +10,6 @@ const CAT_COLOR: Record<string, string> = {
|
||||
const CAT_LABEL: Record<string, string> = {
|
||||
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
|
||||
}
|
||||
const AUTO = new Set(["auto", "agent", "hermes"])
|
||||
const EDGE_HI = "rgba(99, 102, 241, 0.9)"
|
||||
const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
|
||||
|
||||
@@ -22,8 +21,11 @@ function hexToRgba(hex: string, alpha: number): string {
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
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)
|
||||
@@ -114,27 +116,14 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
fg.d3ReheatSimulation()
|
||||
}, [graphData])
|
||||
|
||||
const activeNode = hovered || selected
|
||||
const activeNode = hovered || selectedNodeId
|
||||
|
||||
// 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>()
|
||||
@@ -146,207 +135,141 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
|
||||
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%)" }}>
|
||||
<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)"
|
||||
nodeRelSize={4}
|
||||
cooldownTicks={120}
|
||||
|
||||
<ForceGraph2D
|
||||
ref={fgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
graphData={graphData}
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
nodeRelSize={4}
|
||||
cooldownTicks={120}
|
||||
// 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
|
||||
|
||||
// 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"
|
||||
}
|
||||
}}
|
||||
if (activeNode === sId || activeNode === tId) return EDGE_HI
|
||||
if (activeNode) return DIM_EDGE
|
||||
|
||||
// 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
|
||||
|
||||
// Highlight-Kanten leuchten in Indigo auf
|
||||
if (activeNode === sId || activeNode === tId) return EDGE_HI
|
||||
|
||||
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus
|
||||
if (activeNode) return DIM_EDGE
|
||||
|
||||
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25)
|
||||
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
|
||||
}}
|
||||
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 = 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 === selectedNodeId
|
||||
|
||||
// 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
|
||||
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
|
||||
if (!isDimmed) {
|
||||
ctx.shadowColor = color
|
||||
ctx.shadowBlur = isHovered || isSelected ? 12 : 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, 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.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()
|
||||
}
|
||||
|
||||
// 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)))
|
||||
}}
|
||||
/>
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
|
||||
ctx.beginPath()
|
||||
|
||||
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()
|
||||
|
||||
<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>
|
||||
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()
|
||||
|
||||
{/* 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>
|
||||
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)))
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user