How to Make Stunning Data Visualizations With D3.js

How to Make Stunning Data Visualizations With D3.js

Displaying hierarchical data sets as interactive treemaps
Ferenc Almasi • Last updated 2021 November 11 • Read time 23 min read
Learn how you can use D3.js to create a treemap for visualizing project structures and to spot, which files takes up the most space.
  • twitter
  • facebook
JavaScript

This tutorial was written using v5 of D3. To update to version 6, you can clone the repository from GitHub. See the related pull request for the changes involved.

As data becoming more and more prevalent, making them come to life with visualizations also becomes more common. It helps us understand trends and patterns that otherwise would be hard to spot.

In this tutorial, we will be looking at how to make a treemap. Inspired by Mike Bostock’s — the creator of D3.js — solution on Observable, we will go through how this can be done in the latest, fifth version of D3.js.

D3.js examples on the front page

What is D3?

D3.js is a JavaScript library that we can use to create data visualizations with the use of HTML, CSS, and SVG. The name itself stands for Data-Driven Documents.

It’s an extremely powerful framework for creating all kinds of visualizations with smooth animations and interactivity. By the end of this tutorial, you will learn how to create the following interactive treemap:

The final treemap, output of this tutorial

Project Setup

Let’s jump into setting up our project. Create a new folder with an index file and import D3.

Copied to clipboard! Playground
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>D3</title>
        <link rel="stylesheet" type="text/css" href="styles.css" />
    </head>
    <body>
        <svg class="treemap"></svg>
        
        <script src="https://d3js.org/d3.v5.js"></script>
        <script src="treemap.js"></script>
    </body>
</html>
index.html

I’ve also created a styles.css file to hold some resets as well as treemap.js that will be responsible for generating the treemap. Everything else will be handled by D3. We will populate the SVG from JavaScript.

All I have at this point in styles.css are two rules to make the SVG take up the whole screen.

Copied to clipboard! Playground
body {
    margin: 0;
}

svg {
    position: fixed;
    font: 12px sans-serif;
}
styles.css

Before starting to work on the treemap however, we will need a dataset to work with.

Looking to improve your skills? Check out our interactive course to master JavaScript from start to finish.
Master JavaScriptinfo Remove ads

Generating Data

We will be working with a JSON file. At the end, we want to have a structure like this:

The dataset used for the treemap

We have the root folder at the top. Each folder can have files or sub-folders. Sub-folders are denoted by a name and an array of children. If we come across a file, we also want to store its size as the value.

To generate the data, we can create a recursive function. For this, I’m using a similar implementation I’ve created in one of my previous tutorials. There I explain how recursion works. This is the function:

Copied to clipboard! Playground
const getFiles = folder => {
    if (!fs.existsSync(folder)) {
        return null;
    }

    const projectTree = {
        name: folder,
        children: []
    };

    const isDirectory = fs.statSync(folder).isDirectory();

    if (isDirectory) {
        const files = fs.readdirSync(folder);

        files.forEach(file => {
            const isSubDirectory = fs.statSync(`${folder}/${file}`).isDirectory();

            if (isSubDirectory) {
                const child = getFiles(`${folder}/${file}`);

                child.name = file;

                projectTree.children.push(child);
            } else {
                projectTree.children.push({
                    name: file,
                    value: fs.statSync(`${folder}/${file}`).size
                });
            }
        });
    } else {
        projectTree.children.push({
            name: folder,
            value: fs.statSync(folder).size 
        });
    }

    return projectTree;
};
fileTree.js

Let’s have a brief look at how it works.

Understanding the data generation function

We can generate an object by calling the function with a folder of choice. Since it returns a JavaScript object, we will have to call JSON.stringify on it. Let’s break down how it works:

  • Line:2: We start with some checks. If the folder doesn’t exist, we return with null.
  • Line:6: We define the root object. It has a name and some children.
  • Line:13: If we are dealing with a directory, we want to loop through each file, starting from line:16.
  • Line:19: If one of the files is a sub-directory, we want to call this function with a new path. The output value of this will be a child node.
  • Line:25: If we are dealing with a file, we can simply append it to children with its name and size.
  • Line:32: The same steps can be done if we are at the root and the parameter we passed in is a file.

You can run this function on the same folder I’m using, to get the same results. I’m using one of React’s packages. Don’t forget to wrap it in JSON.stringify. Save the output as data.json as we will use it later.

Copied to clipboard!
// Run it with node fileTree.js
console.log('filetree:', JSON.stringify(getFiles('src')));
fileTree.js

Making The Treemap Setup

Now that we have everything set up, let’s jump into treemap.js. First, start off with defining some configuration variables.

Defining configurations

Add the following to your treemap.js.

Copied to clipboard! Playground
const width = window.innerWidth;
const height = window.innerHeight;

const filePath = 'data.json';

let data;
let color = d3.scaleSequential([8, 0], d3.interpolateCool);
treemap.js

We want to show the treemap in full-screen, so we are going with the window’s dimensions for width and height. We also want to define the path for the data at the beginning so we can easily change it later.

Lastly, I’ve created two other variables, one that will hold the JSON data once we read the file. And another one for the color scheme. This will return a new function we can call with different numbers. For each number, it will return a different color.

The color function

We will use the depth of the tree to create various colors for different levels.

Creating the treemap function

First, we need a function that will create a treemap for us based on the passed data. We can use the built-in d3.treemap function for this. Add the following to your treemap.js.

Copied to clipboard! Playground
const treemap = data => d3.treemap()
                          .size([width, height])
                          .paddingOuter(5)
                          .paddingTop(20)
                          .paddingInner(1)
                          .round(true)
                          (d3.hierarchy(data)
                          .sum(d => d.value)
                          .sort((a, b) => b.value - a.value));
treemap.js

Let’s examine what this function does. We call d3.treemap and set some parameters:

  • We make it fullscreen
  • Set some paddings to make the rectangles nicely separated
  • Enable rounding for the width and height of the rectangles
  • Then we call the treemap function with d3.hierarchy, passing our JSON data. This will decorate our dataset with extra properties such as depth or parent. We then need to sum and sort the dataset so we have the largest element on top.
What each padding does for treemap

Getting data from the JSON

The next step is to get the JSON data. For this we can use d3.json.

Copied to clipboard!
(async () => {
    data = await d3.json(filePath).then(data => data);
    render(data);
})();
treemap.js

Since this function uses the Fetch API internally, we will need to use a web server. For this purpose, I’m using the http-server module, which is a perfect solution for serving static assets. You can get it installed globally by running npm i http-server -g.

After that, we can pass the data into a render function that will draw everything into the screen. So let’s create that now.


Making Treemaps

First, we want to define the root of the treemap and get the SVG from the DOM. This is where we will place everything.

Copied to clipboard! Playground
const render = data => {
    const root = treemap(data);
    const svg  = d3.select('.treemap')
                   .attr('viewBox', [0, 0, width, height]);
};
treemap.js

This can be done with d3.select. It works just like a jQuery selector. We can also manipulate attributes with the attr method. Since each d3 call returns the same object, we can chain calls one after the other. We want to make our SVG take up the full screen. This is why we set the viewBox to the width and height of the screen.

Also the root will now have information on the position and dimension of each node, so we know where to place them.

Creating SVG groups

Now we need to make the nodes for the groups which will hold the rectangles and their title. Add the following to the render function.

Copied to clipboard! Playground
const node = svg.selectAll('g')
                .data(d3.nest().key(d => d.height).entries(root.descendants()))
                .join('g')
                .selectAll('g')
                .data(d => d.values)
                .join('g')
                .attr('transform', d => `translate(${d.x0},${d.y0})`);
treemap.js

For the first sight, this may seem intimidating but let’s see what each of the functions does. First, we select the SVG and select all group elements inside it. Since we don’t have any, D3 will create this for us based on the length of the data we pass in.

The second data function is used for data binding. It expects an array. For each layer, we want to create a separate group. From the descendants of our treemap, d3.nest.key.entries will calculate how many depths we have. Based on the height property of the datum that is passed to key.

values returned from d3.nest
data returned from d3.nest().key(d => d.height).entries(root.descendants()), 4 groups

We then need to call join to update the elements to match the data that was previously bound by data.

Then we want to bind data once more. In order to do that, we have to call selectAll again. But this time, we will use the values returned by the previous data bind. This is so that for each array element, we create a new group item inside each top group. After that, we need to call join once more to update the elements.

So now we have the data available to add a transform to each element. This is used for positioning the rectangles inside the map.

How we position each rectangle with attr
The generated DOM

Creating rectangles

Now let’s get something showing on the screen. We first want to create the rectangles. Add the following after the node variable.

Copied to clipboard!
node.append('rect')
    .attr('fill', d => color(d.height))
    .attr('width', d => d.x1 - d.x0)
    .attr('height', d => d.y1 - d.y0);
treemap.js

In each attr call, d represents datum, that is the properties of each element. For every node, we append a rectangle and choose the fill color based on its height property. This is where we use the color function which we defined. We can also calculate the width and height based on the starting and ending points. Now you should have some empty rectangles drawn on the screen:

Empty rectangles drawn by d3.js

Adding labels

Let’s add labels as a next step. Add the following lines after the rectangles.

Copied to clipboard! Playground
node.append('text')
    .selectAll('tspan')
    .data(d => [d.data.name, d.value])
    .join('tspan')
    .attr('fill-opacity', (d, i, nodes) => i === nodes.length - 1 ? 0.75 : null)
    .text(d => d);
treemap.js

Here we use the selectAll-data-join chain again. This will create two tspan elements inside the text node. One with the name and one with the value. On line:5, we also set the opacity of the value to 75%. As you can see, the callback functions can accept three arguments:

  • one for the datum, holding information about the node. Since we bound d.data.name and d.value previously, it will only hold those values.
  • one for the index in the loop
  • one for the nodes which hold references to the DOM nodes

So if the loop is at the very last element, which holds d.value, we reduce the opacity. Otherwise, we can leave it as is. If you refresh the page, we will now have some titles. But they are position pretty badly. Let’s fix it.

labels generated by d3js

Positioning labels

We want to position the parents and children separately. To do that, add two new blocks to treemap.js.

Copied to clipboard! Playground
// Set position for parents
node.filter(d => d.children)
    .selectAll('tspan')
    .attr('dx', 5)
    .attr('y', 15);
    
// Set position for everything else that doesn't have children
node.filter(d => !d.children)
    .selectAll('tspan')
    .attr('x', 3)
    .attr('y', (d, i, nodes) => i === nodes.length - 1 ? 30 : 15);
treemap.js

This can be done by using filters. For the first block, anything that has children will be selected. The opposite is true for the second block. For the parents, we want the name and value to be on the same line. For the children, we want them on separate lines. We can follow the same logic we used for fill-opacity to correctly position both tspan elements.

Labels positioned with D3

It starting to come together, but we have one more problem. The texts are overflowing.

Fixing overflow

To fix this, we want to add a clipPath element, with the same dimensions as the rectangles. In SVG, this can be done using the use tag with a reference to the id of the rectangle.

referencing rectangles in clip path

Since we have quite a few elements on the page, we need to somehow generate unique ids. For this, I’ve turned to StackOverflow to find out what is the best approach. This is the function that I ended up using:

Copied to clipboard! Playground
const uuidv4 = () => {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        const r = Math.random() * 16 | 0;
        const v = c == 'x' ? r : (r & 0x3 | 0x8);

        return v.toString(16);
    });
};
treemap.js

First, let’s extend the rectangles with an id attribute.

Copied to clipboard!
node.append('rect')
    .attr('id', d => d.nodeId = uuidv4())
    ...
treemap.js
Adding an id for the existing rectangles

We can also add additional properties to datum. Later we’re going to reuse nodeId in the clipPath element. So let’s add that next. Add it just after we defined the rectangles.

Copied to clipboard! Playground
// Create clip path for text
node.append('clipPath')
    .attr('id', d => d.clipId = uuidv4())
    .append('use')
    .attr('href', d => `#${d.nodeId}`);
treemap.js
Add clipPath before the text element, otherwise we won’t have access to d.clipId

We also want to set an id for the clip-path. This is so that we can reference it in the text. For the href we can use the previously set nodeId. Lastly, we need to reference this clip-path in our text element.

Copied to clipboard!
node.append('text')
    .attr('clip-path', d => `url(#${d.clipId})`)
    ...
treemap.js
Adding the clip-path attribute to the text elements

If we did everything correctly, we should now have a pretty solid foundation.

Text clipping fixed

Formatting values

We can add one more thing to make the labels prettier. The values are currently in bytes. This doesn’t mean too much for the average person, so let’s convert them to more readable formats. Once more, I’ve turned to StackOverflow to find out how we can convert bytes to KB and MB. This is the function we want to use:

Copied to clipboard! Playground
const formatBytes = (bytes, decimals = 2) => {
    if (bytes === 0) return '0 Bytes';

    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
treemap.js

To format the values, all we have to do is wrap d.value into this function, where we add the texts.

Copied to clipboard! Playground
node.append('text')
        .attr('clip-path', d => `url(#${d.clipId})`)
        .selectAll('tspan')
         // Wrap d.value into the formatBytes function
        .data(d => [d.data.name, formatBytes(d.value)])
        ...
treemap.js

And now this looks way better.

values formatted to readable formats

Adding titles

To improve accessibility, let’s also add titles to the rectangles. We preferably want to see the whole path, like in the image below.

showing file paths with d3.js

To do this, append the following to treemap.js:

Copied to clipboard! Playground
node.append('title')
    .text(d => {
        const path = getPath(d, '/');
        const icon = path.includes('.') ? '📋' : '📂️';

        return `${icon} ${getPath(d, '/')}\n${formatBytes(d.value)}`;
    });
treemap.js

Based on the return value of getPath— which we haven’t defined yet — we want to show a file or folder icon. This is followed by the full path of the file. On a new line, we can also show the size. The getPath function can be done in a single line.

Copied to clipboard!
const getPath = (element, separator) => element.ancestors().reverse().map(elem => elem.data.name).join(separator)
treemap.js

It expects an element and a separator. With element.ancestors, we can get every parent. To start with the root first, we also need to call reverse. As we are only interested in their name, we can call a map and join the strings together with the passed separator.

Adding shadows

Lastly, to make the depth of each level more prominent, let’s add some shadows. For this, we will need to create a new filter on the SVG. At the beginning of the render function, add the following:

Copied to clipboard! Playground
// Create shadow
svg.append('filter')
   .attr('id', 'shadow')
   .append('feDropShadow')
   .attr('flood-opacity', 0.5)
   .attr('dx', 0)
   .attr('dy', 0)
   .attr('stdDeviation', 2);
treemap.js

On its own, this won’t do much, but now we have a filter element with an id of shadow that we can reference for our nodes.

using filters in SVG

To use it, all we need to do is add a filter attribute for the group elements.

Copied to clipboard! Playground
const node = svg.selectAll('g')
                .data(d3.nest().key(d => d.height).entries(root.descendants()))
                .join('g')
                // Adding filters for each <g>
                .attr('filter', 'url(#shadow)')
                ...
treemap.js

Now we should have some nice drop shadows showing.

shadows applied for each group

Making The Map Interactive

As a very last touch, let’s make the whole thing interactive. First, let’s start by creating a theme switcher so we can choose from different color schemes. Then we can also implement a zooming functionality so the treemap will be navigable.

Theme switcher

For this, we’re going to need a dropdown menu. Add the following to your index.html file.

Copied to clipboard! Playground
<div class="options">
  <select>
      <option value="interpolateCool" selected="selected">interpolateCool</option>
      <option value="interpolateCividis">interpolateCividis</option>
      <option value="interpolateCubehelixDefault">interpolateCubehelixDefault</option>
      <option value="interpolateInferno">interpolateInferno</option>
      <option value="interpolateMagma">interpolateMagma</option>
      <option value="interpolatePlasma">interpolatePlasma</option>
      <option value="interpolateRainbow">interpolateRainbow</option>
      <option value="interpolateSinebow">interpolateSinebow</option>
      <option value="interpolateSpectral">interpolateSpectral</option>
      <option value="interpolateViridis">interpolateViridis</option>
      <option value="interpolateWarm">interpolateWarm</option>
  </select>
</div>
index.html

We can also add some absolute positioning in CSS to make it always show in the top right corner. To make it work, we need to add an on-change listener for the select.

Copied to clipboard! Playground
d3.select('select').on('change', function () {
    color = d3.scaleSequential([8, 0], d3[d3.select(this).property('value')]);

    node.select('rect').attr('fill', d => color(d.height));
});
treemap.js

In the callback function, we simply need to redefine the color variable, that we’ve added as a configuration in the beginning. Then we get the value from the select and pass it to scaleSequential. For example, if we select the fourth option, we get d3['interpolateInferno'] back, which returns an interpolator.

Then we simply have to reassign the fill attribute to the rectangles with the new color.

undefined

Adding Zooming Functionality

Now there are some nodes that are so small we don’t see any sub-folders in it. Just like the “forks” folder in the GIF above. Ideally, we want to zoom into them. So let’s add that now.

For this to work, we will need to re-render the treemap with a new root. In this example, the root will be the “forks” folder.

Copied to clipboard!
node.filter(d => d.children && d !== root)
    .attr('cursor', 'pointer')
    .on('click', d => zoom(d.path, data));
treemap.js

Start with a filter. We only want to add a click event to those that have children. We also don’t want to have a click listener on the root. To suggest to the user that this element can be interacted with, let’s also change the mouse to a pointer.

This will call a zoom function with a path and the JSON data. Since we don’t have a path property on the datum, let’s add it as well. Inside the title where we appended the text, add this new line:

Copied to clipboard! Playground
node.append('title')
    .text(d => {
        const path = getPath(d, '/');
        const icon = path.includes('.') ? '📋' : '📂️';
 
        d.path = getPath(d, '.');

        return `${icon} ${getPath(d, '/')}\n${formatBytes(d.value)}`;
    });
treemap.js

Creating the zoom function

For the zoom function, we want to call render with a new root.

Copied to clipboard! Playground
const zoom = (path, root) => {
    const name = path.split('.').splice(-1)[0];
    const treemapData = '???';
    
    render({
        name,
        children: treemapData
    });
}
treemap.js

The name of the root will be the last element inside the path. Staying at the example, in case of react-src.forks, it will be “forks”. To get the treemapData we can use a reducer to loop through the JSON and get the portion we are interested in.

Copied to clipboard! Playground
const normalizedPath = path.split('.')
                           .slice(1)
                           .join('.');

const treemapData = normalizedPath.split('.').reduce((obj, path) => {
    let returnObject;

    obj.forEach(node => {
        if (node.name === path) {
            returnObject = node.children;
        }
    });

    return returnObject;
}, root.children);
treemap.js

Since we always start from the root, we can get rid of the first part of the path. For react-src.forks this leaves us with only forks. This will be the value of normalizedPath. Inside the reducer, we then use a forEach to loop through the children, and if the name matches, we return that object. If we try this out, we will get an error however.

getting errors in Chrome DevTools

This is because, at the very beginning of the render function, we need to get rid of everything inside the SVG. Otherwise, we would try to add the same things for an already populated SVG.

Copied to clipboard!
d3.select('.treemap').selectAll('*').remove();
treemap.js
zooming in
re-rendering the treemap with a new root

But we don’t have a way to zoom out. To fix this, add a button to your index file and a new click listener just after we render the initial data.

Copied to clipboard! Playground
(async () => {
    data = await d3.json(filePath).then(data => data);

    render(data);
    
    d3.select('button').on('click', () => render(data));
})();
treemap.js

This ensures we are able to reset the view and go back to the original root. 

And you have just created your very first treemap! đźŽ‰
Looking to improve your skills? Check out our interactive course to master JavaScript from start to finish.
Master JavaScriptinfo Remove ads

Wrapping Things Up

I hope the amount of steps needed to finish this project wasn’t too overwhelming. To wrap everything up, I would like to provide you some documentation that can help you dig deeper into the world of D3.js. While the official D3 documentation is quite large, it can be hard to see how things fit together. This is why I recommend to rather look through the examples on Observable.

Also, the whole project is hosted on GitHub, so you can clone it if you want to experiment with the finished piece. Thank you for taking the time to read this article. Happy coding!

  • twitter
  • facebook
JavaScript
Did you find this page helpful?
đź“š More Webtips
Frontend Course Dashboard
Master the Art of Frontend
  • check Access 100+ interactive lessons
  • check Unlimited access to hundreds of tutorials
  • check Prepare for technical interviews
Become a Pro

Courses

Recommended

This site uses cookies We use cookies to understand visitors and create a better experience for you. By clicking on "Accept", you accept its use. To find out more, please see our privacy policy.