Animating figures with Manim: A citation network example

Grant Sanderson’s 3blue1brown channel has amassed almost 5 million YouTube followers by explaining complicated mathematical topics (e.g., linear algebra, neural networks, disease modeling) with clear and compelling visual storytelling. The channel’s success is due in no small part to Manim, the open-source Python-based “Mathematical Animation Engine” developed by Sanderson to create his stunning videos.

This blog post will demonstrate how to use the Manim engine to turn your figures into slick, animated GIFs. As an example, I will visualize co-authorship clusters within a network of literature.

Setup

Two versions of Manim are available: the original repository developed and maintained by Grant Sanderson, and an expanded and more stable Community Edition maintained by a larger group of users. For the rest of this post, I will refer exclusively to the latter. Manim is simple to install using basic Python package managers such as conda and pip; see this page in the docs for details. I also highly recommend looking through the detailed tutorials and example gallery in the Manim Community docs, as well as this blogpost by Khuyen Tran on Towards Data Science. These resources provide more details on the basics of Manim and a broader view of what is possible – for this post, I will focus more narrowly on the example of visualizing a citation network.

All code for this blogpost can be found in this GitHub repository.

Co-authorship network data

In a previous blogpost, Rohini Gupta demonstrated how to generate a database of literature using the Dimensions tool and visualize co-authorship networks using the VOSviewer tool. The resulting interactive VOSviewer visualization can be found on the Reed Group website. This co-authorship network can be downloaded as a JSON file by clicking “save” in the top right hand corner.

Opening the JSON file, we see a nested set of key:value pairs, similar to a dictionary in Python. The “network” consists of two lists called “items” and “links”. Each element of “items” is an author, with details on how many publications they have authored that cite the Borg MOEA, and how many citations those publications have. Each element of “links” is a connection between two authors, signifying that they have co-authored at least one publication together that cites the Borg MOEA.

{
    "network": {
        "items": [
            {
                "id": 2,
                "label": "abdo, gamal m.",
                "x": 0.9606,
                "y": 0.2704,
                "cluster": 5,
                "weights": {
                    "Documents": 1,
                    "Citations": 38,
                    "Norm. citations": 1.5776
                },
                "scores": {
                    "Avg. pub. year": 2018,
                    "Avg. citations": 38,
                    "Avg. norm. citations": 1.5776
                }
            },
           ...
       ],
        "links": [
            {
                "source_id": 2,
                "target_id": 194,
                "strength": 1
            },
            ...
      ],
}

Static visualization with Manim

In order to get a feel for the data and for how Manim’s geometric objects work, let’s create a static visualization (i.e., no animation) of the co-authorship network. First, we import libraries, load a colormap, and import the JSON file into lists of nodes and links.

import json
from manim import *
from matplotlib import cm
from matplotlib.colors import rgb2hex
import numpy as np

cmap = cm.get_cmap('viridis')

### open JSON file of VOSviewer citation network
with open('VOSviewer-network.json', 'r') as f:
    vos = json.load(f)['network']
    nodes = vos['items']
    links = vos['links']

Now we can create a new class defining the static citation network visualization, inheriting from Manim’s basic Scene class.

### create animation class for static citation network, inheriting from basic Scene Manim class
class CitationNetworkStatic(Scene):
    def construct(self):
        ax = Axes(
            x_range=[-1.1,1.2], y_range=[-1.1,1.1], axis_config={"include_tip": False}
        )

Next, we can draw each link in the dataset using Manim’s Line class. The x and y locations are taken directly from VOSviewer’s visualization of the network.

        ### create link for each citation, add line to animation
        for link in links:
            source = [node for node in nodes if node['id'] == link['source_id']][0]
            target = [node for node in nodes if node['id'] == link['target_id']][0]
            line = Line(ax.coords_to_point(source['x'], source['y']), ax.coords_to_point(target['x'], target['y']), color=GREY)
            line.set_opacity(0.6)
            self.add(line)

Once we have created the Line object (line 5) and modified its opacity (line 6), we add it to the figure (line 7).

The next step is loop over the “clusters” assigned by VOSviewer, and plot each author within each cluster as a Manim Circle object. The authors in each cluster can be grouped together using the Manim Group class. I chose to color each circle based on its cluster and size each circle based on how many times the authors’ Borg-citing papers have been cited. I also keep track of the most-cited author within each cluster for labeling purposes. Note that the clustering and labeling methodology here were chosen purely for convenience – there are many other ways to define “influence” in citation networks, but the purpose of this exercise is simply to demonstrate the visualization technique.

        ### create circle for each node, colored by cluster and sized by citations. 
        ### create group for each cluster, labeled by largest node. add all circles to animation.
        nodegroups = []
        nodegrouplabels = []
        nodegroupweights = []
        for cluster in range(1, 50):
            nodelist = [node for node in nodes if node['cluster'] == cluster]
            color =  rgb2hex(cmap(cluster/25))
            nodeweights = [node['weights']['Citations'] for node in nodelist]
            largestlabel = [node['label'] for node in nodelist if node['weights']['Citations'] == max(nodeweights)]
            largestweight = [node['weights']['Citations'] for node in nodelist if node['weights']['Citations'] == max(nodeweights)]
            nodegrouplabels.append(largestlabel)
            nodegroupweights.append(largestweight)
            nodegrouplist = []
            for node in nodelist:
                circle = Circle(color = color)
                circle.set_fill(color, opacity = 0.8)
                circle.move_to(ax.coords_to_point(node['x'], node['y']))
                circle.scale(np.log10(node['weights']['Citations']+2)/15)
                self.add(circle)
                nodegrouplist.append(circle)
            nodegroup = Group(*nodegrouplist)
            nodegroups.append(nodegroup)

Lastly, we can loop over the clusters and label each with the most “influential” author within that cluster, using Manim’s Text class. The size of each author’s name, like the size of the markers, is set based on total citations within each author’s Borg-citing publications.

        ### add text for central author in each cluster
        order = np.argsort(nodegroupweights)[::-1]
        for i in order:
            nodegroup = nodegroups[i]
            if len(nodegroup) > 0:
                text = Text(nodegrouplabels[i][0]).set_color(WHITE).move_to(nodegroup).scale(np.log10(nodegroupweights[i][0]+10) / 8)
                self.add(text)

With the CitationNetworkStatic class now fully defined in the scene_vosviewer.py file, we can create the Manim visualization by running the following simple command from the command prompt.

manim -qh scene_vosviewer.py CitationNetworkStatic

This command tells the Manim engine to run the scene_vosviewer.py script and create an image from the CitationNetworkStatic class. The -qh flag means “high quality”. This command creates the following image in the media/ directory:

Static visualization of co-authorship network, created with Manim

Adding animation

As you can see, Manim provides some nice functionality visualizing geometric objects such as circles, lines, text, etc (see docs for many more options). However, the real advantage of Manim over other plotting libraries is its streamlined functionality for creating powerful, precise animations. Geometric objects in Manim (e.g., Line, Circle, Text, and even Group) have built-in operators for appearing, disappearing, moving, rotating, transforming, changing colors, etc.

As a simple demonstration, we will create an animated GIF that does the following:

  1. Begins with the links already drawn, and pauses 1 second
  2. Ads the co-authorship clusters one at a time, pausing 0.25 seconds between each
  3. Pauses 1 second
  4. Loops over the clusters. For each, zoom in and pan the camera to that cluster, rotate all circles around the Group’s centroid, display the group’s label, pause for 1 second, then remove the label and zoom back out to the entire figure.

To do this, we only need to make a few rather small changes to our code. First, the new class CitationNetworkAnimated should inherit from Manim’s MovingCameraScene class, which adds functionality for camera zooming and panning on top of the basic Scene class.

class CitationNetworkAnimated(MovingCameraScene):

The simplest thing we can do to transform our static image into an animation is to add pauses in between different steps. To pause one second, we simply add

        self.wait(1)

For the application described above, I add a 1 second pause after adding the links to the figure, a 0.25 second pause within the first cluster loop where each cluster of circles is added to the figure, a 1 second pause after that loop completes, and a 1 second pause within the second cluster loop where each group is rotated and labeled.

The rest of the animations in Step 4 above are added with the following snippet, which replaces the final snippet in the previous section:

        ### now animate zooming & labeling clusters 1 by 1
        self.camera.frame.save_state()
        self.wait(1)
        order = np.argsort(nodegroupweights)[::-1]
        for i in order:
            nodegroup = nodegroups[i]
            if len(nodegroup) > 0:
                text = Text(nodegrouplabels[i][0]).set_color(WHITE).move_to(nodegroup).scale(np.log10(nodegroupweights[i][0]+10) / 8)
                self.play(self.camera.frame.animate.move_to(nodegroup).set(width = max(nodegroup.width * 2, text.width * 2)))
                self.play(Rotate(nodegroup, angle=2*PI))
                self.add(text)
                self.wait(1)
                self.remove(text)
                self.play(Restore(self.camera.frame))

Lines 11-13 are used to add the text to the figure, wait 1 second, then remove the text from the figure. Line 10 uses Manim’s play and Rotate methods to rotate each cluster by a full 360 degrees around its centroid. Finally, the animated zooming and panning are orchestrated with lines 2, 9, and 14. Line 2 saves the initial camera frame (location and extent) when viewing the entire figure. Line 9 zooms and pans to a particular cluster. Line 14 restores the camera to the original frame. Each of these actions is automatically done in a smooth, aesthetically pleasing way without having to manually create and stitch together the intermediate still images.

Finally, we can create the GIF using the following command:

manim -ql -i scene_vosviewer.py CitationNetworkAnimated

The -i flag tells Manim to create a GIF rather than a movie file. I’ve used the low quality flag -ql to save time and space (Note: WordPress seems to be downgrading the image quality somewhat, it looks better when I view the file directly). Here is the result:

Animated visualization of co-authorship network, created with Manim

This is, of course, a rather silly example which probably won’t win me any Oscars. But I hope it demonstrates the ease and power of creating animated figures with the Manim engine in Python. If you’re intrigued and looking for more inspiration, I definitely recommend checking out the detailed tutorials and example gallery in the Manim Community docs, the blogpost by Khuyen Tran on Towards Data Science, and of course Grant Sanderson’s 3blue1brown channel.