fix: graph view on brave

This commit is contained in:
Alex committed 2026-06-23 19:15:43 +01:00
1 parent 6e4480b61b
commit ae3e79bf8f
3 files changed
+124 -107

No files matched your search

+47 -59
View File
@@ -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<GraphViewProps> = ({
docId,
@@ -56,14 +50,6 @@ const GraphView: React.FC<GraphViewProps> = ({
const hoveredNodeIdRef = useRef<string | null>(null);
const fgRef = useRef<ForceGraphMethods | undefined>(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<GraphViewProps> = ({
.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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<GraphViewProps> = ({
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleContainerClick}
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
>
{size.width > 0 && (
@@ -205,36 +216,13 @@ const GraphView: React.FC<GraphViewProps> = ({
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<HTMLElement>
}
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;
+43 -26
View File
@@ -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();
});
});
+34 -22
View File
@@ -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 ?? '';