Skip to content

Commit e16f9a6

Browse files
authored
Basic implementation of undirected graphs (#861)
* toying around with a graph using networkx layouting * basic implementation and documentation of a class for undirected graphs * import graph module into global namespace * add graph module to documentation * poetry: add networkx as a dependency * remove some debug prints * sort all extracted mobjects w.r.t. z_index * add test for z_index (from #327) * more complex z_index test * black * improve imports * use z_index to have edges below vertices * add type hints * rename some tests to make space for graph tests * fix problem with manual positioning * add test * black * new animate syntax * document label_fill_color
1 parent fe2ba6d commit e16f9a6

File tree

10 files changed

+401
-42
lines changed

10 files changed

+401
-42
lines changed

docs/source/reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Mobjects
2222
~mobject.frame
2323
~mobject.functions
2424
~mobject.geometry
25+
~mobject.graph
2526
~mobject.logo
2627
~mobject.matrix
2728
~mobject.mobject

manim/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .mobject.frame import *
3434
from .mobject.functions import *
3535
from .mobject.geometry import *
36+
from .mobject.graph import *
3637
from .mobject.logo import *
3738
from .mobject.matrix import *
3839
from .mobject.mobject import *

manim/mobject/graph.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
"""Mobjects used to represent mathematical graphs (think graph theory, not plotting)."""
2+
3+
__all__ = [
4+
"Graph",
5+
]
6+
7+
from ..constants import UP
8+
from ..utils.color import BLACK
9+
from .types.vectorized_mobject import VMobject
10+
from .geometry import Dot, Line, LabeledDot
11+
from .svg.tex_mobject import MathTex
12+
13+
from typing import Hashable, Union, List, Tuple
14+
15+
from copy import copy
16+
import networkx as nx
17+
import numpy as np
18+
19+
20+
class Graph(VMobject):
21+
"""An undirected graph (that is, a collection of vertices connected with edges).
22+
23+
Graphs can be instantiated by passing both a list of (distinct, hashable)
24+
vertex names, together with list of edges (as tuples of vertex names). See
25+
the examples below for details.
26+
27+
.. note::
28+
29+
This implementation uses updaters to make the edges move with
30+
the vertices.
31+
32+
Parameters
33+
----------
34+
35+
vertices
36+
A list of vertices. Must be hashable elements.
37+
edges
38+
A list of edges, specified as tuples ``(u, v)`` where both ``u``
39+
and ``v`` are vertices.
40+
labels
41+
Controls whether or not vertices are labeled. If ``False`` (the default),
42+
the vertices are not labeled; if ``True`` they are labeled using their
43+
names (as specified in ``vertices``) via :class:`~.MathTex`. Alternatively,
44+
custom labels can be specified by passing a dictionary whose keys are
45+
the vertices, and whose values are the corresponding vertex labels
46+
(rendered via, e.g., :class:`~.Text` or :class:`~.Tex`).
47+
label_fill_color
48+
Sets the fill color of the default labels generated when ``labels``
49+
is set to ``True``. Has no effect for other values of ``labels``.
50+
layout
51+
Either one of ``"spring"`` (the default), ``"circular"``, ``"kamada_kawai"``,
52+
``"planar"``, ``"random"``, ``"shell"``, ``"spectral"``, and ``"spiral"``
53+
for automatic vertex positioning using ``networkx``
54+
(see `their documentation <https://networkx.org/documentation/stable/reference/drawing.html#module-networkx.drawing.layout>`_
55+
for more details), or a dictionary specifying a coordinate (value)
56+
for each vertex (key) for manual positioning.
57+
layout_scale
58+
The scale of automatically generated layouts: the vertices will
59+
be arranged such that the coordinates are located within the
60+
interval ``[-scale, scale]``. Default: 2.
61+
layout_config
62+
Only for automatically generated layouts. A dictionary whose entries
63+
are passed as keyword arguments to the automatic layout algorithm
64+
specified via ``layout`` of``networkx``.
65+
vertex_type
66+
The mobject class used for displaying vertices in the scene.
67+
vertex_config
68+
Either a dictionary containing keyword arguments to be passed to
69+
the class specified via ``vertex_type``, or a dictionary whose keys
70+
are the vertices, and whose values are dictionaries containing keyword
71+
arguments for the mobject related to the corresponding vertex.
72+
edge_type
73+
The mobject class used for displaying edges in the scene.
74+
edge_config
75+
Either a dictionary containing keyword arguments to be passed
76+
to the class specified via ``edge_type``, or a dictionary whose
77+
keys are the edges, and whose values are dictionaries containing
78+
keyword arguments for the mobject related to the corresponding edge.
79+
80+
Examples
81+
--------
82+
83+
First, we create a small graph and demonstrate that the edges move
84+
together with the vertices.
85+
86+
.. manim:: MovingVertices
87+
88+
class MovingVertices(Scene):
89+
def construct(self):
90+
vertices = [1, 2, 3, 4]
91+
edges = [(1, 2), (2, 3), (3, 4), (1, 3), (1, 4)]
92+
g = Graph(vertices, edges)
93+
self.play(ShowCreation(g))
94+
self.wait()
95+
self.play(g[1].animate.move_to([1, 1, 0]),
96+
g[2].animate.move_to([-1, 1, 0]),
97+
g[3].animate.move_to([1, -1, 0]),
98+
g[4].animate.move_to([-1, -1, 0]))
99+
self.wait()
100+
101+
There are several automatic positioning algorithms to choose from:
102+
103+
.. manim:: GraphAutoPosition
104+
:save_last_frame:
105+
106+
class GraphAutoPosition(Scene):
107+
def construct(self):
108+
vertices = [1, 2, 3, 4, 5, 6, 7, 8]
109+
edges = [(1, 7), (1, 8), (2, 3), (2, 4), (2, 5),
110+
(2, 8), (3, 4), (6, 1), (6, 2),
111+
(6, 3), (7, 2), (7, 4)]
112+
autolayouts = ["spring", "circular", "kamada_kawai",
113+
"planar", "random", "shell",
114+
"spectral", "spiral"]
115+
graphs = [Graph(vertices, edges, layout=lt).scale(0.5)
116+
for lt in autolayouts]
117+
r1 = VGroup(*graphs[:3]).arrange()
118+
r2 = VGroup(*graphs[3:6]).arrange()
119+
r3 = VGroup(*graphs[6:]).arrange()
120+
self.add(VGroup(r1, r2, r3).arrange(direction=DOWN))
121+
122+
Vertices can also be positioned manually:
123+
124+
.. manim:: GraphManualPosition
125+
:save_last_frame:
126+
127+
class GraphManualPosition(Scene):
128+
def construct(self):
129+
vertices = [1, 2, 3, 4]
130+
edges = [(1, 2), (2, 3), (3, 4), (4, 1)]
131+
lt = {1: [0, 0, 0], 2: [1, 1, 0], 3: [1, -1, 0], 4: [-1, 0, 0]}
132+
G = Graph(vertices, edges, layout=lt)
133+
self.add(G)
134+
135+
The vertices in graphs can be labeled, and configurations for vertices
136+
and edges can be modified both by default and for specific vertices and
137+
edges.
138+
139+
.. note::
140+
141+
In ``edge_config``, edges can be passed in both directions: if
142+
``(u, v)`` is an edge in the graph, both ``(u, v)`` as well
143+
as ``(v, u)`` can be used as keys in the dictionary.
144+
145+
.. manim:: LabeledModifiedGraph
146+
:save_last_frame:
147+
148+
class LabeledModifiedGraph(Scene):
149+
def construct(self):
150+
vertices = [1, 2, 3, 4, 5, 6, 7, 8]
151+
edges = [(1, 7), (1, 8), (2, 3), (2, 4), (2, 5),
152+
(2, 8), (3, 4), (6, 1), (6, 2),
153+
(6, 3), (7, 2), (7, 4)]
154+
g = Graph(vertices, edges, layout="circular", layout_scale=3,
155+
labels=True, vertex_config={7: {"fill_color": RED}},
156+
edge_config={(1, 7): {"stroke_color": RED},
157+
(2, 7): {"stroke_color": RED},
158+
(4, 7): {"stroke_color": RED}})
159+
self.add(g)
160+
161+
"""
162+
163+
def __init__(
164+
self,
165+
vertices: List[Hashable],
166+
edges: List[Tuple[Hashable, Hashable]],
167+
labels: bool = False,
168+
label_fill_color: str = BLACK,
169+
layout: Union[str, dict] = "spring",
170+
layout_scale: float = 2,
171+
layout_config: Union[dict, None] = None,
172+
vertex_type: "Mobject" = Dot,
173+
vertex_config: Union[dict, None] = None,
174+
edge_type: "Mobject" = Line,
175+
edge_config: Union[dict, None] = None,
176+
) -> None:
177+
VMobject.__init__(self)
178+
179+
nx_graph = nx.Graph()
180+
nx_graph.add_nodes_from(vertices)
181+
nx_graph.add_edges_from(edges)
182+
self._graph = nx_graph
183+
184+
automatic_layouts = {
185+
"circular": nx.layout.circular_layout,
186+
"kamada_kawai": nx.layout.kamada_kawai_layout,
187+
"planar": nx.layout.planar_layout,
188+
"random": nx.layout.random_layout,
189+
"shell": nx.layout.shell_layout,
190+
"spectral": nx.layout.spectral_layout,
191+
"spiral": nx.layout.spiral_layout,
192+
"spring": nx.layout.spring_layout,
193+
}
194+
195+
if layout_config is None:
196+
layout_config = {}
197+
198+
if isinstance(layout, dict):
199+
self._layout = layout
200+
elif layout in automatic_layouts and layout != "random":
201+
self._layout = automatic_layouts[layout](
202+
nx_graph, scale=layout_scale, **layout_config
203+
)
204+
self._layout = dict(
205+
[(k, np.append(v, [0])) for k, v in self._layout.items()]
206+
)
207+
elif layout == "random":
208+
# the random layout places coordinates in [0, 1)
209+
# we need to rescale manually afterwards...
210+
self._layout = automatic_layouts["random"](nx_graph, **layout_config)
211+
for k, v in self._layout.items():
212+
self._layout[k] = 2 * layout_scale * (v - np.array([0.5, 0.5]))
213+
self._layout = dict(
214+
[(k, np.append(v, [0])) for k, v in self._layout.items()]
215+
)
216+
else:
217+
raise ValueError(
218+
f"The layout '{layout}' is neither a recognized automatic layout, "
219+
"nor a vertex placement dictionary."
220+
)
221+
222+
if isinstance(labels, dict):
223+
self._labels = labels
224+
elif isinstance(labels, bool):
225+
if labels:
226+
self._labels = dict(
227+
[(v, MathTex(v, fill_color=label_fill_color)) for v in vertices]
228+
)
229+
else:
230+
self._labels = dict()
231+
232+
if self._labels and vertex_type is Dot:
233+
vertex_type = LabeledDot
234+
235+
# build vertex_config
236+
if vertex_config is None:
237+
vertex_config = {}
238+
default_vertex_config = {}
239+
if vertex_config:
240+
default_vertex_config = dict(
241+
[(k, v) for k, v in vertex_config.items() if k not in vertices]
242+
)
243+
self._vertex_config = dict(
244+
[(v, vertex_config.get(v, copy(default_vertex_config))) for v in vertices]
245+
)
246+
for v, label in self._labels.items():
247+
self._vertex_config[v]["label"] = label
248+
249+
self.vertices = dict(
250+
[(v, vertex_type(**self._vertex_config[v])) for v in vertices]
251+
)
252+
for v in self.vertices:
253+
self[v].move_to(self._layout[v])
254+
255+
# build edge_config
256+
if edge_config is None:
257+
edge_config = {}
258+
default_edge_config = {}
259+
if edge_config:
260+
default_edge_config = dict(
261+
(k, v)
262+
for k, v in edge_config.items()
263+
if k not in edges and k[::-1] not in edges
264+
)
265+
self._edge_config = {}
266+
for e in edges:
267+
if e in edge_config:
268+
self._edge_config[e] = edge_config[e]
269+
elif e[::-1] in edge_config:
270+
self._edge_config[e] = edge_config[e[::-1]]
271+
else:
272+
self._edge_config[e] = copy(default_edge_config)
273+
274+
self.edges = dict(
275+
[
276+
(
277+
(u, v),
278+
edge_type(
279+
self[u].get_center(),
280+
self[v].get_center(),
281+
z_index=-1,
282+
**self._edge_config[(u, v)],
283+
),
284+
)
285+
for (u, v) in edges
286+
]
287+
)
288+
289+
self.add(*self.vertices.values())
290+
self.add(*self.edges.values())
291+
292+
for (u, v), edge in self.edges.items():
293+
294+
def update_edge(e, u=u, v=v):
295+
e.set_start_and_end_attrs(self[u].get_center(), self[v].get_center())
296+
e.generate_points()
297+
298+
update_edge(edge)
299+
edge.add_updater(update_edge)
300+
301+
def __getitem__(self: "Graph", v: Hashable) -> "Mobject":
302+
return self.vertices[v]
303+
304+
def __repr__(self: "Graph") -> str:
305+
return f"Graph on {len(self.vertices)} vertices and {len(self.edges)} edges"

0 commit comments

Comments
 (0)