Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a Tutte Embedding Layout for Graphs #38762

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/sage/graphs/generic_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -20393,6 +20393,8 @@ def layout(self, layout=None, pos=None, dim=2, save_pos=False, **options):
option spring : Use spring layout to finalize the current layout.
option tree_orientation : The direction of tree branches -- 'up', 'down', 'left' or 'right'.
option tree_root : A vertex designation for drawing trees. A vertex of the tree to be used as the root for the ``layout='tree'`` option. If no root is specified, then one is chosen close to the center of the tree. Ignored unless ``layout='tree'``.
option external_face : A list of vertices to be the external face in the Tutte layout. Ignored unless ``layout='tutte'``.
option external_face_pos : A dictionary of positions for the external face in the Tutte layout. If none are specified, the external face is a regular polygon. Ignored unless ``layout='tutte'``

Some of them only apply to certain layout algorithms. For details, see
:meth:`.layout_acyclic`, :meth:`.layout_planar`,
Expand Down Expand Up @@ -21014,6 +21016,95 @@ def layout_graphviz(self, dim=2, prog='dot', **options):
positions = dot2tex.dot2tex(self.graphviz_string(**options), format='positions', prog=prog)

return {key_to_vertex[key]: pos for key, pos in positions.items()}

def layout_tutte(self, external_face, external_face_pos=None, **options):
"""
nturillo marked this conversation as resolved.
Show resolved Hide resolved

Compute graph layout based on a Tutte embedding.

The graph must be 3-connected and planar.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be more precise on the connectivity requirement. Is it exactly or at least 3 ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any graph that is k-connected for k $\geq$ 3 is 3-connected. For example, a 5-connected graph is 3-connected.


INPUT:

- ``external_face`` -- list; the external face to be made a polygon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't it possible to find an embedding without specifying the external face ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to calculate the embedding, an external face must be specified. Any subgraph of the graph that is a cycle will work as an external face. It would be possible to find an arbitrary external face automatically if none is specified, but it's unclear to me how one would be chosen from the available options. Also, that functionality is outside the scope of my original intention.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider tha following:

H = Graph(self) # take a (undirected) copy H of the graph
u, v = next(H.edge_iterator(labels=False)  # take any edge (u, v) of H
H.delete_edge(u, v)  # remove edge (u, v) from H
C = H.shortest_path(v, u) # Compute a shortest path from v to u in H minus (u, v)

You get a short cycle C, and this cycle is a face.

This can of course be done in a follow up PR, but this seems quite simple.


- ``external_face_pos`` -- list (default: ``None``); the positions of the vertices
nturillo marked this conversation as resolved.
Show resolved Hide resolved
of the external face. If ``None``, will automatically generate a unit sided regular polygon.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line requires 2 more spaces indentation. See other methods for examples.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the other methods, it seems like the inputs have two spaces of indentation for an input element which requires more than a line of explanation. Is that not correct?


- ``**options`` -- other parameters not used here

OUTPUT: a dictionary mapping vertices to positions

EXAMPLES::

sage: g = graphs.WheelGraph(n=7)
sage: g.layout_tutte(external_face=[1, 2, 3, 4, 5, 6])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doctest is unstable. I obtain different results on my laptop (macOS...)

File "src/sage/graphs/generic_graph.py", line 21041, in sage.graphs.generic_graph.GenericGraph.layout_tutte
Failed example:
    g.layout_tutte(external_face=[1, 2, 3, 4, 5, 6])
Expected:
    {1: (-0.499999999999999, 0.866025403784438),
    2: (0.500000000000000, 0.866025403784439),
    3: (1.00000000000000, 8.85026869717109e-17),
    4: (0.500000000000000, -0.866025403784439),
    5: (-0.500000000000001, -0.866025403784438),
    6: (-1.00000000000000, 4.55896726715916e-16),
    0: (-5.55111512312578e-17, 1.86944233679563e-16)}
Got:
    {0: (4.16333634234434e-16, 7.42055745992141e-16),
     1: (-0.499999999999999, 0.866025403784439),
     2: (0.500000000000000, 0.866025403784440),
     3: (1.00000000000000, 7.31549942167514e-16),
     4: (0.500000000000000, -0.866025403784438),
     5: (-0.500000000000000, -0.866025403784437),
     6: (-1.00000000000000, 1.29124025055007e-15)}

You should find another test that is more stable. Printing vertex positions is never a good idea.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In many of the other layout examples, they include this sort of example. However, I've removed it from this function.

{1: (-0.499999999999999, 0.866025403784438),
2: (0.500000000000000, 0.866025403784439),
3: (1.00000000000000, 8.85026869717109e-17),
4: (0.500000000000000, -0.866025403784439),
5: (-0.500000000000001, -0.866025403784438),
6: (-1.00000000000000, 4.55896726715916e-16),
0: (-5.55111512312578e-17, 1.86944233679563e-16)}
sage: g = graphs.CubeGraph(n=3, embedding=2)
sage: g.plot(layout='tutte', external_face=['101','111','001','011'], external_face_pos={'101':(1,0), '111':(0,0), '001':(2,1), '011':(-1,1)})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test is failing on my laptop

File "src/sage/graphs/generic_graph.py", line 21050, in sage.graphs.generic_graph.GenericGraph.layout_tutte
Failed example:
    g.plot(layout='tutte', external_face=['101','111','001','011'], external_face_pos={'101':(1,0), '111':(0,0), '001':(2,1), '011':(-1,1)})
Expected:
    Launched png viewer for Graphics object consisting of 21 graphics primitives
Got:
    Graphics object consisting of 21 graphics primitives

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is a quirk of WSL, it should be fixed now.

Launched png viewer for Graphics object consisting of 21 graphics primitives

nturillo marked this conversation as resolved.
Show resolved Hide resolved
"""
from sage.graphs.graph import Graph
nturillo marked this conversation as resolved.
Show resolved Hide resolved
from sage.matrix.constructor import zero_matrix
from sage.rings.real_mpfr import RR

if (len(external_face) < 3):
nturillo marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError("External face must have at least 3 vertices")

if (not self.is_planar()):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need a copy of the graph?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was copying another layout function, but you're right I don't need this.

raise ValueError("Graph must be planar")

C = self.subgraph(vertices=external_face)
if (not C.is_cycle(directed_cycle=False)):
raise ValueError("External face must be a cycle")
external_face_ordered = C.depth_first_search(start=external_face[0], ignore_direction=False)

from sage.graphs.connectivity import vertex_connectivity
if (not vertex_connectivity(self, k=3)):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you check if the vertex connectivity is more than 3. What if the graph is only 2 or 1 connected ? what if the graph is not connected ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, I was getting my connectivity rules reversed. It should be fixed now. If the connectivity is more than three, that's actually okay; it's the reverse situation which induces a problem.

raise ValueError("Graph must be 3-connected")

from math import sin, cos, pi
pos = dict()

if external_face_pos is None:
l = len(external_face)
a0 = pi/l+pi/2
nturillo marked this conversation as resolved.
Show resolved Hide resolved
for i, vertex in enumerate(external_face_ordered):
ai = a0+pi*2*i/l
pos[vertex] = (cos(ai),sin(ai))
nturillo marked this conversation as resolved.
Show resolved Hide resolved
else:
for v,p in external_face_pos.items():
pos[v] = p

V = self.vertices()
n = len(V)
M = zero_matrix(RR,n,n)
b = zero_matrix(RR,n,2)

vertices_to_indices = {v:i for i,v in enumerate(V)}
nturillo marked this conversation as resolved.
Show resolved Hide resolved
for i in range(n):
v = V[i]
if v in pos:
M[i,i] = 1
b[i,0] = pos[v][0]
b[i,1] = pos[v][1]
else:
nv = self.neighbors(v)
for u in nv:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a call to V.index(u) takes time $O(n)$. You should better first define a mapping from vertices to index in V like vertex_to_int = {v: I for I, v in enumerate(V)} and then use it. Each query will then be constant time.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay good catch fixed!

j = vertices_to_indices[u]
M[i,j] = -1
M[i,i] = len(nv)

sol = M.pseudoinverse()*b
return {V[i]:sol[i] for i in range(n)}


def _layout_bounding_box(self, pos):
"""
Expand Down Expand Up @@ -21333,6 +21424,9 @@ def plot(self, **options):
of the tree using the keyword tree_root, otherwise a root will be
selected at random. Then the tree will be plotted in levels,
depending on minimum distance for the root.

- ``'tutte'`` -- uses the Tutte embedding algorithm. The graph must be
a 3-connected, planar graph.

- ``vertex_labels`` -- boolean (default: ``True``); whether to print
vertex labels
Expand Down Expand Up @@ -21399,6 +21493,13 @@ def plot(self, **options):
"down". If "up" (resp., "down"), then the root of the tree will
appear on the bottom (resp., top) and the tree will grow upwards
(resp. downwards). Ignored unless ``layout='tree'``.

- ``external_face`` -- list of vertices; the external face to be made a
in the Tutte layout. Ignored unless ``layout='tutte''``.
nturillo marked this conversation as resolved.
Show resolved Hide resolved

- ``external_face_pos`` -- dictionary (default: ``None``). If specified,
used as the positions for the external face in the Tutte layout. Ignored
unless ``layout='tutte'``.

- ``save_pos`` -- boolean (default: ``False``); save position computed
during plotting
Expand Down
5 changes: 5 additions & 0 deletions src/sage/graphs/graph_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@
'tree_orientation':
'The direction of tree branches -- \'up\', \'down\', '
'\'left\' or \'right\'.',
'external_face':
'The external face of the graph, used for Tutte embedding layout',
'external_face_pos':
'The position of the external face of the graph, used for Tutte embedding'
'layout. If none specified, the external face is a regular polygon.',
'save_pos':
'Whether or not to save the computed position for the graph.',
'dim':
Expand Down
Loading