Replace custom dependency graph with React Flow
- Install reactflow and dagre for professional graph visualization - Use dagre for automatic tree layout (top-to-bottom) - Custom styled nodes with package name, version, and size - Built-in zoom/pan controls and minimap - Click nodes to navigate to package page - Cleaner, more professional appearance
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
NodeProps,
|
||||
Handle,
|
||||
Position,
|
||||
} from 'reactflow';
|
||||
import dagre from 'dagre';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types';
|
||||
import { resolveDependencies, getArtifactDependencies } from '../api';
|
||||
import './DependencyGraph.css';
|
||||
@@ -11,15 +25,14 @@ interface DependencyGraphProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
interface NodeData {
|
||||
label: string;
|
||||
project: string;
|
||||
package: string;
|
||||
version: string | null;
|
||||
size: number;
|
||||
depth: number;
|
||||
children: GraphNode[];
|
||||
isRoot?: boolean;
|
||||
isRoot: boolean;
|
||||
onNavigate: (project: string, pkg: string) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -30,29 +43,89 @@ function formatBytes(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Custom node component
|
||||
function DependencyNode({ data }: NodeProps<NodeData>) {
|
||||
return (
|
||||
<div
|
||||
className={`flow-node ${data.isRoot ? 'flow-node--root' : ''}`}
|
||||
onClick={() => data.onNavigate(data.project, data.package)}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} className="flow-handle" />
|
||||
<div className="flow-node__name">{data.package}</div>
|
||||
<div className="flow-node__details">
|
||||
{data.version && <span className="flow-node__version">{data.version}</span>}
|
||||
<span className="flow-node__size">{formatBytes(data.size)}</span>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className="flow-handle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { dependency: DependencyNode };
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge[],
|
||||
direction: 'TB' | 'LR' = 'TB'
|
||||
) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 60;
|
||||
|
||||
dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 80 });
|
||||
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: layoutedNodes, edges };
|
||||
}
|
||||
|
||||
function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) {
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolution, setResolution] = useState<DependencyResolutionResponse | null>(null);
|
||||
const [graphRoot, setGraphRoot] = useState<GraphNode | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set());
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<NodeData>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
const handleNavigate = useCallback((project: string, pkg: string) => {
|
||||
navigate(`/project/${project}/${pkg}`);
|
||||
onClose();
|
||||
}, [navigate, onClose]);
|
||||
|
||||
// Build graph structure from resolution data
|
||||
const buildGraph = useCallback(async (resolutionData: DependencyResolutionResponse) => {
|
||||
const buildFlowGraph = useCallback(async (
|
||||
resolutionData: DependencyResolutionResponse,
|
||||
onNavigate: (project: string, pkg: string) => void
|
||||
) => {
|
||||
const artifactMap = new Map<string, ResolvedArtifact>();
|
||||
resolutionData.resolved.forEach(artifact => {
|
||||
artifactMap.set(artifact.artifact_id, artifact);
|
||||
});
|
||||
|
||||
// Fetch dependencies for each artifact to build the tree
|
||||
// Fetch dependencies for each artifact
|
||||
const depsMap = new Map<string, Dependency[]>();
|
||||
|
||||
for (const artifact of resolutionData.resolved) {
|
||||
@@ -64,50 +137,82 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root artifact (the requested one)
|
||||
// Find the root artifact
|
||||
const rootArtifact = resolutionData.resolved.find(
|
||||
a => a.project === resolutionData.requested.project &&
|
||||
a.package === resolutionData.requested.package
|
||||
);
|
||||
|
||||
if (!rootArtifact) {
|
||||
return null;
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
// Build tree recursively
|
||||
const flowNodes: Node<NodeData>[] = [];
|
||||
const flowEdges: Edge[] = [];
|
||||
const visited = new Set<string>();
|
||||
const nodeIdMap = new Map<string, string>(); // artifact_id -> node id
|
||||
|
||||
// Build nodes and edges recursively
|
||||
const processNode = (artifact: ResolvedArtifact, isRoot: boolean) => {
|
||||
if (visited.has(artifact.artifact_id)) {
|
||||
return nodeIdMap.get(artifact.artifact_id);
|
||||
}
|
||||
|
||||
const buildNode = (artifact: ResolvedArtifact, depth: number): GraphNode => {
|
||||
const nodeId = `${artifact.project}/${artifact.package}`;
|
||||
visited.add(artifact.artifact_id);
|
||||
const nodeId = `node-${flowNodes.length}`;
|
||||
nodeIdMap.set(artifact.artifact_id, nodeId);
|
||||
|
||||
flowNodes.push({
|
||||
id: nodeId,
|
||||
type: 'dependency',
|
||||
position: { x: 0, y: 0 }, // Will be set by dagre
|
||||
data: {
|
||||
label: `${artifact.project}/${artifact.package}`,
|
||||
project: artifact.project,
|
||||
package: artifact.package,
|
||||
version: artifact.version || artifact.tag,
|
||||
size: artifact.size,
|
||||
isRoot,
|
||||
onNavigate,
|
||||
},
|
||||
});
|
||||
|
||||
const deps = depsMap.get(artifact.artifact_id) || [];
|
||||
const children: GraphNode[] = [];
|
||||
|
||||
for (const dep of deps) {
|
||||
// Find the resolved artifact for this dependency
|
||||
const childArtifact = resolutionData.resolved.find(
|
||||
a => a.project === dep.project && a.package === dep.package
|
||||
);
|
||||
|
||||
if (childArtifact && !visited.has(childArtifact.artifact_id)) {
|
||||
children.push(buildNode(childArtifact, depth + 1));
|
||||
if (childArtifact) {
|
||||
const childNodeId = processNode(childArtifact, false);
|
||||
if (childNodeId) {
|
||||
flowEdges.push({
|
||||
id: `edge-${nodeId}-${childNodeId}`,
|
||||
source: nodeId,
|
||||
target: childNodeId,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 15,
|
||||
height: 15,
|
||||
color: 'var(--accent-primary)',
|
||||
},
|
||||
style: {
|
||||
stroke: 'var(--border-primary)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
project: artifact.project,
|
||||
package: artifact.package,
|
||||
version: artifact.version || artifact.tag,
|
||||
size: artifact.size,
|
||||
depth,
|
||||
children,
|
||||
isRoot: depth === 0,
|
||||
};
|
||||
return nodeId;
|
||||
};
|
||||
|
||||
return buildNode(rootArtifact, 0);
|
||||
processNode(rootArtifact, true);
|
||||
|
||||
// Apply dagre layout
|
||||
return getLayoutedElements(flowNodes, flowEdges);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,11 +232,11 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
|
||||
setResolution(result);
|
||||
|
||||
const graph = await buildGraph(result);
|
||||
setGraphRoot(graph);
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = await buildFlowGraph(result, handleNavigate);
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
// Check if it's a resolution error
|
||||
try {
|
||||
const errorData = JSON.parse(err.message);
|
||||
if (errorData.error === 'circular_dependency') {
|
||||
@@ -153,101 +258,15 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [projectName, packageName, tagName, buildGraph]);
|
||||
}, [projectName, packageName, tagName, buildFlowGraph, handleNavigate, onClose, setNodes, setEdges]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
navigate(`/project/${node.project}/${node.package}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNodeToggle = (node: GraphNode, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.id)) {
|
||||
next.delete(node.id);
|
||||
} else {
|
||||
next.add(node.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setZoom(z => Math.max(0.25, Math.min(2, z + delta)));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current || (e.target as HTMLElement).classList.contains('graph-canvas')) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const resetView = () => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const renderNode = (node: GraphNode, index: number = 0): JSX.Element => {
|
||||
const isCollapsed = collapsedNodes.has(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={`${node.id}-${index}`} className="graph-node-container">
|
||||
<div
|
||||
className={`graph-node ${node.isRoot ? 'graph-node--root' : ''} ${hoveredNode?.id === node.id ? 'graph-node--hovered' : ''}`}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
onMouseEnter={() => setHoveredNode(node)}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
>
|
||||
<div className="graph-node__header">
|
||||
<span className="graph-node__name">{node.project}/{node.package}</span>
|
||||
{hasChildren && (
|
||||
<button
|
||||
className="graph-node__toggle"
|
||||
onClick={(e) => handleNodeToggle(node, e)}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? '+' : '-'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="graph-node__details">
|
||||
{node.version && <span className="graph-node__version">@ {node.version}</span>}
|
||||
<span className="graph-node__size">{formatBytes(node.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<div className="graph-children">
|
||||
<div className="graph-connector"></div>
|
||||
<div className="graph-children-list">
|
||||
{node.children.map((child, i) => renderNode(child, i))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const defaultViewport = useMemo(() => ({ x: 50, y: 50, zoom: 0.8 }), []);
|
||||
|
||||
return (
|
||||
<div className="dependency-graph-modal" onClick={onClose}>
|
||||
<div className="dependency-graph-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="dependency-graph-header">
|
||||
<h2>DependGraph</h2>
|
||||
<h2>Dependency Graph</h2>
|
||||
<div className="dependency-graph-info">
|
||||
<span>{projectName}/{packageName} @ {tagName}</span>
|
||||
{resolution && (
|
||||
@@ -268,28 +287,7 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dependency-graph-toolbar">
|
||||
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.min(2, z + 0.25))}>
|
||||
Zoom In
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.max(0.25, z - 0.25))}>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={resetView}>
|
||||
Reset View
|
||||
</button>
|
||||
<span className="zoom-level">{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="dependency-graph-container"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div className="dependency-graph-container">
|
||||
{loading ? (
|
||||
<div className="graph-loading">
|
||||
<div className="spinner"></div>
|
||||
@@ -304,16 +302,23 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
</svg>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : graphRoot ? (
|
||||
<div
|
||||
className="graph-canvas"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
}}
|
||||
) : nodes.length > 0 ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
{renderNode(graphRoot)}
|
||||
</div>
|
||||
<Controls />
|
||||
<Background color="var(--border-primary)" gap={20} />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div className="graph-empty">No dependencies to display</div>
|
||||
)}
|
||||
@@ -334,15 +339,6 @@ function DependencyGraph({ projectName, packageName, tagName, onClose }: Depende
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hoveredNode && (
|
||||
<div className="graph-tooltip">
|
||||
<strong>{hoveredNode.project}/{hoveredNode.package}</strong>
|
||||
{hoveredNode.version && <div>Version: {hoveredNode.version}</div>}
|
||||
<div>Size: {formatBytes(hoveredNode.size)}</div>
|
||||
<div className="tooltip-hint">Click to navigate</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user