35be5e13f6
Ursache im force-graph-Internals gefunden: die Hit-Erkennung laeuft ueber eine Schatten-Canvas, die nur alle 800 ms aktualisiert wird (HOVER_CANVAS_THROTTLE_DELAY) - waehrend Simulation/Zoom/Pan zeigt sie auf Positionen von vor fast einer Sekunde, und Klicks werden ueber genau dieses veraltete hoverObj ausgeloest. Dazu verschluckt das eingebaute Node-Dragging Klicks ab 2 px Mausbewegung. - Eigenes Picking gegen LIVE-Knotenkoordinaten (screen2GraphCoords + Naechster- Nachbar, Trefferradius min. ~16 Bildschirm-Pixel, zoom-kompensiert) - enablePointerInteraction=false: Schatten-Canvas wird gar nicht mehr gemalt (Perf) - enableNodeDrag=false: kein Klick-Verschlucken, kein Layout-Anschubsen beim Klicken - Pan-Schutz: >6 px Mausweg zaehlt nicht als Klick E2E im Browser verifiziert: Hover (Cursor), Knoten-Klick oeffnet Detail, Hintergrund-Klick waehlt ab, Pan aendert Auswahl nicht. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import { useCallback, 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)"
|
|
const FALLBACK_EDGE = "rgba(100, 116, 139, 0.25)"
|
|
|
|
// Ruhefarben der Kanten je Kategorie EINMAL vorberechnen — niemals pro Frame rechnen.
|
|
const CAT_EDGE_COLOR: Record<string, string> = {
|
|
identity: "rgba(0, 245, 255, 0.25)", knowledge: "rgba(59, 130, 246, 0.25)",
|
|
rules: "rgba(217, 70, 239, 0.25)", events: "rgba(251, 191, 36, 0.25)",
|
|
}
|
|
// Halo-Farben (billiger Glow-Ersatz für ctx.shadowBlur, das pro Knoten/Frame unbezahlbar ist)
|
|
const CAT_HALO_COLOR: Record<string, string> = {
|
|
identity: "rgba(0, 245, 255, 0.16)", knowledge: "rgba(59, 130, 246, 0.16)",
|
|
rules: "rgba(217, 70, 239, 0.16)", events: "rgba(251, 191, 36, 0.16)",
|
|
}
|
|
|
|
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()
|
|
}, [])
|
|
|
|
// Knoten-Objekte über Daten-Updates hinweg WIEDERVERWENDEN: D3 behält x/y auf dem
|
|
// Objekt — nur so überleben die Positionen Refetches und Suchfilter, statt dass der
|
|
// ganze Graph bei jedem Update neu explodiert (und Knoten unterm Cursor wegwandern).
|
|
const nodeCacheRef = useRef(new Map<string, any>())
|
|
|
|
const graphData = useMemo(() => {
|
|
const cache = nodeCacheRef.current
|
|
const seen = new Set<string>()
|
|
const nodes = data.nodes.map((n) => {
|
|
seen.add(n.id)
|
|
let node = cache.get(n.id)
|
|
if (!node) {
|
|
node = { ...n }
|
|
cache.set(n.id, node)
|
|
} else if (node.content !== n.content || node.category !== n.category) {
|
|
node.content = n.content
|
|
node.category = n.category
|
|
node.__baseTextWidth = undefined
|
|
}
|
|
return node
|
|
})
|
|
for (const id of Array.from(cache.keys())) {
|
|
if (!seen.has(id)) cache.delete(id)
|
|
}
|
|
|
|
const catById = new Map(data.nodes.map((n) => [n.id, n.category]))
|
|
const links = data.edges.map((e) => ({
|
|
source: e.source,
|
|
target: e.target,
|
|
weight: e.weight || 0,
|
|
// Stabile String-IDs behalten (D3 ersetzt source/target durch Objektreferenzen)
|
|
__sid: e.source,
|
|
__tid: e.target,
|
|
__restColor: CAT_EDGE_COLOR[catById.get(e.source) ?? ""] ?? FALLBACK_EDGE,
|
|
}))
|
|
return { nodes, links }
|
|
}, [data])
|
|
|
|
// Grad (Degree) berechnen
|
|
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])
|
|
|
|
// Quadranten-Fokuskoordinaten für sauberes Clustering
|
|
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
|
|
}
|
|
|
|
// Reheat nur, wenn sich die STRUKTUR ändert (Knoten-/Kantenmenge) — nicht bei jeder
|
|
// neuen Objekt-Identität der Daten. Verhindert Layout-Neustarts bei blossen Refetches.
|
|
const structureKey = useMemo(() => {
|
|
return `${data.nodes.map((n) => n.id).sort().join(",")}#${data.edges.length}`
|
|
}, [data])
|
|
|
|
useEffect(() => {
|
|
const fg = fgRef.current
|
|
if (!fg) return
|
|
|
|
// 1. Standard-Zentrumskraft entfernen
|
|
fg.d3Force("center", null)
|
|
|
|
// 2. Clustering-Kräfte
|
|
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. Konstanter, stabiler Kollisionsradius
|
|
fg.d3Force("collide", forceCollide().radius(28).iterations(2))
|
|
|
|
// 5. Federkraft (Links)
|
|
const link = fg.d3Force("link")
|
|
if (link) link.distance(85)
|
|
|
|
// Simulation einmalig heiss starten für die neue Struktur
|
|
fg.d3ReheatSimulation()
|
|
}, [structureKey])
|
|
|
|
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
|
|
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])
|
|
|
|
// Stabile Accessor-Identitäten: nur neu, wenn sich der aktive Knoten ändert.
|
|
// Nutzt die vorberechneten __sid/__tid/__restColor — KEIN nodes.find() pro Frame mehr.
|
|
const linkColor = useCallback((link: any) => {
|
|
if (activeNode) {
|
|
return (link.__sid === activeNode || link.__tid === activeNode) ? EDGE_HI : DIM_EDGE
|
|
}
|
|
return link.__restColor
|
|
}, [activeNode])
|
|
|
|
const linkWidth = useCallback((link: any) => {
|
|
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 2.5 : 1.4
|
|
}, [activeNode])
|
|
|
|
const linkParticles = useCallback((link: any) => {
|
|
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
|
|
}, [activeNode])
|
|
|
|
// EIGENES Picking statt der Bibliotheks-Hit-Map: force-graph erkennt Hover/Klick
|
|
// über eine Schatten-Canvas, die nur alle 800 ms aktualisiert wird — solange die
|
|
// Simulation läuft oder gezoomt wird, zeigt die Erkennung auf Positionen von vor
|
|
// fast einer Sekunde. Wir rechnen stattdessen exakt gegen die LIVE-Koordinaten
|
|
// der Knoten (319 Distanzchecks pro Mausbewegung sind vernachlässigbar).
|
|
const pickNode = useCallback((clientX: number, clientY: number) => {
|
|
const fg = fgRef.current
|
|
const el = containerRef.current
|
|
if (!fg || !el) return null
|
|
const rect = el.getBoundingClientRect()
|
|
const { x: gx, y: gy } = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top)
|
|
const k = Math.max(fg.zoom() || 1, 0.01)
|
|
let best: any = null
|
|
let bestD2 = Infinity
|
|
for (const n of graphData.nodes as any[]) {
|
|
if (n.x == null || n.y == null) continue
|
|
const size = 6 + Math.min(degree[n.id] || 0, 15) * 0.8
|
|
// Trefferradius nie kleiner als ~16 Bildschirm-Pixel, egal wie weit rausgezoomt
|
|
const hitR = Math.max(size + 3, 16 / k)
|
|
const dx = n.x - gx
|
|
const dy = n.y - gy
|
|
const d2 = dx * dx + dy * dy
|
|
if (d2 <= hitR * hitR && d2 < bestD2) { bestD2 = d2; best = n }
|
|
}
|
|
return best
|
|
}, [graphData, degree])
|
|
|
|
const handlePointerMove = useCallback((ev: React.PointerEvent) => {
|
|
// Nur auf der Canvas picken — über Buttons/Legende gilt: kein Hover
|
|
const node = ev.target instanceof HTMLCanvasElement ? pickNode(ev.clientX, ev.clientY) : null
|
|
setHovered(node ? node.id : null)
|
|
if (containerRef.current) {
|
|
containerRef.current.style.cursor = node ? "pointer" : "default"
|
|
}
|
|
}, [pickNode])
|
|
|
|
const handlePointerLeave = useCallback(() => {
|
|
setHovered(null)
|
|
if (containerRef.current) containerRef.current.style.cursor = "default"
|
|
}, [])
|
|
|
|
// Klick selbst auswerten — aber nicht nach einem Pan (Mausweg > 6 px zählt nicht als Klick)
|
|
const downPosRef = useRef<{ x: number; y: number } | null>(null)
|
|
const handlePointerDown = useCallback((ev: React.PointerEvent) => {
|
|
downPosRef.current = { x: ev.clientX, y: ev.clientY }
|
|
}, [])
|
|
|
|
const handleClick = useCallback((ev: React.MouseEvent) => {
|
|
if (!(ev.target instanceof HTMLCanvasElement)) return // Buttons/Legende nicht abfangen
|
|
const down = downPosRef.current
|
|
if (down && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) > 6) return
|
|
const node = pickNode(ev.clientX, ev.clientY)
|
|
onNodeSelect(node ? node.id : null)
|
|
}, [pickNode, onNodeSelect])
|
|
|
|
const nodeCanvasObject = useCallback((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. Billiger Glow: transparenter Halo-Kreis statt ctx.shadowBlur (das kostet bei
|
|
// hunderten Knoten pro Frame die gesamte Framerate). Echter Blur nur für den
|
|
// EINEN aktiven Knoten.
|
|
if (!isDimmed) {
|
|
ctx.beginPath()
|
|
ctx.arc(x, y, size * 1.9, 0, 2 * Math.PI, false)
|
|
ctx.fillStyle = CAT_HALO_COLOR[node.category] || "rgba(100, 116, 139, 0.14)"
|
|
ctx.fill()
|
|
}
|
|
|
|
if (isHovered || isSelected) {
|
|
ctx.shadowColor = color
|
|
ctx.shadowBlur = 15
|
|
}
|
|
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. Label nur für den aktiven Fokus (Hovered, Selected & direkte Nachbarn)
|
|
const showLabel = isHovered || isSelected || (activeNode && connectedNodes.has(node.id))
|
|
|
|
if (showLabel) {
|
|
const label = node.content.length > 35 ? node.content.slice(0, 34) + "…" : node.content
|
|
|
|
// Textbreite EINMALIG auf dem Objekt cachen (ctx.measureText ist teuer)
|
|
if (node.__baseTextWidth === undefined) {
|
|
const prevFont = ctx.font
|
|
ctx.font = '500 10px "Space Grotesk", sans-serif'
|
|
node.__baseTextWidth = ctx.measureText(label).width
|
|
ctx.font = prevFont
|
|
}
|
|
|
|
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`
|
|
|
|
// Breite linear zur geänderten FontSize skalieren — kein measureText pro Frame
|
|
const textWidth = node.__baseTextWidth * (fontSize / 10)
|
|
const bpad = 4.5 / Math.max(0.5, globalScale)
|
|
|
|
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
|
|
|
|
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.88)"
|
|
ctx.fillRect(rx, ry, rw, rh)
|
|
|
|
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.strokeRect(rx, ry, rw, rh)
|
|
|
|
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)))
|
|
}
|
|
}, [degree, hovered, selectedNodeId, activeNode, connectedNodes])
|
|
|
|
return (
|
|
<div className="absolute inset-0" ref={containerRef}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerLeave={handlePointerLeave}
|
|
onPointerDown={handlePointerDown}
|
|
onClick={handleClick}
|
|
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}
|
|
// Bibliotheks-Picking komplett aus: Hover/Klick machen wir selbst (exakt,
|
|
// ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an.
|
|
enablePointerInteraction={false}
|
|
enableNodeDrag={false}
|
|
linkColor={linkColor}
|
|
linkWidth={linkWidth}
|
|
linkDirectionalParticles={linkParticles}
|
|
linkDirectionalParticleWidth={2.2}
|
|
linkDirectionalParticleSpeed={0.006}
|
|
nodeCanvasObject={nodeCanvasObject}
|
|
/>
|
|
|
|
<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>
|
|
)
|
|
}
|