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:
+44
File diff suppressed because one or more lines are too long
-44
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Mission Control 2.0</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index--2fz5jZz.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -188,23 +188,60 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
|
return (activeNode && (link.__sid === activeNode || link.__tid === activeNode)) ? 4 : 0
|
||||||
}, [activeNode])
|
}, [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)
|
setHovered(node ? node.id : null)
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
containerRef.current.style.cursor = node ? "pointer" : "default"
|
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
|
// Klick selbst auswerten — aber nicht nach einem Pan (Mausweg > 6 px zählt nicht als Klick)
|
||||||
// Bildschirm-Pixel Radius, sonst sind Knoten rausgezoomt unmöglich zu treffen.
|
const downPosRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const nodePointerAreaPaint = useCallback((node: any, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
const handlePointerDown = useCallback((ev: React.PointerEvent) => {
|
||||||
const size = 6 + Math.min(degree[node.id] || 0, 15) * 0.8
|
downPosRef.current = { x: ev.clientX, y: ev.clientY }
|
||||||
const r = Math.max(size + 5, 14 / Math.max(globalScale || 1, 0.01))
|
}, [])
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.beginPath()
|
const handleClick = useCallback((ev: React.MouseEvent) => {
|
||||||
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI, false)
|
if (!(ev.target instanceof HTMLCanvasElement)) return // Buttons/Legende nicht abfangen
|
||||||
ctx.fill()
|
const down = downPosRef.current
|
||||||
}, [degree])
|
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 nodeCanvasObject = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
const x = node.x ?? 0
|
const x = node.x ?? 0
|
||||||
@@ -290,6 +327,10 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0" ref={containerRef}
|
<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%)" }}>
|
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
|
||||||
|
|
||||||
<ForceGraph2D
|
<ForceGraph2D
|
||||||
@@ -299,10 +340,10 @@ export function GraphView({ data, selectedNodeId, onNodeSelect }: {
|
|||||||
graphData={graphData}
|
graphData={graphData}
|
||||||
backgroundColor="rgba(0,0,0,0)"
|
backgroundColor="rgba(0,0,0,0)"
|
||||||
cooldownTicks={120}
|
cooldownTicks={120}
|
||||||
nodePointerAreaPaint={nodePointerAreaPaint}
|
// Bibliotheks-Picking komplett aus: Hover/Klick machen wir selbst (exakt,
|
||||||
onNodeClick={(node: any) => onNodeSelect(node.id)}
|
// ohne 800-ms-Throttle), Drag verschluckt sonst Klicks und schubst das Layout an.
|
||||||
onBackgroundClick={() => onNodeSelect(null)}
|
enablePointerInteraction={false}
|
||||||
onNodeHover={handleNodeHover}
|
enableNodeDrag={false}
|
||||||
linkColor={linkColor}
|
linkColor={linkColor}
|
||||||
linkWidth={linkWidth}
|
linkWidth={linkWidth}
|
||||||
linkDirectionalParticles={linkParticles}
|
linkDirectionalParticles={linkParticles}
|
||||||
|
|||||||
Reference in New Issue
Block a user