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>
This commit is contained in:
Hitonabi
2026-07-09 13:20:54 +02:00
parent dfcf86902a
commit 35be5e13f6
5 changed files with 102 additions and 61 deletions
+56 -15
View File
@@ -188,23 +188,60 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
}, [activeNode])
const handleNodeHover = useCallback((node: any) => {
// 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"
}, [])
// Hit-Map: Trefferfläche wird mit dem Zoom NICHT beliebig klein — mindestens ~14
// Bildschirm-Pixel Radius, sonst sind Knoten rausgezoomt unmöglich zu treffen.
const nodePointerAreaPaint = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => {
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
const r = Math.max(size + 5, 14 / Math.max(globalScale || 1, 0.01))
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI, false)
ctx.fill()
}, [degree])
// 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
@@ -290,6 +327,10 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
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
@@ -299,10 +340,10 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
graphData={graphData}
backgroundColor="rgba(0,0,0,0)"
cooldownTicks={120}
nodePointerAreaPaint={nodePointerAreaPaint}
onNodeClick={(node: any) => onNodeSelect(node.id)}
onBackgroundClick={() => onNodeSelect(null)}
onNodeHover={handleNodeHover}
// 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}