Repository: networkx/networkx

Issue:
Shell radius too big for shells of 1 element.
This bug appears to be the opposite of issue #3188 where shells of one element collapsed to zero radius.

```
nx.__version__
'2.4'
```

Shells with one element now get assigned an X position equal to their location +1 in nlist.

shell_layout(nlist=[[1,2],[3, 4],[5, 6], [7], [8]])

{1: array([2.00000003e-01, 8.74227801e-09]),
 2: array([-2.00000003e-01, -8.74227801e-09]),
 3: array([4.00000006e-01, 1.74845560e-08]),
 4: array([-4.00000006e-01, -1.74845560e-08]),
 5: array([6.00000024e-01, 2.62268340e-08]),
 6: array([-6.00000024e-01, -2.62268340e-08]),
 7: array([4., 0.]),
 8: array([5., 0.])}

Resulting in plots that look like:

![bokeh_plot](https://user-images.githubusercontent.com/47577359/71023057-f5a87b00-20cf-11ea-8465-07b0d9027cf8.png)


Discussion:
Suggested solution:

```
def shell_layout_suggestion(nlist=None, scale=1, center=[0,0] ):

    import numpy as np

    npos = {}
    # Create array of radii with outer radius = 1 * scale
    radii = np.linspace(1, len(nlist), len(nlist) ) * scale / len(nlist)

    for nodes, radius in zip(nlist, radii):
        for node in nodes:
            i = nodes.index(node)
            # Discard the extra angle since it matches 0 radians.
            theta = np.linspace(0, 2 * np.pi, len(nodes) +1)[:-1]
            pos = np.column_stack([radius * np.cos(theta[i]) + center[0], radius * np.sin(theta[i]) + center[1]])
            npos.update({node: pos[0]})

    return npos
```
Resultant plot:
![bokeh_plot (1)](https://user-images.githubusercontent.com/47577359/71100113-6ad48880-2182-11ea-8717-40ecf3652aa3.png)

Updated - to stagger the first point of each shell around the circle to avoid shell having its first point at Y=0.

```
def shell_layout_suggestion(nlist=None, scale=1, center=[0,0] ):

    import numpy as np

    npos = {}
    # Create array of radii with outer radius = 1 * scale
    radii = np.linspace(1, len(nlist), len(nlist) ) * scale / len(nlist)

    for nodes, radius in zip(nlist, radii):
        for node in nodes:
            i = nodes.index(node)
            # Discard the extra angle since it matches 0 radians.
            theta = np.linspace(0, 2 * np.pi, len(nodes) +1)[:-1] + (np.pi * nlist.index(nodes)/len(nlist))
            pos = np.column_stack([radius * np.cos(theta[i]) + center[0], radius * np.sin(theta[i]) + center[1]])
            npos.update({node: pos[0]})

    return npos


```
![bokeh_plot (2)](https://user-images.githubusercontent.com/47577359/71101573-efc0a180-2184-11ea-949c-1183ecdb4088.png)
This is a bug.
The code checks whether there is one node in the shell but doesn't scale the radius by the number of nodes in the graph in that case (and it does scale the radius when the shell has more than one node).

Thanks for this!
You're welcome.

I couldn't figure out why the original code treated shells differently based on the number of nodes in the shell.  Nor could I figure out why it should.

So my approach treats all shells the same, regardless of the number of nodes.

Also, I didn't see why it was necessary to rescale when it seems straight forward to scale first, then fit all the nodes into that scale.
The old code treated shells with single nodes specially because it couldn't use ```rescale_layout``` with a single node (of course, then it didn't rescale at all -- which isn't right either). You are correct that we don't need to rescale using the generic rescale function in shall_layout because we can easily set the scale directly.

Your code can be improved by using ```enumerate()``` to avoid the fairly slow ```list.index``` method in two places. 
If you want to make a pull request that would be great. Otherwise I will make the fix.

Resolving patch:
diff --git a/networkx/drawing/layout.py b/networkx/drawing/layout.py
--- a/networkx/drawing/layout.py
+++ b/networkx/drawing/layout.py
@@ -168,7 +168,7 @@ def circular_layout(G, scale=1, center=None, dim=2):
     return pos
 
 
-def shell_layout(G, nlist=None, scale=1, center=None, dim=2):
+def shell_layout(G, nlist=None, rotate=None, scale=1, center=None, dim=2):
     """Position nodes in concentric circles.
 
     Parameters
@@ -179,6 +179,11 @@ def shell_layout(G, nlist=None, scale=1, center=None, dim=2):
     nlist : list of lists
        List of node lists for each shell.
 
+    rotate : angle in radians (default=pi/len(nlist))
+       Angle by which to rotate the starting position of each shell
+       relative to the starting position of the previous shell.
+       To recreate behavior before v2.5 use rotate=0.
+
     scale : number (default: 1)
         Scale factor for positions.
 
@@ -227,25 +232,27 @@ def shell_layout(G, nlist=None, scale=1, center=None, dim=2):
         # draw the whole graph in one shell
         nlist = [list(G)]
 
+    radius_bump = scale / len(nlist)
+
     if len(nlist[0]) == 1:
         # single node at center
         radius = 0.0
     else:
         # else start at r=1
-        radius = 1.0
+        radius = radius_bump
 
+    if rotate is None:
+        rotate = np.pi / len(nlist)
+    first_theta = rotate
     npos = {}
     for nodes in nlist:
-        # Discard the extra angle since it matches 0 radians.
-        theta = np.linspace(0, 1, len(nodes) + 1)[:-1] * 2 * np.pi
-        theta = theta.astype(np.float32)
-        pos = np.column_stack([np.cos(theta), np.sin(theta)])
-        if len(pos) > 1:
-            pos = rescale_layout(pos, scale=scale * radius / len(nlist)) + center
-        else:
-            pos = np.array([(scale * radius + center[0], center[1])])
+        # Discard the last angle (endpoint=False) since 2*pi matches 0 radians
+        theta = np.linspace(0, 2 * np.pi, len(nodes),
+                            endpoint=False, dtype=np.float32) + first_theta
+        pos = radius * np.column_stack([np.cos(theta), np.sin(theta)]) + center
         npos.update(zip(nodes, pos))
-        radius += 1.0
+        radius += radius_bump
+        first_theta += rotate
 
     return npos
 
@@ -474,7 +481,7 @@ def fruchterman_reingold_layout(G,
         pos = _sparse_fruchterman_reingold(A, k, pos_arr, fixed,
                                            iterations, threshold,
                                            dim, seed)
-    except:
+    except ValueError:
         A = nx.to_numpy_array(G, weight=weight)
         if k is None and fixed is not None:
             # We must adjust k by domain size for layouts not near 1x1
@@ -575,7 +582,7 @@ def _sparse_fruchterman_reingold(A, k=None, pos=None, fixed=None,
     # make sure we have a LIst of Lists representation
     try:
         A = A.tolil()
-    except:
+    except AttributeError:
         A = (coo_matrix(A)).tolil()
 
     if pos is None:
@@ -935,7 +942,7 @@ def planar_layout(G, scale=1, center=None, dim=2):
             raise nx.NetworkXException("G is not planar.")
     pos = nx.combinatorial_embedding_to_pos(embedding)
     node_list = list(embedding)
-    pos = np.row_stack((pos[x] for x in node_list))
+    pos = np.row_stack([pos[x] for x in node_list])
     pos = pos.astype(np.float64)
     pos = rescale_layout(pos, scale=scale) + center
     return dict(zip(node_list, pos))


Test patch:
diff --git a/networkx/drawing/tests/test_layout.py b/networkx/drawing/tests/test_layout.py
--- a/networkx/drawing/tests/test_layout.py
+++ b/networkx/drawing/tests/test_layout.py
@@ -1,14 +1,12 @@
 """Unit tests for layout functions."""
+import networkx as nx
+from networkx.testing import almost_equal
+
 import pytest
 numpy = pytest.importorskip('numpy')
 test_smoke_empty_graphscipy = pytest.importorskip('scipy')
 
 
-import pytest
-import networkx as nx
-from networkx.testing import almost_equal
-
-
 class TestLayout(object):
 
     @classmethod
@@ -151,6 +149,8 @@ def test_adjacency_interface_numpy(self):
         assert pos.shape == (6, 2)
         pos = nx.drawing.layout._fruchterman_reingold(A, dim=3)
         assert pos.shape == (6, 3)
+        pos = nx.drawing.layout._sparse_fruchterman_reingold(A)
+        assert pos.shape == (6, 2)
 
     def test_adjacency_interface_scipy(self):
         A = nx.to_scipy_sparse_matrix(self.Gs, dtype='d')
@@ -169,6 +169,9 @@ def test_single_nodes(self):
         vpos = nx.shell_layout(G, [[0], [1, 2], [3]])
         assert not vpos[0].any()
         assert vpos[3].any()  # ensure node 3 not at origin (#3188)
+        assert numpy.linalg.norm(vpos[3]) <= 1  # ensure node 3 fits (#3753)
+        vpos = nx.shell_layout(G, [[0], [1, 2], [3]], rotate=0)
+        assert numpy.linalg.norm(vpos[3]) <= 1  # ensure node 3 fits (#3753)
 
     def test_smoke_initial_pos_fruchterman_reingold(self):
         pos = nx.circular_layout(self.Gi)