Build a real-time leaderboard with D3.js and React !

As web apps are more suited for real-time data processing, we need more and more data visualization solutions for real-time data, with a responsive layout.

In this article, I show you how to make this simple leaderboard, using D3.js and React.

D3.js is one of the standard library for front-end data visualization rendering. It a declarative system to build an underlying complex SVG.

To bring life to your leaderboard, I will show you how to make simple animations. They will make real-time more readable and interesting for the user.

Lastly, we will make sure to have a responsive leaderboard, which should adapt to any size of input data, while staying readable on a smaller screen.

💡 You already want to check out the end result ? Look at it here !

Are you ready ? Then let's get started !! 🤩

Setup the project and libraries

To follow along with this tutorial, you can either setup the article's project in a few commands, or you can adapt it directly in your project.

We are going to use 3 libraries : D3.js and React, of course, but also react-use-measure, a small librairy to easily measure React components. This will be useful to have a flexible and responsive SVG component.

Using the tutorial project

To follow along the article, you download the article's React project using these simple commands.

# Cloning the starter project
git clone -b setup [email protected]:rhidra/d3js-leaderboard.git
cd d3js-leaderboard

# Install dependancies
npm i

For your own project

Install D3.js.

npm i d3

Install a small React library to measure components. This will be useful when we try to make our leaderboard responsive.

npm i react-use-measure

Overview of the initial setup

If you look in the App.jsx file

<div className="app">
  <div className="leaderboard-container">
    <Leaderboard
      data={data}
    />
  </div>

  <div className="button">
    <button onClick={() => refreshData()}>Refresh Data</button>
  </div>
</div>

We have two blocks, one with our future leaderboard, and one with a button. If you look at the rest of the file you can see that the button will update the data passed to the leaderboard in the data variable.

Basically, we give some data to the leaderboard. This data may come from the frontend, as it is now, but it may also come from a backend, using an asynchronous function.

The goal of the leaderboard is to update the data in real-time, without refreshing the page. So, in the Leaderboard component, we must consider possible changes to the data input.

Now let's take a look at the Leaderboard.jsx file.

import { useRef, useState, useEffect } from 'react';
import * as d3 from 'd3';

function Leaderboard({data}) {
  const d3Ref = useRef(null);

  useEffect(() => {
    /***
    Write D3.js code here !
    ***/
  }, [d3Ref, data]);

  return (
    <svg
      ref={d3Ref}
    />
  );
}

export default Leaderboard;

In the useEffect(), you will write all the D3.js code. The Leaderboard component is basically just made of a <svg> component. In the following sections, we are going to connect it to D3.js. Then, we will use the framework to draw shapes and text on the SVG canvas.

Finally, we can take a quick look at the data. It comes from the data.js file and is made of a unique ID, a label and a value.

const data = [
    ...
    { "id":15, "value":33, "label":"Indonesia" },
  { "id":16, "value":14, "label":"China" },
  { "id":21, "value":7, "label":"Germany" },
  { "id":22, "value":12, "label":"China" },
  { "id":23, "value":38, "label":"Argentina" },
  { "id":24, "value":58, "label":"China" },
    ...
];

Setting a fixed width and height

First of all, because we want a flexible and responsive design, we should not use the SVG viewBox parameter. Because of this, we must specify a fixed width and height for the SVG component.

Since we will know the height of one row of the leaderboard, we can easily compute the total height. We can also include some margin and padding, if we want to.

Because we want a vertical leaderboard, which should take all the horizontal space, the CSS width should be 100%. Unfortunately, we cannot write <svg width="100%"/>, we must use a value in pixel. A solution is to measure the child component from the parent. In App.jsx, you will measure the width of the Leaderboard child. Then, you can pass it its width as a parameter.

Here is the new Leaderboard.

function Leaderboard({data, width}) {
    // ...

    // Constant (in px)
    const rowHeight = 60;

    // Total height of the leaderboard
    const [height, setHeight] = useState(rowHeight * data.length ?? 0);

    useEffect(() => {
        // Update total height, to use the most up-to-date value 
        setHeight(rowHeight * data.length);
      const height = rowHeight * data.length;

        // ...
    }, [d3Ref, data, width]);


    return (
        <svg
          width={width}
          height={height}
          ref={d3Ref}
        />
      );
}

For App, there is no easy, one-line solution to easily measure the size of a component. So instead, we will use a React library, react-use-measure. It is quite popular and very easy to use.

This makes our App look like this.

import useMeasure from 'react-use-measure';

// ...

// Use React-use-measure to measure the Leaderboard component
const [ref, {width: leaderboardWidth}] = useMeasure({debounce: 100});

return (
    // ...
    <div className="leaderboard-container" ref={ref}>
      <Leaderboard
        data={data}
        width={leaderboardWidth}
      />
    </div>
    // ...
);

One last important thing : do not forget to set a constant max-width and width: 100% in the CSS, so that the leaderboard component does not extends its width indefinitely, and it looks good on smaller devices !

Let's draw some SVG !

Now that the boring stuff is done, time to have some fun 🥳 !

useEffect(() => {
    // ...

    // Select the root SVG tag
  const svg = d3.select(d3Ref.current);

    // Scales
    // Get the biggest value in the set,
    // to draw all other relative to the maximum value.
  const maxValue = d3.max(data.map(d => +d.value)) ?? 1;
  const x = d3.scaleLinear().domain([0, maxValue]).range([5, width]);
  const y = d3.scaleLinear().domain([0, data.length]).range([0, height]);

    // Join the data
    // We use the ID of a row to distinguish identical elements.
    const g = svg.selectAll('g').data(data, d => d.id);

First, we select the root SVG component, and we draw <g> elements, one for each data row. In SVG, a <g> element is just a group of other elements.

We also define a few scaling utility functions x and y, using the maximum value of the dataset.

On the last line, we are telling D3.js to use the ID of a row to look for identical rows. Note that our code will be executed every time we change the data or the screen size, so rows may already be drawn. Maybe the value will be the same, but the order may not, so we must move it. Therefore, using D3.js, we can easily decide what to do whether we are creating, updating or deleting a row.

To recap a bit, first we are going to define rows at creation, then how each one should be updated (the newly created, but also previously modified rows), and finally we will define a small animation before removing the row.

Create the rows

At the initialization, we will simply define the skeleton of the SVG, i.e. creating the tags with as much static information as possible. The g.enter() function isolate the rows that needs to be created.

// Initialization
const gEnter = g.enter()
  .append('g')
    .attr('transform', `translate(0, ${y(data.length) + 500})`);

First, we define the <g> element of our row, and we give it a transformation. This transform instructions moves the group vertically to y(data.length) + 500 . In other words, it moves the row beyond the bottom of the leaderboard, to not be in sight. This will allow us to make a small enter animation for when new rows are added.

// More constants !
const fontSize = '1.1rem';
const textColor = 'black';
const bgColor = '#d4d8df'; // Background bar color (grey)
const barColor = '#3d76c1'; // Main bar color (blue)
const barHeight = 10;
const marginText = 2; // Margin between the text and the bars

// Append background rect as child
gEnter
  .append('rect')
    .attr('class', 'bg')
    .attr('fill', bgColor)
    .attr('x', 0).attr('y', marginText)
    .attr('rx', 5).attr('ry', 5)
    .attr('height', barHeight);

// Append main rect as child
gEnter
  .append('rect')
    .attr('class', 'main')
    .attr('fill', barColor)
    .attr('x', 0).attr('y', marginText)
    .attr('rx', 5).attr('ry', 5) // Rectangle border radius
    .attr('height', barHeight);

// Append label text as child
gEnter
  .append('text')
    .attr('class', 'label')
    .attr('font-size', fontSize)
    .attr('fill', textColor)
    .attr('x', 0)
    .attr('y', -5)
    .text(d => d.label);

// Append value text as child
gEnter
  .append('text')
    .attr('class', 'value')
    .attr('text-anchor', 'end')
    .attr('fill', textColor)
    .attr('font-size', fontSize)
    .attr('y', -5);

Our row is made up of four elements :

  • The background bar in grey, which should always have the same shape.
  • The main bar, above the background bar, which has a variable width and a color.
  • The label, with a constant value found in the row data.
  • The value text, with a variable value.

The lines are quite self-explanatory, we siply set color, size and position attribute to the four elements.

Update the rows

Now that we created each necessary row, we can take care of updating them, if they need to.

// Update each g row, when data changes
const gUpdate = g.merge(gEnter);
gUpdate
  .transition()
    .ease(d3.easePoly)
    .duration(500)
    .attr('transform', (d, i) => `translate(0, ${y(i) + 30})`);

Because we use a parent <g>, we can simply update its transform attribute to move the row to the right position. You can see that we display the rows in order, which is why we use the i index parameter instead of the value d.value.

You can also see that we use a transition animation. If you remove it, you will see an ugly snap of all the row at their positions.

// Update rect bg
gUpdate
  .select('rect.bg')
  .attr('width', x(maxValue));

// Update rect main
gUpdate
  .select('rect.main')
  .transition()
    .ease(d3.easePolyOut)
    .duration(1000)
    .attr('width', d => x(d.value));

// Update value text
gUpdate
  .select('text.value')
  .text(d => d.value)
  .attr('x', x(maxValue));

Here we update the rest of the elements. We set the correct width to the rectangles, also by using a transition animation. And we also update the text value. As you can see, since the label is constant, we do not need to update it. If you don't have a constant label for a same ID, you will probably need to update like here.

Remove the rows

Some rows won't be present in the dataset after an update, so we must remove them. To do that, we use the g.exit() function, which isolate rows which should be removed.

// Exit animation
g.exit()
    .attr('opacity', 1)
  .transition()
    .ease(d3.easeLinear)
    .duration(200)
    .attr('transform', (d, i) => `translate(-50, ${y(i)})`)
    .attr('opacity', 0)
  .remove();

To remove them, we simply move them 50 pixels left smoothly, and we reduce slowly their opacity to 0. You can tweak the .duration(200) if you are not happy with the exit animation duration.

And... That's it ?

Yup ! That's it ! 😁

You can try it with a smaller screen size, and change the data input. It probably does not look exactly as you want for your project, so you can add more properties and tweak the parameters in the D3.js code.

💡 Feel free to follow me on Twitter (@remyhidra), to get more articles and tutorial about Web development. I just started writing blog posts and I am trying to build a small audience from it, so there will be plenty of content coming soon ! 😄

Additionaly, tell me in the comments, what other kind of data visualization would you like to build ?

21