the cover image of blog post Network diagram with D3.js

Network diagram with D3.js

2025-02-12
11 min read

Every article in my blog site is linked with a few keywords. I call them tags. I was thinking to build a dedicate page for all the tags and their related articles so the readers can find related articles there.

My first idea was builidng a list page with tabs, it is perfectly fine but the layout is so dull. As articles and tags are linked mutually, I started to think to build a graph view for it. image

Here comes the D3.js network graph! If you didn't hear about D3.js, it's one of the most famous JavaScript libraries for data visualization.

This blog post will not be an exhaustive tutorial about how to use D3.js to build the network chart, but I will try my best to show you how I built it for my own scenario.

Data preparation

Network diagram is composed with nodes and links, in both you can have other custom fields but id is crucial to connect the nodes.

// target structure { "nodes": [ { "id": 1, "name": "A" }, { "id": 2, "name": "B" } ], "links": [ { "source": 1, "target": 2 } ] }

My initial data about blog articles is like this, an array and each element has a tags list,

[ { slug: '1', title: 'xxx', tags: ['docker', 'next.js'] } { slug: '2', title: 'yyy', tags: ['react', 'next.js'] } ... ]

To do the data tranformation I use ramdajs like this, it's not easy to know what happened in a firt glance, the following flow would be helpful.

[ { slug: '1', title: 'xxx', tags: ['docker', 'next.js'] } { slug: '2', title: 'yyy', tags: ['react', 'next.js'] } ... ] // map with tags => [ [ { slug: '1', title: 'xxx', tag: 'docker' }, { slug: '1', title: 'xxx', tag: 'next.js' }, ] [ { slug: '2', title: 'yyy', tag: 'react' }, { slug: '2', title: 'yyy', tag: 'next.js' }, ] ... ] // flatten => [ { slug: '1', title: 'xxx', tag: 'docker' }, { slug: '1', title: 'xxx', tag: 'next.js' }, { slug: '2', title: 'yyy', tag: 'react' }, { slug: '1', title: 'yyy', tag: 'next.js' }, ... ] // group by tag => { 'docker': [{ slug: '1', title: 'xxx', tag: 'docker' }, ], 'next.js': [{ slug: '1', title: 'xxx', tag: 'next.js' }, { slug: '2', title: 'yyy', tag: 'next.js' }, ], 'react': [{ slug: '2', title: 'yyy', tag: 'docker' }, ], ... } // map on keys => [ { id: 'docker', label: 'docker', group: 'tag', weight: 1 }, { id: 'next.js', label: 'next.js', group: 'tag', weight: 2 }, { id: 'react', label: 'react', group: 'tag', weight: 1 }, ... ]

Now I have got a list of tag nodes, to construct all nodes for the network diagram I just need to concatenate it with article nodes, the result should be like below,

[ { id: 'docker', label: 'docker', group: 'tag', weight: 1 }, { id: 'next.js', label: 'next.js', group: 'tag', weight: 2 }, { id: '1', label: 'title xxx', group: 'article', weight: 1 }, ... ]

at the same time the links part can be done with a map on flatten list above easily.

"links": [ { "source": '1', "target": 'docker' }, { "source": '1', "target": 'next.js' } ... ]

Force simulation

The most challenge in the network diagram is deciding the position of each node with consideration of linking, collision, etc. Luckily we don't have to touch that complicated calculation by ourselves, d3-force provides a bunch of utility functions to help the simulation.

d3.forceSimulation(nodes) .force( 'link', d3.forceLink<Node, Link>(links).id((d) => d.id) ) .force( 'collide', d3.forceCollide((d) => getRadius(d.weight) + 8 * RADIUS) ) .force('charge', d3.forceManyBody().strength(-20)) .force('center', d3.forceCenter(width / 2, height / 2))

Explanations:

  1. feed links data to d3.forceLink so linked nodes can be arranged around.
  2. I'd liket to adjust the radius of node circle by weight in the diagram, so an arrow callback function is provided to forceCollide to tell the radius of range avoiding node collision.
  3. forceManyBody is used to simulate gravity(attraction) if strength is positive or electrostatic charge(repulsion) if it's negative. here I set it to -20 to keep the nodes apart loosely.
  4. forceCenter decides the mean position of all nodes, here I just use the center point of the canvas.

When the simulation is done, the position (x/y coordinates) and index will be added to each node,

[ { id: 'docker', label: 'docker', group: 'tag', weight: 1, x: 200, y: 35, index: 0 }, { id: 'next.js', label: 'next.js', group: 'tag', weight: 2, x:48, y: 100, index: 1 }, { id: '1', label: 'article title', group: 'article', weight: 1, x: 225: y: 40, index: 2 }, ... ]

Rendering

Running a force simulation is a process of iteration essentially, it takes time, a common practise is to use the on('tick') callback to render the updated nodes at each iteration.

.on('tick', () => { drawNetwork(nodes, links); })

My first attemp for rendering was using SVG, i.e. <circle/> for each node and <line/> for edges; but it didn't really work because a single force simulation can have hunderds of iterations, updating the DOM so heavily was quite bad in terms of performance.

So I switch to canvas for rendering with the inspiration from this article.

However, I still need some sort of interactivity from the network diagram, e.g. clicking an article node I wish it can redirect to the targeted page, it is not easy to attach event listeners when using canvas.

I figured out a workaround. Besides the on('tick') event, there is on('end') event from the force simulation so I know when the simulation is done. This is crucial to let me generate the SVG elements only once depending on the completion of simulation, this tremendously improved the performance.

The following is the gist of rendering code, it is the typical react way, nothing amazing, in a nut shell, the canvas is in charge of animation(for the simulation) but the svg renders the final state and provides the interactivity.

const initData = { nodes: [] as Node[], links: [] as SimulatedLink[] }; const [simulatedData, setSimulatedData] = useState(initData); d3.forceSimulation(nodes) ... .on('tick', () => { drawCanvas({ nodes, links }); }); .on('end', () => { setSimulatedData({ nodes, links }); }); ... return( {simulatedData && ( <svg viewBox={`0 0 ${width} ${height}`} > <g> {simulatedData.links.map((d, i) => (<line key={i} x1={d.source.x} y1={d.source.y} x2={d.target.x} y2={d.target.y} stroke="#34342F" />)} </g> <g stroke="currentColor" strokeWidth="1"> {simulatedData.nodes.map((d, i) => { return ( <g key={i} onClick={() => navigate(d.id)}> <circle cx={d.x} cy={d.y} r={getRadius(d.weight)} ... /> <text x={d.x && d.x - 50 + getRadius(d.weight)} y={d.y && d.y + 15 + getRadius(d.weight)} ... > {d.label} </text> </g> ); })} </g> </svg> ) )}

The below screenshot is part of the final diagram generated, you can play around from tags page, or dig into the code details from my github repo: https://github.com/xavierchow/xblog . image

© 2025 Xavier Zhou