Regular Memory

Draggable Network Graph with D3.js V5

Posted at — Nov 18, 2019

If you work with graphs, it’s useful to have a fast way to interactively visualize a set of connected nodes. This post explains the D3 code required to go from a text file describing a set of edges to an interactive graph visualization.

If you just want the code check out this gist.

D3.js: a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS.

Below is an interactive example of the final product. Click and drag nodes around in the graph. Edit the textarea then click the graph to see your changes in realtime.

Setup D3

The setup process for D3.js is straightforward. We just include the library in a script tag. A second script tag at the end of the body element is where we will write our code. The code below should be saved in a file called index.html.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- index.html -->
<!DOCTYPE html>
<html><head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Include D3.js -->
    <script src="https://d3js.org/d3.v5.js"></script>

</head>
<body>
<script>
    // D3 code here
    console.log(d3);
</script>
</body>
</html>

Describe a Graph in a Text File

As shown above in the interactive example, our graph is defined by a list of edges. If you’re following along, create a file called graph.json in the same directory as index.html and add the following content:

1
2
3
4
5
6
[
    ["A", "B"],
    ["C", "D"],
    ["A", "C"],
    ["C", "E"]
]

We use a list of edges to describe our graph. The set of unique strings we find while parsing the edges becomes our set of vertices.

Parse Text File Data

D3 can load a JSON file and fire a callback with the parsed result, so that’s what we’ll use to get the graph data. There’s just a bit of boiler plate first to create an SVG context on which to render our graphics. Modify the index.html file to include the following code in the script section.

12
13
14
15
16
17
18
19
20
21
22
23
24
<script>
// index.html script section
var width = 960,
    height = 500;

var svg = d3.select("body").append("svg")
    .attr("width", width)
    .attr("height", height);

// Read the Note below if this throws an error
d3.json("graph.json").then(data => console.log(data))
.catch(e => console.log(e));
</script>

Note, D3 won’t be able to load the file in certain browsers if you try to serve it over file://. The easiest way fix this is to run a local server with NodeJS. With NPM installed, run the following command to globally install the http-server node package:

npm install http-server -g

After a successful install, run the following command in the local directory to host the current directory as a static server.

http-server

Visit localhost:8080 to see our running app, which is currently just a white screen!

We now see the following message printed to the browser’s console, which displays the JSON object from graph.json:

Now we have data and an SVG context, so we’re ready to start writing D3 code to render our graph.

Format the Data and Render the Graph

The section is split into two: Format the Data and Render the Graph. In both these subsections, there’s a fair bit of code to walk through. Each code block will display line numbers relative to the index.html file, which means, if you’re following along, its best if your index.html file is an exact match to what’s shown below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html><head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" type="text/css" href="style.css">

    <!-- Include D3.js -->
    <script src="https://d3js.org/d3.v5.min.js"></script>

</head>
<body>
<script>
    var width = 960,
        height = 500;

    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height);

    d3.json("graph.json").then(data => {

        // code will go here

    }).catch(e => console.log(e));

</script>

Note, on line 5 we added a link to style.css. Create a file called style.css in the same directory as index.html and add the following lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* style.css*/
html, body {
    padding: 0px;
    margin: 0px;
    height: 100%;
    font-family: "HelveticaNeue-Light",
        "Helvetica Neue Light", "Helvetica Neue",
        Helvetica, Arial, "Lucida Grande", sans-serif;
    font-weight: 300;
}

svg {
    width: 100vw;
    height: 100vh;
}

.edge {
    stroke: #555;
}

.node>text {
    stroke: #333;
}

.node>circle {
    stroke: #555;
    stroke-width: 3px;
    fill: white;
    cursor: pointer;
}

The CSS above will ensure our rendered graph looks nice once we display it.

Format the Data

Looking back at our graph.json file, we only specified the edges of our graph. Specifying only the edges makes it easy to modify the text representation of our graph, but it means we’ll have to do a bit of extra work in code to extract information about the vertices, but it’s code, so hopefully we’ll only have to write it once. Place the following code inside the callback from D3’s json method.

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
d3.json("graph.json").then(data => {

    // Object to hold nodes and edges
    let graph = {"nodes": {}, "edges": []};

    // For each edge from graph.json
    // create an object with a source and target
    data.forEach(edge => graph.edges.push({
        "source": edge[0],
        "target": edge[1]
    }));

    // Create flat (1-dimensional) array from the list of edges
    let nodes = data.flat();
    // Remove duplicates from the list of vertices
    nodes = [...new Set(nodes)];

    // Create dictionary where each node will map to its properties
    for (node of nodes) {
        graph.nodes[node] = {
            "label": node,
            // Assign random coordinates to each vertex
            "x": Math.random() * 500 + 100,
            "y": Math.random() * 500 + 100
        };
    }

    // Add node refs to each edge
    graph.edges.forEach(d => {
        d.source = graph.nodes[d.source];
        d.target = graph.nodes[d.target];
    });

}).catch(e => console.log(e));

The code above creates a data structure called graph to hold information about our nodes and edges. We iterate over the data object in order to format our edges. We find all the unique vertex names from our data and assign labels and random coordinates to each vertex. None of that code uses D3, but it’s time to start in the next section.

Render the Graph

The code in this section should be placed inside D3’s json callback after the code from the previous section.

D3 might look a bit strange the first few times you see it, but here’s whats happening in the next code block; svg.selectAll(".edge") selects all elements with class edge even if none exist yet. We then give D3 an array representing our data .data(graph.edges). Finally, we call .enter() and describe how to build an edge out of an element from our data array. A line is an SVG element with two pairs of coordinates representing end points.

55
56
57
58
59
60
61
62
var edge = svg.selectAll(".edge")
    .data(graph.edges)
    .enter().append("line")
    .attr("class", "edge")
    .attr("x1", d => d.source.x)
    .attr("y1", d => d.source.y)
    .attr("x2", d => d.target.x)
    .attr("y2", d => d.target.y);

Each node is a circle and a text element. We wrap the circle and text inside a group which SVG calles a g tag.

64
65
66
67
var node = svg.selectAll("node")
    .data(Object.values(graph.nodes))
    .enter().append("g")
    .attr("class", "node");

Here we describe that each g tag node group will contain a circle with a given radius r and coordinates. We assigned random values to the coordinates earlier.

69
70
71
72
73
74
node.append("circle")
    .attr("r", 20)
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)
    // Leave out the dragging for now
    //.call(d3.drag().on("drag", dragged));

Finally, a text element is added to each node which will show the nodes name.

76
77
78
79
80
81
82
node.append("text")
    .attr("dx", d => d.x)
    .attr("dy", d => d.y)
    .attr("text-anchor", "middle")
    .attr("dominant-baseline", "central")
    .attr("pointer-events", "none")
    .text(d => d.label);

Refreshing the app now will show our graph with vertices at random locations and the nodes will not be draggable. In the next section, we add one more code block to add draggability.

Making Nodes Draggable

To make nodes draggable, add the following function definition inside D3’s json callback after the code from the previous section.

84
85
86
87
88
89
90
function dragged(d) {
    d.x = d3.event.x, d.y = d3.event.y;
    d3.select(this).attr("cx", d.x).attr("cy", d.y);
    d3.select(this.parentNode).select("text").attr("dx", d.x).attr("dy", d.y);
    edge.filter(function(l) { return l.source === d; }).attr("x1", d.x).attr("y1", d.y);
    edge.filter(function(l) { return l.target === d; }).attr("x2", d.x).attr("y2", d.y);
}

Also, uncomment line 74 - this line .call(d3.drag().on("drag", dragged)); - to connect the dragged function with each node.

Conclusion

That’s everything. We now have a draggable network graph we can easily modify by adding/removing edges from the graph.json file, and, hopefully, we learnt something about D3 along the way. Get the full code in one file here.

comments powered by Disqus