├── .gitignore ├── LICENSE ├── README.md ├── example.py ├── manimnx ├── __init__.py └── manimnx.py ├── random_graphs.gif └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .ipynb_checkpoints 3 | media 4 | Tex 5 | videos 6 | *.ipynb 7 | manimnx.egg-info 8 | build 9 | dist 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rajat Vadiraj Dwaraknath 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # manimnx 2 | An interface between `networkx` and `manim` to make animating graphs easier. This package is in development and I welcome new feature requests and ideas. 3 | 4 | An example animation which transforms between two random graphs: 5 | 6 | ![](random_graphs.gif) 7 | 8 | 9 | # Install 10 | 11 | Install using this command: 12 | 13 | `pip install manimnx` 14 | 15 | Requires `manimlib` and `networkx`. 16 | 17 | # Example 18 | 19 | To generate the example shown above, run this command after cloning this repo: 20 | 21 | `manim example.py RandomGraphs -mi` 22 | 23 | The `-m` flag is for medium quality, and `-i` is to generate a gif instead of an mp4. 24 | 25 | 26 | Here is the code to generate the example gif. You can find it in [`example.py`](https://github.com/rajatvd/manim-nx/blob/master/example.py). 27 | 28 | ```py 29 | from manimlib.imports import * 30 | import networkx as nx 31 | import manimnx.manimnx as mnx 32 | import numpy as np 33 | import random 34 | 35 | 36 | class RandomGraphs(Scene): 37 | 38 | def construct(self): 39 | import time 40 | # list of colors to choose from 41 | COLORS = [RED, BLUE, GREEN, ORANGE, YELLOW] 42 | np.random.seed(int(time.time())) 43 | 44 | # make a random graph 45 | G1 = nx.erdos_renyi_graph(10, 0.5) 46 | # choose random colors for the nodes 47 | for node in G1.nodes.values(): 48 | node['color'] = random.choice(COLORS) 49 | 50 | # make the manim graph 51 | mng = mnx.ManimGraph(G1) 52 | 53 | self.play(*[ShowCreation(m) for m in mng]) # create G1 54 | self.wait(1) 55 | 56 | # lets get a new graph 57 | G2 = G1.copy() 58 | mnx.assign_positions(G2) # assign new node positions 59 | 60 | # add and remove random edges 61 | new_G = nx.erdos_renyi_graph(10, 0.5) 62 | G2.add_edges_from(new_G.edges) 63 | for edge in G1.edges: 64 | if edge not in new_G.edges: 65 | G2.remove_edge(*edge) 66 | 67 | # recolor nodes randomly 68 | for node in G2.nodes.values(): 69 | node['color'] = random.choice(COLORS) 70 | 71 | # the transform_graph function neatly moves nodes to their new 72 | # positions along with edges that remain. New edges are faded in and 73 | # removed ones are faded out. Try to replace this with a vanilla 74 | # Transform and notice the difference. 75 | self.play(*mnx.transform_graph(mng, G2)) # transform G1 to G2 76 | 77 | # vanilla transform mixes up all mobjects, and doesn't look as good 78 | # self.play(Transform(mng, mnx.ManimGraph(G2))) 79 | self.wait(1) 80 | ``` 81 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from manimlib.imports import * 2 | import networkx as nx 3 | import manimnx.manimnx as mnx 4 | import numpy as np 5 | import random 6 | 7 | 8 | class RandomGraphs(Scene): 9 | 10 | def construct(self): 11 | import time 12 | # list of colors to choose from 13 | COLORS = [RED, BLUE, GREEN, ORANGE, YELLOW] 14 | np.random.seed(int(time.time())) 15 | 16 | # make a random graph 17 | G1 = nx.erdos_renyi_graph(10, 0.5) 18 | # choose random colors for the nodes 19 | for node in G1.nodes.values(): 20 | node['color'] = random.choice(COLORS) 21 | 22 | # make the manim graph 23 | mng = mnx.ManimGraph(G1) 24 | 25 | self.play(*[ShowCreation(m) for m in mng]) # create G1 26 | self.wait(1) 27 | 28 | # lets get a new graph 29 | G2 = G1.copy() 30 | mnx.assign_positions(G2) # assign new node positions 31 | 32 | # add and remove random edges 33 | new_G = nx.erdos_renyi_graph(10, 0.5) 34 | G2.add_edges_from(new_G.edges) 35 | for edge in G1.edges: 36 | if edge not in new_G.edges: 37 | G2.remove_edge(*edge) 38 | 39 | # recolor nodes randomly 40 | for node in G2.nodes.values(): 41 | node['color'] = random.choice(COLORS) 42 | 43 | # the transform_graph function neatly moves nodes to their new 44 | # positions along with edges that remain. New edges are faded in and 45 | # removed ones are faded out. Try to replace this with a vanilla 46 | # Transform and notice the difference. 47 | self.play(*mnx.transform_graph(mng, G2)) # transform G1 to G2 48 | 49 | # vanilla transform mixes up all mobjects, and doesn't look as good 50 | # self.play(Transform(mng, mnx.ManimGraph(G2))) 51 | self.wait(1) 52 | -------------------------------------------------------------------------------- /manimnx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rajatvd/manimnx/4b19e66f765ef7d45c0d470b8ce9ff63ff6fdae8/manimnx/__init__.py -------------------------------------------------------------------------------- /manimnx/manimnx.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | import numpy as np 3 | 4 | from manimlib.imports import * 5 | 6 | # outsource exact details of node and edge creation to user 7 | # provide convenience functions for moving nodes and edges 8 | # get item and stuff similar to nx but return the vmobject instead of the key 9 | 10 | 11 | def get_dot_node(n, G): 12 | """Create a dot node with a given color. 13 | 14 | Uses RED by default. 15 | 16 | Parameters 17 | ---------- 18 | n : 19 | Node key for graph G. 20 | 21 | Returns 22 | ------- 23 | Dot 24 | The Dot VMobject. 25 | 26 | """ 27 | n = G.nodes[n] 28 | node = Dot(color=n.get('color', RED)) 29 | x, y = n['pos'] 30 | node.move_to(x*RIGHT + y*UP) 31 | return node 32 | 33 | 34 | def get_line_edge(ed, G): 35 | """Create a line edge using the color of node n1. 36 | 37 | Uses WHITE by default. 38 | 39 | Parameters 40 | ---------- 41 | ed: 42 | Edge key for the networkx graph G. 43 | 44 | Returns 45 | ------- 46 | Line 47 | The Line VMobject. 48 | 49 | """ 50 | n1 = G.nodes[ed[0]] 51 | n2 = G.nodes[ed[1]] 52 | x1, y1 = n1['pos'] 53 | x2, y2 = n2['pos'] 54 | start = x1*RIGHT + y1*UP 55 | end = x2*RIGHT + y2*UP 56 | return Line(start, end, color=n1.get('color', WHITE)) 57 | 58 | # %% 59 | 60 | 61 | def assign_positions(G, pos_func=nx.spring_layout, scale=np.array([6.5, 3.5])): 62 | """Assign the `pos` attribute to nodes in G. 63 | 64 | Parameters 65 | ---------- 66 | G : Graph 67 | The graph to assign positions to. 68 | pos_func : function (Graph) -> dict(node:numpy array) 69 | Function which returns a dict of positions as numpy arrays, keyed by 70 | nodes in the graph. 71 | The positions should be in the box (-1, 1) X (-1, 1). 72 | By default, nx.spring_layout is used. 73 | scale : ndarray with ndim=2 74 | Multiply all the positions from the pos_func by this scale vector. 75 | By default [6.5, 3.5] to fill manim's screen. 76 | 77 | """ 78 | unscaled_pos = pos_func(G) 79 | positions = {k: v*scale for k, v in unscaled_pos.items()} 80 | for node, pos in positions.items(): 81 | G.nodes[node]['pos'] = pos 82 | 83 | # %% 84 | 85 | 86 | class ManimGraph(VGroup): 87 | """A manim VGroup which wraps a networkx Graph. 88 | 89 | Parameters 90 | ---------- 91 | graph : networkx.Graph 92 | The graph to wrap. 93 | get_node : function (node, Graph) -> VMobject 94 | Create the VMobject for the given node key in the given Graph. 95 | get_edge : function (edge, Graph) -> VMobject 96 | Create the VMobject for the given edge key in the given Graph. 97 | 98 | Additional kwargs are passed to VGroup. 99 | 100 | Attributes 101 | ---------- 102 | nodes : dict 103 | Dict mapping mob_id -> node mobject 104 | edges : dict 105 | Dict mapping mob_id -> edge mobject 106 | id_to_node: dict 107 | Dict mapping mob_id -> node key 108 | id_to_edge: dict 109 | Dict mapping mob_id -> edge key 110 | 111 | 112 | """ 113 | 114 | def __init__(self, graph, 115 | get_node=get_dot_node, 116 | get_edge=get_line_edge, **kwargs): 117 | super().__init__(**kwargs) 118 | self.graph = graph 119 | self.get_edge = get_edge 120 | self.get_node = get_node 121 | 122 | self.nodes = {} 123 | self.edges = {} 124 | 125 | self.id_to_node = {} 126 | self.id_to_edge = {} 127 | 128 | n = list(self.graph.nodes())[0] 129 | if 'pos' not in self.graph.nodes[n].keys(): 130 | assign_positions(self.graph) 131 | 132 | self.count = 0 133 | self._add_nodes() 134 | self._add_edges() 135 | 136 | def _add_nodes(self): 137 | """Create nodes using get_node and add to submobjects and nodes dict.""" 138 | for node in self.graph.nodes: 139 | n = self.get_node(node, self.graph) 140 | self.graph.nodes[node]['mob_id'] = self.count 141 | self.nodes[self.count] = n 142 | self.id_to_node[self.count] = node 143 | self.add(n) 144 | self.count += 1 145 | 146 | def _add_edges(self): 147 | """Create edges using get_edge and add to submobjects and edges dict.""" 148 | for edge in self.graph.edges: 149 | self.graph.edges[edge]['mob_id'] = self.count 150 | e = self.get_edge(edge, self.graph) 151 | self.edges[self.count] = e 152 | self.id_to_edge[self.count] = edge 153 | self.add_to_back(e) 154 | self.count += 1 155 | 156 | def relabel_id(self, g, old, new): 157 | """Relabel the old node id with the new one using the give graph. 158 | 159 | DOES NOT update the graph object, so that the relabeling can be 160 | animated via transform_graph. 161 | 162 | Parameters 163 | ---------- 164 | g : nx.Graph 165 | Graph to get mob_id of new label 166 | old : string 167 | Old label. 168 | new : string 169 | New label. 170 | 171 | """ 172 | self.id_to_node[g.node[new]['mob_id']] = new 173 | 174 | # %% 175 | 176 | 177 | class TransformAndRemoveSource(Transform): 178 | def clean_up_from_scene(self, scene): 179 | super().clean_up_from_scene(scene) 180 | scene.remove(self.mobject) 181 | 182 | 183 | # %% 184 | def transform_graph(mng, G, 185 | node_transform=Transform, 186 | edge_transform=Transform): 187 | """Transforms the graph in ManimGraph mng to the graph G. 188 | 189 | This is better than just using Transform because it keeps edges and 190 | nodes which don't change stationary, unlike Transform which mixes up 191 | everything. 192 | 193 | For any new mob_ids in G, or any nodes or edges without a mob_id, they 194 | are assumed to be new objects which are to be created. If they contain 195 | an expansion attribute, they will be created and transformed from those 196 | mob_ids in the expansion. Otherwise, they will be faded in. 197 | 198 | Missing mob_ids in G are dealt with in two ways: 199 | If they are found in the contraction attribute of the node or edge, the 200 | mobjects are first transformed to the contracted node then faded away. If 201 | not found in any contraction, they are faded away. (Note that this means 202 | that any older contractions present in G do not affect the animation in any 203 | way.) 204 | 205 | Cannot contract to new nodes/edges and expand from new nodes/edges. 206 | # TODO: consider changing this behavior 207 | 208 | 209 | Parameters 210 | ---------- 211 | mng : ManimGraph 212 | The ManimGraph object to transform. 213 | G : Graph 214 | The graph containing attributes of the target graph. 215 | node_transform, edge_transform: Animation 216 | These are the animations used to transform between nodes and edges 217 | which exist in both the initial and target graphs. By default uses 218 | the vanilla Transform from manimlib. 219 | 220 | Returns 221 | ------- 222 | list of Animations 223 | List of animations to show the transform. 224 | 225 | """ 226 | anims = [] 227 | id_to_mobj = {**mng.nodes, **mng.edges} 228 | 229 | old_ids = list(mng.nodes.keys()) + list(mng.edges.keys()) 230 | new_ids = [] 231 | 232 | # just copying the loops for edges and nodes, not worth functioning that 233 | # i think 234 | # ------- ADDITIONS AND DIRECT TRANSFORMS --------- 235 | # NODES 236 | for node, node_data in G.nodes.items(): 237 | new_node = mng.get_node(node, G) 238 | if 'mob_id' not in node_data.keys(): 239 | G.nodes[node]['mob_id'] = mng.count 240 | mng.count += 1 241 | 242 | mob_id = node_data['mob_id'] 243 | new_ids.append(mob_id) 244 | 245 | if mob_id in old_ids: 246 | # if mng.graph.nodes[mng.id_to_node[mob_id]] != node_data: 247 | anims.append(node_transform(mng.nodes[mob_id], new_node)) 248 | else: 249 | if 'expansion' in node_data.keys(): 250 | objs = [id_to_mobj[o['mob_id']] 251 | for o in node_data['expansion'].values()] 252 | anims.append(TransformFromCopy(VGroup(*objs), new_node)) 253 | else: 254 | anims.append(FadeIn(new_node)) 255 | 256 | mng.nodes[mob_id] = new_node 257 | mng.add(new_node) 258 | mng.id_to_node[mob_id] = node 259 | 260 | # EDGES 261 | for edge, edge_data in G.edges.items(): 262 | new_edge = mng.get_edge(edge, G) 263 | if 'mob_id' not in edge_data.keys(): 264 | G.edges[edge]['mob_id'] = mng.count 265 | mng.count += 1 266 | 267 | mob_id = edge_data['mob_id'] 268 | new_ids.append(mob_id) 269 | 270 | if mob_id in old_ids: 271 | # only transform if new is different from old 272 | # TODO: how to check this properly, and is it really needed? 273 | 274 | # if mng.graph.edges[mng.id_to_edge[mob_id]] != edge_data: 275 | anims.append(edge_transform(mng.edges[mob_id], new_edge)) 276 | else: 277 | if 'expansion' in edge_data.keys(): 278 | objs = [id_to_mobj[o['mob_id']] 279 | for o in edge_data['expansion'].values()] 280 | anims.append(TransformFromCopy(VGroup(*objs), new_edge)) 281 | else: 282 | anims.append(FadeIn(new_edge)) 283 | 284 | mng.edges[mob_id] = new_edge 285 | mng.add_to_back(new_edge) 286 | mng.id_to_edge[mob_id] = edge 287 | 288 | # --------- REMOVALS AND CONTRACTIONS ---------- 289 | # NODES 290 | for node, node_data in mng.graph.nodes.items(): 291 | mob_id = node_data['mob_id'] 292 | if mob_id in new_ids: 293 | continue 294 | 295 | contracts_to = [] 296 | for node2, node_data in G.nodes.items(): 297 | for c2 in node_data.get('contraction', {}).values(): 298 | if mob_id == c2['mob_id']: 299 | contracts_to.append( 300 | mng.get_node(mng.id_to_node[node_data['mob_id']], G) 301 | ) 302 | break 303 | 304 | mobj = id_to_mobj[mob_id] 305 | if len(contracts_to) == 0: 306 | anims.append(FadeOut(mobj)) 307 | else: 308 | # anims.append(Succession(Transform(mobj, VGroup(*contracts_to)), 309 | # FadeOut(mobj))) 310 | anims.append(TransformAndRemoveSource(mobj, VGroup(*contracts_to))) 311 | 312 | # dont actually remove so that the edges in the back remain in the 313 | # back while transforming. this might bite later though 314 | # mng.remove(mobj) 315 | del mng.nodes[mob_id] 316 | del mng.id_to_node[mob_id] 317 | 318 | # EDGES 319 | for edge, edge_data in mng.graph.edges.items(): 320 | mob_id = edge_data['mob_id'] 321 | if mob_id in new_ids: 322 | continue 323 | 324 | contracts_to = [] 325 | for edge2, edge_data in G.edges.items(): 326 | for c2 in edge_data.get('contraction', {}).values(): 327 | if mob_id == c2['mob_id']: 328 | contracts_to.append( 329 | mng.get_edge(mng.id_to_edge[edge_data['mob_id']], G) 330 | ) 331 | break 332 | 333 | mobj = id_to_mobj[mob_id] 334 | if len(contracts_to) == 0: 335 | anims.append(FadeOut(mobj)) 336 | else: 337 | anims.append(TransformAndRemoveSource(mobj, VGroup(*contracts_to))) 338 | 339 | # mng.remove(mobj) 340 | del mng.edges[mob_id] 341 | del mng.id_to_edge[mob_id] 342 | 343 | mng.graph = G 344 | return anims 345 | 346 | 347 | # %% 348 | def shift_nodes(nodes, shift, fg): 349 | """Shift the pos of nodes in fg by shift in-place.""" 350 | for node in nodes: 351 | fg.nodes[node]['pos'] += shift 352 | 353 | 354 | # %% 355 | def map_attr(attr, keys, values, fg): 356 | """Map the values to the `attr` attribute of elements in fg. 357 | 358 | If keys are singletons, assumes that they are nodes. In all other cases, 359 | assumes they are edges. 360 | """ 361 | assert len(keys) == len(values) 362 | if len(tuple(keys[0])) == 1: 363 | for node, value in zip(keys, values): 364 | fg.nodes[node][attr] = value 365 | else: 366 | for edge, value in zip(keys, values): 367 | fg.edges[edge][attr] = value 368 | -------------------------------------------------------------------------------- /random_graphs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rajatvd/manimnx/4b19e66f765ef7d45c0d470b8ce9ff63ff6fdae8/random_graphs.gif -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import * 2 | 3 | LONG_DESC = """ 4 | An interface between `manim` and `networkx` to make animating graphs easier. 5 | """ 6 | 7 | setup(name='manimnx', 8 | version='0.1.4', 9 | description='Interface between `manim` and `networkx`', 10 | long_description=LONG_DESC, 11 | author='Rajat Vadiraj Dwaraknath', 12 | url='https://github.com/rajatvd/manim-nx', 13 | install_requires=['manimlib', 'networkx'], 14 | author_email='rajatvd@gmail.com', 15 | license='MIT', 16 | packages=find_packages(), 17 | zip_safe=False) 18 | --------------------------------------------------------------------------------