mpld3 networkx d3.js force layout

Author

Dheepak Krishnamurthy

Published

October 2, 2016

Keywords

python, mpld3, networkx, d3.js

mpld3 is a matplotlib to d3 library. It is lightweight and a pure Python / Javascript package, allowing a lot of the matplotlib interface to be accessible in the web. There are a number of examples on their website. Its integration with d3 allows someone familiar with Javascript to use Python and visualize using the power of d3. d3.js is a powerful low level visualization library and there are loads of examples online on the many features it brings to the table.

mpld3 also has the ability to add plugins to add new functionality. I wanted to take a shot at adding a d3.js force layout plugin. The force layout is a powerful visualization tool and NetworkX has a nifty function that will convert the graph along with its attributes into a JSON graph format. I’d played around with this before and figured this would be a nice feature to have, so I’ve worked on it over the weekend and here it is - a NetworkX to d3.js force layout plugin for mpld3. I’ve shared an example below.

%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import mpld3
mpld3.enable_notebook()

from mpld3 import plugins

fig, ax = plt.subplots()

import networkx as nx
G=nx.Graph()
G.add_node(1, color='red', x=0.25, y=0.25, fixed=True, name='Node1')
G.add_node(2, x=0.75, y=0.75, fixed=True)
G.add_edge(1,2)
G.add_edge(1,3)
G.add_edge(2,3)

pos = None

plugins.connect(fig, NetworkXD3ForceLayout(G, pos, ax))

I’ve implemented a sticky version of the force layout, since this is what I wanted. This can be turned off by passing an argument to the plugin. The reason it is called a sticky version is because dragging a node to a new position will fix it at that location. You can double click the node to release it.

These blocks were used as a reference[1].

I’ll run through an explanation of the code briefly.

fig, ax = plt.subplots()

This returns a figure and axes object. This plugin requires a single axes object to be passed to it. The figure and axes object, and everything that is on the axes object is converted to mpld3 plot objects. In theory, you could use NetworkX’s draw function to visualize the graph and mpld3 will render it fine. The only downside to that is that the final output will not be a force layout.

Next we create a graph with the following commands

import networkx as nx
G=nx.Graph()
G.add_node(1, color='red', x=0.25, y=0.25, fixed=True, name='Node1')
G.add_node(2, x=0.75, y=0.75, fixed=True)
G.add_edge(1,2)
G.add_edge(1,3)
G.add_edge(2,3)

I’ve set the color attribute of the first node to red. This is an attribute on the node object and will be used by the force layout to color the node. We can also set the (x, y) coordinates to values for the first and second node. Passing the fixed=True keyword argument assigns a attribute fixed with the value True on the NetworkX graph. When converted to a force layout, this will fix the positions of those nodes.

We are almost done! This registers the plugin with mpld3.

plugins.connect(fig, NetworkXD3ForceLayout(G, pos, ax))

The pos argument passed here is None. I plan to set it up such that you can pass a position dictionary to the plugin and have the plugin assign (x,y) coordinates when available. You can generate the pos dictionary using any of NetworkX’s layout functions.

Additional keywords arguments can be passed to the constructor of the NetworkXD3ForceLayout class. This allows a user to control certain force layout properties like gravity, linkDistance, linkStrength etc. You can also set a default node size or turn off the dragging feature. The full list of attributes that can be passed is found in the docstring. I plan to write a more detailed description in a following post.

Here is another example of a NetworkX graph converted to a force layout. This is Zachary’s Karate Club. Nodes in Mr Hi’s club are coloured purple and the rest are coloured orange. Node size is also changed based on the number of neighbours.

import matplotlib.pyplot as plt
import networkx as nx

fig, axs = plt.subplots(1, 1, figsize=(10, 10))
ax = axs

G=nx.karate_club_graph()
pos = None

for node, data in G.nodes(data=True):
    if data['club'] == 'Mr. Hi':
        data['color'] = 'purple'
    else:
        data['color'] = 'orange'

for n, data in G.nodes(data=True):
    data['size'] = len(G.neighbors(n))

mpld3.plugins.connect(fig,
    NetworkXD3ForceLayout(
        G,
        pos,
        ax,
        gravity=.5,
        link_distance=20,
        charge=-600,
        friction=1
    )
)

See mpld3 documentation for more information.

import mpld3

class NetworkXD3ForceLayout(mpld3.plugins.PluginBase):
    """A NetworkX to D3 Force Layout Plugin"""

    JAVASCRIPT = """
    mpld3.register_plugin("networkxd3forcelayout", NetworkXD3ForceLayoutPlugin);
    NetworkXD3ForceLayoutPlugin.prototype = Object.create(mpld3.Plugin.prototype);
    NetworkXD3ForceLayoutPlugin.prototype.constructor = NetworkXD3ForceLayoutPlugin;
    NetworkXD3ForceLayoutPlugin.prototype.requiredProps = ["graph",
                                                                "ax_id",];
    NetworkXD3ForceLayoutPlugin.prototype.defaultProps = { coordinates: "data",
                                                               gravity: 1,
                                                               charge: -30,
                                                               link_strength: 1,
                                                               friction: 0.9,
                                                               link_distance: 20,
                                                               maximum_stroke_width: 2,
                                                               minimum_stroke_width: 1,
                                                               nominal_stroke_width: 1,
                                                               maximum_radius: 10,
                                                               minimum_radius: 1,
                                                               nominal_radius: 5,
                                                            };
    function NetworkXD3ForceLayoutPlugin(fig, props){
        mpld3.Plugin.call(this, fig, props);
    };
    var color = d3.scaleOrdinal(d3.schemeCategory10);
    NetworkXD3ForceLayoutPlugin.prototype.zoomScaleProp = function (nominal_prop, minimum_prop, maximum_prop) {
        var zoom = this.ax.zoom;
        scalerFunction = function() {
            var prop = nominal_prop;
            if (nominal_prop*zoom.scale()>maximum_prop) prop = maximum_prop/zoom.scale();
            if (nominal_prop*zoom.scale()<minimum_prop) prop = minimum_prop/zoom.scale();
            return prop
        }
        return scalerFunction;
    }
    NetworkXD3ForceLayoutPlugin.prototype.setupDefaults = function () {
        this.zoomScaleStroke = this.zoomScaleProp(this.props.nominal_stroke_width,
                                                  this.props.minimum_stroke_width,
                                                  this.props.maximum_stroke_width)
        this.zoomScaleRadius = this.zoomScaleProp(this.props.nominal_radius,
                                                  this.props.minimum_radius,
                                                  this.props.maximum_radius)
    }
    NetworkXD3ForceLayoutPlugin.prototype.zoomed = function() {
        this.tick()
    }
    NetworkXD3ForceLayoutPlugin.prototype.draw = function(){
        plugin = this
        DEFAULT_NODE_SIZE = this.props.nominal_radius;
        var height = this.fig.height
        var width = this.fig.width
        var graph = this.props.graph
        var gravity = this.props.gravity.toFixed()
        var charge = this.props.charge.toFixed()
        var link_distance = this.props.link_distance.toFixed()
        var link_strength = this.props.link_strength.toFixed()
        var friction = this.props.friction.toFixed()
        this.ax = mpld3.get_element(this.props.ax_id, this.fig)
        var ax = this.ax;
        this.ax.elements.push(this)
        ax_obj = this.ax;
        var width = d3.max(ax.x.range()) - d3.min(ax.x.range()),
            height = d3.max(ax.y.range()) - d3.min(ax.y.range());
        var color = d3.scaleOrdinal(d3.schemeCategory10);
        this.xScale = d3.scaleLinear().domain([0, 1]).range([0, width]) // ax.x;
        this.yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]) // ax.y;
        this.force = d3.forceSimulation();
        this.svg = this.ax.axes.append("g");
        for(var i = 0; i < graph.nodes.length; i++){
            var node = graph.nodes[i];
            if (node.hasOwnProperty('x')) {
                node.x = this.ax.x(node.x);
            }
            if (node.hasOwnProperty('y')) {
                node.y = this.ax.y(node.y);
            }
        }
        this.force
            .force("link",
                d3.forceLink()
                    .id(function(d) { return d.index })
                    .strength(link_strength)
                    .distance(link_distance)
            )
            .force("collide", d3.forceCollide(function(d){return d.r + 8 }).iterations(16))
            .force("charge", d3.forceManyBody().strength(charge))
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("y", d3.forceY(0))
            .force("x", d3.forceX(0));
        this.force.nodes(graph.nodes);
        this.force.force("link").links(graph.links);
        this.link = this.svg.selectAll(".link")
            .data(graph.links)
          .enter().append("line")
            .attr("class", "link")
            .attr("stroke", "black")
            .style("stroke-width", function (d) { return Math.sqrt(d.value); });
        this.node = this.svg.selectAll(".node")
            .data(graph.nodes)
          .enter().append("circle")
            .attr("class", "node")
            .attr("r", function(d) {return d.size === undefined ? DEFAULT_NODE_SIZE : d.size ;})
            .style("fill", function (d) { return color(d); });
        this.node.append("title")
            .text(function (d) { return d.name; });
        this.force.on("tick", this.tick.bind(this));
        this.setupDefaults()
    };
    NetworkXD3ForceLayoutPlugin.prototype.tick = function() {
        this.link.attr("x1", function (d) { return this.ax.x(this.xScale.invert(d.source.x)); }.bind(this))
                 .attr("y1", function (d) { return this.ax.y(this.yScale.invert(d.source.y)); }.bind(this))
                 .attr("x2", function (d) { return this.ax.x(this.xScale.invert(d.target.x)); }.bind(this))
                 .attr("y2", function (d) { return this.ax.y(this.yScale.invert(d.target.y)); }.bind(this));
        this.node.attr("transform", function (d) {
            return "translate(" + this.ax.x(this.xScale.invert(d.x)) + "," + this.ax.y(this.yScale.invert(d.y)) + ")";
            }.bind(this)
        );
    }
    """

    def __init__(self, graph, ax,
                 gravity=1,
                 link_distance=20,
                 charge=-30,
                 node_size=5,
                 link_strength=1,
                 friction=0.9):

        self.dict_ = {"type": "networkxd3forcelayout",
                      "graph": graph,
                      "ax_id": mpld3.utils.get_id(ax),
                      "gravity": gravity,
                      "charge": charge,
                      "friction": friction,
                      "link_distance": link_distance,
                      "link_strength": link_strength,
                      "nominal_radius": node_size}

References

[1]
“SVG Semantic Zooming.” [Online]. Available: https://bl.ocks.org/mbostock/3680957.

Reuse

Citation

BibTeX citation:
@online{krishnamurthy2016,
  author = {Krishnamurthy, Dheepak},
  title = {Mpld3 Networkx D3.js Force Layout},
  date = {2016-10-02},
  url = {https://kdheepak.com/blog/mpld3-networkx-d3js-force-layout},
  langid = {en}
}
For attribution, please cite this work as:
D. Krishnamurthy, “mpld3 networkx d3.js force layout,” Oct. 02, 2016. https://kdheepak.com/blog/mpld3-networkx-d3js-force-layout.