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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-gapT4XnB.js"></script>
<script type="module" crossorigin src="/assets/index-BGmfM64G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css">
</head>
<body>
+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}