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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user