Files
mission-control-v2/frontend/src/views/GraphView.tsx
T
Hitonabi 35be5e13f6 Politur: Gedaechtnis-Graph Hover/Klick via eigenes Picking exakt gemacht
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>
2026-07-09 13:20:54 +02:00

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>
)
}