Plotting 3D Graphs with Python, igraph, and Cairo

Archive · 1 min read

Out of all the visuals I've produced, I think the "coolest" is the three-dimensional U.S. Supreme Court citation network 1080p movie I produced with Dan Katz. 3D networks, especially dynamic ones, really invoke the "wow" factor. Movies are especially important in dynamic cases, since without the animation, you lose all context and understanding of what led the network to that point. The 3D part may seem unnecessary at first, but an extra dimension can go a long way to unwinding the hairball that most somewhat-dense networks form.

How does it work? Using a Twitter dataset, along with Python, igraph, and Cairo to demonstrate how 3D network animations can be generated. In this example, we animate the rotation of a static snapshot of a user graph.

The program works as follows: First, build a network based on @username mentions in the tweet dataset. Then, calculate a three-dimensional Kamada-Kawai layout of (x,y,z) tuples. While sweeping the polar and azimuthal angles, calculate a two-dimensional projection of the currently rotated layout, draw the vertices and edges in increasing z-order, and output the surface.

If we wanted to dynamically add and remove vertices or edges from this network, changes would be needed to re-calculate and interpolate layouts across network snapshots. The graphmovie library for Python (code.google.com/p/graphmovie/) is designed to do exactly this.

The Python code is available as a gist at Github (gist.github.com/837396).

'''
@author Michael J Bommarito II
@contact michael.bommarito@gmail.com
@date Feb 21, 2011
@license Simplified BSD, (C) 2011.

Plot the network of the first 1000 #cn220 tweets with igraph and cairo.
'''

import cairo
import codecs
import dateutil.parser
import igraph
import numpy
import re

def readTweets(fileName):
	'''
	Read in tweet data from the tab-delimited tweet format.
	'''
	rows = [[field.strip() for field in line.split("\t")] for line in codecs.open(fileName, 'r', 'utf-8')]
	return [(int(row[0]), dateutil.parser.parse(row[1]), row[2], row[3]) for row in rows]

def getNetwork(tweets):
	'''
	Parse the list of tweets and find "edges" embedded in the tweets.
	Edges are just mentions in the tweet text, e.g., @mjbommar.
	Then process the network from the edges.
	'''
	reMention = re.compile('\@([\w]+)')

	# Calculate the list of mentions
	mentions = []
	for tweet in tweets:
		mentions.extend([(tweet[1], tweet[2],  name) for name in reMention.findall(tweet[3])])
	
	# Sort by date
	mentions = sorted(mentions)
	
	# Now convert the dates/mention to edges and weights 
	dates, nodeA, nodeB = zip(*mentions)
	nodes = set(nodeA) | set(nodeB)
	nodes = sorted(list(nodes))
	nodeMap = dict([(v,i) for i,v in enumerate(nodes)])
	edges = [(nodeMap[e[1]], nodeMap[e[2]]) for e in mentions]
	edges, weights = map(list, zip(*[[e, edges.count(e)] for e in set(edges)]))
	
	# Now create the graph
	graph = igraph.Graph(edges)
	graph.es['weight'] = weights
	graph.vs['label'] = nodes
	
	return graph

def project2D(layout, alpha, beta):
	'''
	This method will project a set of points in 3D to 2D based on the given
	angles alpha and beta.
	'''
	
	# Calculate the rotation matrices based on the given angles.
	c = numpy.matrix([[1, 0, 0], [0, numpy.cos(alpha), numpy.sin(alpha)], [0, -numpy.sin(alpha), numpy.cos(alpha)]])
	c = c * numpy.matrix([[numpy.cos(beta), 0, -numpy.sin(beta)], [0, 1, 0], [numpy.sin(beta), 0, numpy.cos(beta)]])
	b = numpy.matrix([[1, 0, 0], [0, 1, 0], [0, 0, 1]])
	
	# Hit the layout, rotate, and kill a dimension
	layout = numpy.matrix(layout)
	X = (b * (c * layout.transpose())).transpose()
	return [[X[i,0],X[i,1],X[i,2]] for i in range(X.shape[0])]

def drawGraph3D(graph, layout, angle, fileName):
	'''
	Draw a graph in 3D with the given layout, angle, and filename.
	'''
	
	# Setup some vertex attributes and calculate the projection
	graph.vs['degree'] = graph.degree()
	vertexRadius = 0.1 * (0.9 * 0.9) / numpy.sqrt(graph.vcount())
	graph.vs['x3'], graph.vs['y3'], graph.vs['z3'] = zip(*layout)
	
	
	layout2D = project2D(layout, angle[0], angle[1])
	graph.vs['x2'], graph.vs['y2'], graph.vs['z2'] = zip(*layout2D)
	minX, maxX = min(graph.vs['x2']), max(graph.vs['x2'])
	minY, maxY = min(graph.vs['y2']), max(graph.vs['y2'])
	minZ, maxZ = min(graph.vs['z2']), max(graph.vs['z2'])
	
	# Calculate the draw order.  This is important if we want this to look
	# realistically 3D.
	zVal, zOrder = zip(*sorted(zip(graph.vs['z3'], range(graph.vcount()))))
	
	# Setup the cairo surface
	surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1280, 800)
	con = cairo.Context(surf)
	con.scale(1280.0, 800.0)
	
	# Draw the background
	con.set_source_rgba(0.0, 0.0, 0.0, 1.0)
	con.rectangle(0.0, 0.0, 1.0, 1.0)
	con.fill()
	
	# Draw the edges without respect to z-order but set their alpha along
	# a linear gradient to represent depth.
	for e in graph.get_edgelist():
		# Get the first vertex info
		v0 = graph.vs[e[0]]
		x0 = (v0['x2'] - minX) / (maxX - minX)
		y0 = (v0['y2'] - minY) / (maxY - minY)
		alpha0 = (v0['z2'] - minZ) / (maxZ - minZ)
		alpha0 = max(0.1, alpha0)
		
		# Get the second vertex info
		v1 = graph.vs[e[1]]
		x1 = (v1['x2'] - minX) / (maxX - minX)
		y1 = (v1['y2'] - minY) / (maxY - minY)
		alpha1 = (v1['z2'] - minZ) / (maxZ - minZ)	
		alpha1 = max(0.1, alpha1)
		
		# Setup the pattern info
		pat = cairo.LinearGradient(x0, y0, x1, y1)
		pat.add_color_stop_rgba(0, 1, 1.0, 1.0,  alpha0 / 6.0)
		pat.add_color_stop_rgba(1, 1, 1.0, 1.0,  alpha1 / 6.0)
		con.set_source(pat)
		
		# Draw the line
		con.set_line_width(vertexRadius / 4.0)
		con.move_to(x0, y0)		
		con.line_to(x1, y1)
		con.stroke()
	
	# Draw vertices in z-order
	for i in zOrder:
		v = graph.vs[i]
		alpha = (v['z2'] - minZ) / (maxZ - minZ)
		alpha = max(0.1, alpha)
		radius = vertexRadius
		x = (v['x2'] - minX) / (maxX - minX)
		y = (v['y2'] - minY) / (maxY - minY)
		
		# Setup the radial pattern for 3D lighting effect 
		pat = cairo.RadialGradient(x, y, radius / 4.0, x, y, radius)
		pat.add_color_stop_rgba(0, alpha, 0, 0, 1)
		pat.add_color_stop_rgba(1, 0, 0, 0, 1)
		con.set_source(pat)
		
		
		# Draw the vertex sphere
		con.move_to(x, y)
		con.arc(x, y, radius, 0, 2 * numpy.pi)	
		con.fill()
	
	# Output the surface
	surf.write_to_png(fileName)

if __name__ == "__main__":
	# Load the tweets
	tweets = readTweets("data/tweets_cn220.csv")
	
	# Determine how many tweets we'll use to construct the network.
	numTweets = 1000
	
	# Load the graph, isolate the giant component, and calculate the layout.
	graph = getNetwork(tweets[0:numTweets])
	graph = graph.components(mode=igraph.WEAK).giant()
	layout = graph.layout_kamada_kawai_3d()
	
	# Now draw the frames while rotating.
	for frame in range(400):
		alpha = frame * numpy.pi / 200.
		beta = frame * numpy.pi / 150.
		print frame, alpha, beta
		drawGraph3D(graph, layout, (alpha, beta), "frames/%08d.png" % (frame))
Python script to plot a 3D network of Twitter mentions using igraph and Cairo.
python network-analysis data-visualization graph-theory

Let's Work Together

We'd welcome the opportunity to discuss how we can help your organization navigate the intersection of technology, governance, and strategy.