mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 20:13:04 +00:00
fix: graph view on brave
This commit is contained in:
1 parent
6e4480b61b
commit
ae3e79bf8f
3 files changed
+124
-107
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '';
|
||||
|
||||
Reference in new issue
Block a user