From ae3e79bf8f410eb97b0a6cc3b2bbfe2cf9f39d75 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 23 Jun 2026 19:15:43 +0100 Subject: [PATCH] fix: graph view on brave --- frontend/src/components/GraphView.tsx | 106 ++++++++---------- .../src/components/graphViewUtils.test.ts | 69 +++++++----- frontend/src/components/graphViewUtils.ts | 56 +++++---- 3 files changed, 124 insertions(+), 107 deletions(-) diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index f8e446fe..f547b140 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -1,12 +1,6 @@ import { forceCollide, type SimulationNodeDatum } from 'd3-force'; import { Network, X } from 'lucide-react'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d'; @@ -23,9 +17,8 @@ import { GraphOverview, collideRadius, maxDegree, - nodeLabelEl, + nodeAtPoint, nodeRadius, - pointerAreaRadius, toForceGraphData, } from './graphViewUtils'; @@ -36,6 +29,7 @@ interface GraphViewProps { } const GRAPH_LIMIT = 100; +const HIT_SLOP = 4; const GraphView: React.FC = ({ docId, @@ -56,14 +50,6 @@ const GraphView: React.FC = ({ const hoveredNodeIdRef = useRef(null); const fgRef = useRef(undefined); const [size, setSize] = useState({ width: 0, height: 480 }); - // Bumped after zoom/pan settles to re-supply nodePointerAreaPaint, which makes - // force-graph repaint the hit-test (pick) canvas immediately. The engine - // otherwise throttles that repaint by 800ms, leaving the pick discs at their - // pre-zoom positions — so just after a zoom the cursor reads stale/empty - // pixels and hover/click silently fail. The miss scales with the screen - // distance a node moved (largest for the biggest, highest-degree hubs) and is - // doubled on Retina, where each pick reads a single device pixel. - const [pickRefresh, setPickRefresh] = useState(0); useEffect(() => { let cancelled = false; @@ -125,23 +111,45 @@ const GraphView: React.FC = ({ .finally(() => setLoadingNode(false)); }; - // Paints the hit-test (pick) disc for a node. Its identity is keyed on - // `pickRefresh` so re-supplying it after a zoom/pan settles forces force-graph - // to repaint the pick canvas immediately rather than after its 800ms throttle. - const nodePointerAreaPaint = useCallback( - (node: object, color: string, ctx: CanvasRenderingContext2D) => { - const graphNode = node as GraphNode & { x?: number; y?: number }; - if (graphNode.x == null || graphNode.y == null) return; - const r = pointerAreaRadius(nodeRadius(graphNode.degree, maxNodeDegree)); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(graphNode.x, graphNode.y, r, 0, 2 * Math.PI); - ctx.fill(); - }, - // pickRefresh is an intentional dependency: bumping it re-supplies this - // callback, which makes force-graph flush the pick canvas after a zoom/pan. - [maxNodeDegree, pickRefresh], - ); + // Geometric hit test, immune to canvas read-back farbling (e.g. Brave): map + // the pointer into graph coordinates and pick the nearest node directly. + const pickNodeAt = (clientX: number, clientY: number): GraphNode | null => { + const fg = fgRef.current; + if (!fg || !containerRef.current) return null; + const rect = containerRef.current.getBoundingClientRect(); + const g = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top); + return nodeAtPoint(data.nodes, g.x, g.y, maxNodeDegree, HIT_SLOP); + }; + + const repaint = () => { + const fg = fgRef.current; + if (fg) fg.zoom(fg.zoom()); + }; + + const handlePointerMove = (event: React.MouseEvent) => { + const node = pickNodeAt(event.clientX, event.clientY); + if (containerRef.current) { + containerRef.current.style.cursor = node ? 'pointer' : 'default'; + } + const nextId = node?.id ?? null; + if (nextId !== hoveredNodeIdRef.current) { + hoveredNodeIdRef.current = nextId; + repaint(); + } + }; + + const handlePointerLeave = () => { + if (containerRef.current) containerRef.current.style.cursor = 'default'; + if (hoveredNodeIdRef.current !== null) { + hoveredNodeIdRef.current = null; + repaint(); + } + }; + + const handleContainerClick = (event: React.MouseEvent) => { + const node = pickNodeAt(event.clientX, event.clientY); + if (node) handleNodeClick(node); + }; const isEmpty = !loading && data.nodes.length === 0; @@ -196,6 +204,9 @@ const GraphView: React.FC = ({
{size.width > 0 && ( @@ -205,36 +216,13 @@ const GraphView: React.FC = ({ width={size.width} height={size.height} nodeRelSize={1} + enablePointerInteraction={false} + enableNodeDrag={false} nodeVal={(node) => nodeRadius((node as GraphNode).degree, maxNodeDegree) } - nodeLabel={(node) => - nodeLabelEl( - (node as GraphNode).name, - ) as unknown as React.ReactHTMLElement - } linkColor={() => 'rgba(150,150,150,0.35)'} - linkPointerAreaPaint={() => {}} cooldownTicks={80} - onEngineStop={() => { - const fg = fgRef.current; - if (!fg) return; - fg.zoom(fg.zoom()); - setPickRefresh((n) => n + 1); - }} - onZoomEnd={() => setPickRefresh((n) => n + 1)} - onNodeClick={(node) => handleNodeClick(node as GraphNode)} - onNodeHover={(node) => { - hoveredNodeIdRef.current = node - ? (node as GraphNode).id - : null; - if (containerRef.current) { - containerRef.current.style.cursor = node - ? 'pointer' - : 'default'; - } - }} - nodePointerAreaPaint={nodePointerAreaPaint} nodeCanvasObject={(node, ctx, globalScale) => { const graphNode = node as GraphNode & { x?: number; diff --git a/frontend/src/components/graphViewUtils.test.ts b/frontend/src/components/graphViewUtils.test.ts index 05a515b1..5a68f0e4 100644 --- a/frontend/src/components/graphViewUtils.test.ts +++ b/frontend/src/components/graphViewUtils.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + GraphNode, GraphOverview, collideRadius, maxDegree, + nodeAtPoint, nodeLabelEl, nodeRadius, - pointerAreaRadius, toForceGraphData, } from './graphViewUtils'; @@ -83,32 +84,48 @@ describe('nodeRadius', () => { }); }); -describe('pointerAreaRadius', () => { - it('enforces a forgiving minimum hit target for small nodes', () => { - const smallest = nodeRadius(0, 0); - expect(pointerAreaRadius(smallest)).toBe(9); - }); - - it('always pads beyond the visual radius for larger nodes', () => { - const largest = nodeRadius(10, 10); - expect(pointerAreaRadius(largest)).toBeGreaterThan(largest); - expect(pointerAreaRadius(largest)).toBeCloseTo(14); - }); -}); - describe('collideRadius', () => { - it('keeps centres farther apart than any node could be picked', () => { - // The picking guarantee: with forceCollide(collideRadius), two centres - // are >= 2*collideRadius apart, which must exceed the largest pick disc - // so a centre is never covered by a neighbour's pick disc. - const smallVisual = nodeRadius(0, 0); - const largeVisual = nodeRadius(10, 10); - const minCentreSpacing = 2 * collideRadius(smallVisual); - expect(minCentreSpacing).toBeGreaterThan(pointerAreaRadius(largeVisual)); - }); - - it('always exceeds the pick disc for the same node', () => { + it('pads beyond the visual radius so centres stay apart', () => { const visual = nodeRadius(3, 57); - expect(collideRadius(visual)).toBeGreaterThan(pointerAreaRadius(visual)); + expect(collideRadius(visual)).toBeGreaterThan(visual); + }); +}); + +describe('nodeAtPoint', () => { + const node = ( + id: string, + x: number | null, + y: number | null, + degree = 1, + ): GraphNode => ({ id, name: id, degree, x, y }) as GraphNode; + + it('returns the node when the point is inside its hit radius', () => { + const nodes = [node('a', 0, 0)]; + const hit = nodeAtPoint(nodes, 2, 0, 1, 4); + expect(hit?.id).toBe('a'); + }); + + it('returns null when the point is outside the hit radius', () => { + const nodes = [node('a', 0, 0)]; + expect(nodeAtPoint(nodes, 100, 100, 1, 4)).toBeNull(); + }); + + it('resolves overlaps to the node with the nearest centre', () => { + const nodes = [node('far', 5, 0), node('near', 1, 0)]; + const hit = nodeAtPoint(nodes, 1.2, 0, 1, 6); + expect(hit?.id).toBe('near'); + }); + + it('skips nodes with null coordinates', () => { + const nodes = [node('ghost', null, null), node('real', 0, 0)]; + const hit = nodeAtPoint(nodes, 0, 0, 1, 4); + expect(hit?.id).toBe('real'); + }); + + it('respects the slop allowance', () => { + const nodes = [node('a', 0, 0)]; // radius 3 at degree/max 1/1 => 12 + const r = nodeRadius(1, 1); + expect(nodeAtPoint(nodes, r + 1, 0, 1, 2)?.id).toBe('a'); + expect(nodeAtPoint(nodes, r + 3, 0, 1, 2)).toBeNull(); }); }); diff --git a/frontend/src/components/graphViewUtils.ts b/frontend/src/components/graphViewUtils.ts index 432ddd74..ea72a9db 100644 --- a/frontend/src/components/graphViewUtils.ts +++ b/frontend/src/components/graphViewUtils.ts @@ -44,9 +44,7 @@ export function toForceGraphData(overview: GraphOverview): ForceGraphData { const MIN_NODE_RADIUS = 3; const MAX_NODE_RADIUS = 12; -const MIN_POINTER_RADIUS = 7; -const POINTER_RADIUS_PAD = 2; -const COLLIDE_PAD = 2; +const COLLIDE_PAD = 4; export function nodeRadius(degree: number, maxDegree: number): number { if (maxDegree <= 0) return MIN_NODE_RADIUS; @@ -54,32 +52,46 @@ export function nodeRadius(degree: number, maxDegree: number): number { return MIN_NODE_RADIUS + scale * (MAX_NODE_RADIUS - MIN_NODE_RADIUS); } -/** - * Hit-test disc radius for a node in the color-picking canvas. - * - * Kept deliberately close to the visual radius: the engine resolves - * hover/click by reading a single pixel at the node centre, so a pick disc - * larger than the inter-node spacing lets a neighbour's disc cover this - * node's centre and steal its picks. Pair with {@link collideRadius}, which - * spaces centres farther apart than the largest pick disc so a centre is - * never covered by another node. - */ -export function pointerAreaRadius(visualRadius: number): number { - return Math.max(visualRadius, MIN_POINTER_RADIUS) + POINTER_RADIUS_PAD; -} - -/** - * Collision radius keeping node centres farther apart than any pick disc, - * so every centre pixel stays clean for the engine's exact-colour lookup. - */ +/** Collision radius spacing node centres apart for a readable layout. */ export function collideRadius(visualRadius: number): number { - return pointerAreaRadius(visualRadius) + COLLIDE_PAD; + return visualRadius + COLLIDE_PAD; } export function maxDegree(nodes: GraphNode[]): number { return nodes.reduce((acc, node) => Math.max(acc, node.degree || 0), 0); } +type PositionedNode = GraphNode & { x?: number; y?: number }; + +/** + * Geometric hit test in graph coordinates: returns the node whose centre is + * nearest to (gx, gy) and within its visual radius plus `slop`, or null. + * Resolves overlaps to the closest centre and skips nodes without a position. + */ +export function nodeAtPoint( + nodes: GraphNode[], + gx: number, + gy: number, + maxDegree: number, + slop: number, +): GraphNode | null { + let best: GraphNode | null = null; + let bestDistSq = Infinity; + for (const node of nodes) { + const positioned = node as PositionedNode; + if (positioned.x == null || positioned.y == null) continue; + const hitRadius = nodeRadius(node.degree, maxDegree) + slop; + const dx = positioned.x - gx; + const dy = positioned.y - gy; + const distSq = dx * dx + dy * dy; + if (distSq <= hitRadius * hitRadius && distSq < bestDistSq) { + best = node; + bestDistSq = distSq; + } + } + return best; +} + export function nodeLabelEl(name: string): HTMLDivElement { const el = document.createElement('div'); el.textContent = name ?? '';