Back in November, when I started playing with the code that eventually turned into the visualization of the Humanist list that I posted last month, I thought I’d be able to pretty much just cobble together existing libraries for displaying networks in the browser. There’s a lot of cool stuff floating around the web – maybe most recognizable is the “force” layout that ships with d3, which lets you drag a network around and watch the force-directed layout react in real-time, which makes it possible to get a kind of intuitive sense of the internal structure of the graph. Here, though, I actually didn’t want the layout to be interactive in that sense. The idea was to show how one particular layout – computed in advance with Textplot and Gephi – matched up with the temporal progression from the beginning to the end of the corpus, so I needed things to stay in more or less the same place.
After poking around a bit, I realized that there actually aren’t many existing tools to do this. Sigma.js seemed like an interesting option, but I wanted a pretty low level of control over the look and feel of things, so I decided to build something from scratch just using d3’s basic SVG manipulation utilities. This turned out to be fun, but also surprisingly hard. I knew I’d run into performance problems sooner or later – the Humanist network adds up to about 1,000 nodes and 5,000 edges, and it’s hard to show more than a couple thousand SVG elements before the framerate drops down into the single digits. Along the way, I also found out that networks are tricky from a design standpoint – especially when they involve words, I think. It’s a weird mix of panning, zooming, and reading, and I stumbled into a familiar set of problems that crop up when you try to display text in some kind of dimensional space where the words aren’t allowed to flow naturally based on their own widths.
Adjusted font-size scaling
As soon as I got the nodes laid out on the screen, I realized that it was impossible to pick a single front size for the labels that worked well at all zoom levels. If you make it big, the words look good when you zoom in and each node gets a lot of space on the screen:
But, when you zoom back, the labels spill over the boundaries of the network and it becomes hard to get a sense of the overall shape:
This works much better when the labels are small:
But then, of course, when you zoom back in everything stays tiny and it’s impossible to actually read any of the labels, which is the whole point:
One way to get around this would be to use “geometric” zooming, where the entire SVG container (and everything inside of it) is scaled up and down as a unit, as if it were a regular image. In this case, though, I wanted to stick with “semantic” zooming, which works better when it comes time to render the edges between nodes. But, how to scale the labels so they look reasonable at different zoom levels? After a while, I realized that d3 actually has a tool to handle exactly this type of problem, although it’s usually used in a different context. In d3, a “scale” is basically just a way to change the range of a set of data. This crops up all the time in visualization projects because the data you care about almost never comes in units that correspond to the actual pixels on the screen. For example, if you’re graphing the price of a barrel of oil, the data might range from $40 – $100 a barrel. But, if you’re working with a 200px-high SVG container, those values need to be scaled to match the pixel-space of the screen – $40 becomes 0px, $100 becomes 200px, etc.
But, this is also a good way to model a better relationship between zoom level and font size. I was already setting a pair of constants to control the minimum and maximum zoom levels, and I realized that I could just scale those two values onto a hand-picked range in font sizes that looked good at different zoom levels. In essence, it’s just this:
Then, as you zoom in and out, the labels fluidly resize to match the zoom level at any given moment.
“Spatial” querying on the edge list with rbush
Once the node labels were in place, the next step was to render the edges. This is where I hit the first real problems with performance. I tried just throwing all ~5,000 of them onto the screen as a big heap of
line elements, but things got really choppy (and, in Firefox, almost unusable). Short of scrapping d3 and using a canvas, or something, the only real solution is to find a way to cut down the number of SVG nodes in the DOM. But how to crop down the edge list, short of just randomly throwing out data or rebuilding the network at a lower level of complexity? If you’re working with point data, you can do things like marker clusters or heatmaps, which basically “blur” together the points into a simpler representation of the underlying density of the data.
rbush pops out the list of edges that intersect with the current viewport, the edges are sorted by weight, and the top N heaviest edges are skimmed off the top and draw into the scene as line elements:
A cool side effect to this is that it gives a sense of underlying patterns in the edge weights – the main “corridors” of strong links that wire up the network – which actually aren’t apparent when you just show everything at once.
Inverting the edge index with a custom Grunt task
Once this was up and running, the last piece of the puzzle with the edges was to display the connections between a node and it’s “siblings” – when the cursor hovers over a word, all of the words that share edges with that word should light up, along with the edges themselves:
This seemed simple enough, but, as I started to sketch it in, I realized that I was going to hit performance problems again. The problem is that the raw GML that comes out of Textplot (via the excellent NetworkX library) stores the node and edge data in a fully normalized form – the nodes are listed out, and then the edges are defined as associations between the node ids:
This is really space-efficient and makes sense for a general-purpose format like GML, but it means that it’s computationally expensive to round up a list of all the siblings for a given node – you have to scan through the entire stack of edges and pick out every one where either the source or the target matches the id of the node you’re interested in. If it were SQL, you’d have to do something like:
SELECT * from edges where source=[id] or target=[id]
This could work fine as a one-off thing – looping through a 5,000-element array isn’t totally crazy, especially when you’re not touching the DOM. But it gets questionable inside of a UI callback, especially one that’s triggered by cursor movement – if the user slides the cursor across the scene, it might trigger hover events on 30-40 nodes, and all that looping would start to really hammer the UI thread, especially on older browsers / machines. To continue with the analogy, in SQL-land you’d just throw an index onto the
target columns, which would build out a map between each node id and the set of edges where that id shows up as the source or target. In the absence of a database, though, I realized that I could just do this manually – precompute it once and bake it into the JSON payload that gets pulled in on pageload. Since I was already using Grunt to handle the rest of the build steps. I decided to just wrap this up as a custom grunt task.
The first step is to just map the label of a node onto its metadata:
And then a walk through the edge list, writing the label of the source node onto a
targets key on the metadata of the target node, and vice versa:
So, if an edge connects nodes 1 and 2, where node 1 is “facebook” and node 2 is “twitter,” we get:
And so on and so forth. Once all is said and done, the display code just has to do a single key lookup to get the list of nodes that should be highlighted. This does add some weight to the JSON payload, but not much – for the Humanist, it goes from 506k to 658k, which isn’t too bad.
Manipulating the “centroid” of the viewport
This last one is a bit more down in the weeds, but it was actually one of the less intuitive things that I ran into on this project, although in retrospect it’s pretty simple. I thought I’d make note of it, though, in case someone else needs to do something similar in the future. Basically, there are a couple of situations where I needed to programmatically set the “centroid” of the viewport. For example, when the user clicks on a word in the drop-down search box, the view should center on that word. This is a little tricky, though, because
d3.zoom() doesn’t have any built-in notion of a “focus position,” in the way mapping libraries like OpenLayers or Leaflet generally have some kind of
setCenter(lon, lat) that will frame the viewport around a particular location. Instead,
d3.zoom() just changes the state of the scales that map coordinates from the source data into the pixel space on the screen. So, when the
zoom event fires, you re-render the position of the nodes by passing their coordinates through the updated scales, and then setting a
transform=translate(x,y) on the node:
Usually, this is all you have to do –
d3.zoom() does all the tricky work of updating the scales in response to click and scroll events, and you just have to apply the new translation vectors to the elements. But, say you want to focus the screen on the word “bitnet,” which sits at a coordinate of (200,300) in the network layout that came out of Gephi. How do you programmatically update the X- and Y-axis scales so that they center around that coordinate on the screen? How do you say, essentially, “take me to this location in the domain of the data set”? Here’s what I ended up doing:
- Reset the translation vector of the zoom control to
[0, 0], but don’t trigger the
zoomevent, since we don’t want the scene to flicker back to the default focus before jumping to the right location.
- Pass the original X/Y coordinates through the X- and Y-axis scales to get pixel-space coordinates on the screen.
- This is where things get confusing – to get the new translation vector for the zoom control, flip the signs on each of the coordinates. This is because we’re actually “moving” the underlying data, not the viewport – so, to pan the viewport to the right, you actually have to shift the nodes to the left.
- Last, before actually setting the new translation vector, pad the values so that the point you care about ends up in the center of the screen, not the top left corner – just measure the height and width of the viewport, and add 1/2 the width to the inverted X coordinate, and 1/2 the width to the inverted Y coordinate.
- Trigger the zoom even on the container with
zoom.event(container), which will fire the callbacks that actually set the new translations on the elements.
With this in place, we can just pass in the original X/Y coordinates that came out of Gephi, and the viewport will center around that point.
I had a blast working on this. Since finishing it last month, I’ve had a couple of conversations that make me wonder if it would be useful to break away the guts of this code into some kind of standalone, general-purpose library for rendering these kinds of networks. (Micki Kaufman, after providing lots of great feedback and helping fix some bugs, was able to adapt the code to create some interactive versions of networks from her Quantifying Kissinger project, which analyzes a corpus of telephone transcripts and meeting notes from Kissinger’s tenure in the White House.)
I’m not sure what form this would take. A custom d3 layout? Or maybe a separate library – sort of like Sigma.js, but more customizable and composable? Also – what if this kind of library implemented the full set of styling options in Gephi? How cool would it be if you could just create a network layout with the GUI in Gephi, save off the GML, pass it directly into the library, and get a visually identical (but fully interactive) version of the same thing in the browser?