DEV Community

Cover image for Tasty Recipes for React & D3. The Ranking Bar.
Vyacheslav Chub for Valor Labs

Posted on

Tasty Recipes for React & D3. The Ranking Bar.

Intro

From time to time, my colleagues and I encounter situations where we need to implement custom visual solutions during front-end projects - this includes various tasks such as charts, diagrams, and interactive schemes. In one project, I only had to deal with charts and was able to resolve the issue quickly and efficiently by using a free chart library. However, in the next project, I was given the choice of which approach to take and which library to use. After doing some research and seeking advice from authoritative sources, I determined that the D3 library was the best solution for three main reasons.

  1. Flexibility. Despite many popular existing patterns, D3 allows us to provide any custom SVG-based graphic.
  2. Popularity. This library is one of the most commonly used. It has a big community and a lot of resources for learning.
  3. Universality. There are many existing patterns for different charts and visualizations based on data. Also, it supports various data formats like JSON and CSV.

Despite D3's popularity, I encountered some difficulties during my research that prompted me to write this article. I want to help my colleagues navigate similar situations.

It's worth noting that all the projects I mentioned earlier are based on React, so all the code examples I will provide will also be connected to React. I don't want to focus on unrelated topics and aim to provide minimalistic solutions, which is why I will use JavaScript instead of TypeScript.

The Ranking Bar Task.

As mentioned before, my goal is to provide fast and easy-to-use solutions, even if they are small and not immediately noticeable. That's why I have created a series of simple examples that demonstrate how to create a simple React Ranking Bar component using D3.

Now, let's focus on a couple of key points.

What we have.

We have the following kind of data—fruits as keys with corresponding values.

const data = {
  Apple: 100,
  Apricot: 200,
  Araza: 5,
  Avocado: 1,
  Banana: 150,
  Bilberry: 700,
  // ...
  Feijoa: 11,
  Fig: 0,
};
Enter fullscreen mode Exit fullscreen mode

What we expect.

We are expecting a simple visualization with the following features:

  1. All bars (fruits) should be ordered from the biggest values to the smallest.
  2. All bars should contain the related fruit name if possible. If the fruit name width is smaller than the bar width, then the name should be cropped and "..." added, or hidden.
  3. The component should be responsive. If the user changes the screen size, the component should be redrawn.

alt-text

Step #1: Getting Started

I'd like to skip the project setup and focus directly on the code, especially since I will provide all the working examples below. In my first step, I will provide an empty SVG-based component.

Our App component should look like this...

import React from "react";
import StackedRank from "./StackedRank";
import "./style.css";

export default function App() {
  return (
    <div id="root-container">
      <StackedRank />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pay attention to the attribute id="root-container". This is a chart container that we will use inside the StackedRank component.

Let's look at StackedRank component.

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

export default function StackedRank() {
  const svgRef = useRef();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    const getMaxWidth = () =>
      parseInt(
        d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
          100,
        10
      );
    setWidth(getMaxWidth());
    setHeight(50);
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    svg
      .append("rect")
      .attr("x", 0)
      .attr("width", width)
      .attr("y", 0)
      .attr("height", height)
      .attr("fill", "grey");
  };

  useEffect(() => {
    recalculateDimension();
  }, []);

  useEffect(() => {
    if (width && height) {
      renderSvg();
    }
  }, [width, height]);

  if (!width || !height) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}
Enter fullscreen mode Exit fullscreen mode

You can find the full solution on StackBlitz, link 1.

Let me explain some important points about the code above. First of all, we need to handle the component container and shapes. The chart width and height are undefined by default.

const [width, setWidth] = useState();
const [height, setHeight] = useState();
Enter fullscreen mode Exit fullscreen mode

This is why we need to set them with the following code:

useEffect(() => {
  recalculateDimension();
}, []);
Enter fullscreen mode Exit fullscreen mode
const recalculateDimension = () => {
  const getMaxWidth = () =>
    parseInt(
      d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
        100,
      10
    );
  setWidth(getMaxWidth());
  setHeight(50);
};
Enter fullscreen mode Exit fullscreen mode

In the code above, we calculate the component width that fits the available screen width using the parent container root-container. Height should be fixed (50px).

Also, pay extra attention to the following code in particular:

if (!width || !height) {
  return <></>;
}

return <svg ref={svgRef} width={width} height={height} />;
Enter fullscreen mode Exit fullscreen mode

First of all, we display our graphical content in SVG format. Secondly, we shouldn't show it if its shapes are undefined.

useEffect(() => {
  if (width && height) {
    renderSvg();
  }
}, [width, height]);
Enter fullscreen mode Exit fullscreen mode

Let's deal with the graphical content when the component shapes are defined.

The following code

const renderSvg = () => {
  const svg = d3.select(svgRef.current);

  svg
    .append("rect")
    .attr("x", 0)
    .attr("width", width)
    .attr("y", 0)
    .attr("height", height)
    .attr("fill", "grey");
};
Enter fullscreen mode Exit fullscreen mode

just draws a grey rectangle according to the component shapes.

That's all for Step #1.

Step #2: The main functionality of the react component

The main goal of this step is to make StackedRank component as a Stacked Rank chart, sorry for tautology. So, we need to draw the below

alt-text

instead of just a gray rectangle.

The related code changes are in Stackblitz, link 2.

First thing we need to do is to define data in the App component and pass it to the chart component.

const data = {
  Apple: 100,
  Apricot: 200,
  Araza: 5,
  Avocado: 1,
  Banana: 150,
  // ...
  Durian: 20,
  Elderberry: 35,
  Feijoa: 11,
  Fig: 0,
};

export default function App() {
  return (
    <div id="root-container">
      <StackedRank data={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Traditionally, I want to provide the full component code and comment on it after.

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

function getNormalizedData(data, width) {
  const tmpData = [];
  let total = 0;
  for (const key of Object.keys(data)) {
    if (data[key] > 0) {
      tmpData.push({ fruit: key, value: data[key] });
      total += data[key];
    }
  }
  tmpData.sort((a, b) => b.value - a.value);
  let x = 0;
  for (const record of tmpData) {
    const percent = (record.value / total) * 100;
    const barwidth = (width * percent) / 100;
    record.x = x;
    record.width = barwidth;
    x += barwidth;
  }
  return tmpData;
}

export default function StackedRank({ data }) {
  const svgRef = useRef();
  const [normalizedData, setNormalizedData] = useState();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    const getMaxWidth = () =>
      parseInt(
        d3.select("#root-container")?.node()?.getBoundingClientRect()?.width ??
          100,
        10
      );
    setWidth(getMaxWidth());
    setHeight(50);
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    const color = d3
      .scaleOrdinal()
      .domain(Object.keys(normalizedData))
      .range(d3.schemeTableau10);

    svg
      .selectAll()
      .data(normalizedData)
      .enter()
      .append("g")
      .append("rect")
      .attr("x", (d) => d.x)
      .attr("width", (d) => d.width - 1)
      .attr("y", 0)
      .attr("height", 50)
      .attr("fill", (_, i) => color(i));

    svg
      .selectAll("text")
      .data(normalizedData)
      .join("text")
      .text((d) => d.fruit)
      .attr("x", (d) => d.x + 5)
      .attr("y", (d) => 30)
      .attr("width", (d) => d.width - 1)
      .attr("fill", "white");
  };

  useEffect(() => {
    recalculateDimension();
  }, []);

  useEffect(() => {
    if (normalizedData) {
      renderSvg();
    }
  }, [normalizedData]);

  useEffect(() => {
    if (width && height && data) {
      setNormalizedData(getNormalizedData(data, width));
    }
  }, [data, width, height]);

  if (!width || !height || !normalizedData) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}
Enter fullscreen mode Exit fullscreen mode

The most tedious and time-consuming part of this step is the data transformation, which is contained in the 'getNormalizedData' function. I don't want to explain it in detail. The main purposes of this function are:

  1. Provide a more convenient data representation - an array of objects instead of one object.
  2. Contain UI-consumed data: the X and width of the bar.

Pay attention to the following lines:

const percent = (record.value / total) * 100;
const barwidth = (width * percent) / 100;
Enter fullscreen mode Exit fullscreen mode

The width of each bar should be calculated depending on the Fruit Total value and the component width.

Also, I recommend debugging or "console.log'ing" this code using my example: Stackblitz, link 2 - StackedRanked.jsx

The code of the component for Step #2 has a bit different initialization logic.

useEffect(() => {
  recalculateDimension();
}, []);

useEffect(() => {
  if (normalizedData) {
    renderSvg();
  }
}, [normalizedData]);

useEffect(() => {
  if (width && height && data) {
    setNormalizedData(getNormalizedData(data, width));
  }
}, [data, width, height]);
Enter fullscreen mode Exit fullscreen mode

Let me translate the React code above into human-readable form. Firstly, we calculate the component dimensions. Once we have them, we normalize the data because we now have enough information. Finally, with the normalized data, we render our SVG using D3. And now, we are ready to focus on rendering.

As you can see below, our rendering consists of four parts. Please read my comments in the code. Don't worry if you are not very familiar with D3 specifically. While the aim of this article is not to teach D3, I would like to provide you with some important D3-specific implementation.

const renderSvg = () => {
  // "Associate" `svg` varable with svgRef:
  // return <svg ref={svgRef} width={width} height={height} />;
  const svg = d3.select(svgRef.current);

  // Get the list of colors using D3-way
  const color = d3
    .scaleOrdinal()
    // Apple, Apricot, Araza, Avocado, etc
    .domain(Object.keys(normalizedData))
    .range(d3.schemeTableau10);

  // Draw all expected bars according to `normalizedData`
  svg
    .selectAll()
    // connect our data here
    .data(normalizedData)
    .enter()
    // now we are ready for drawing
    .append("g")
    // draw the rect
    .append("rect")
    // `d` variable represents an item of normalizedData
    // that we connected before
    // please, also look at `getNormalizedData`
    // we need to take x and width from there
    .attr("x", (d) => d.x)
    .attr("width", (d) => d.width - 1)
    .attr("y", 0)
    .attr("height", 50)
    // Color for the bar depends only on its order `i`
    .attr("fill", (_, i) => color(i));

  // Put texts over all related bars according to `normalizedData`
  svg
    // we need to work with text
    .selectAll("text")
    .data(normalizedData)
    // we need to work with text
    .join("text")
    // because `d` variable represents an item of normalizedData
    // we can take the related fruit name from there
    .text((d) => d.fruit)
    // set x, y, and color
    .attr("x", (d) => d.x + 5)
    .attr("y", (d) => 30)
    .attr("fill", "white");
    // also, you can set more attributes like Font Family, etc...
};
Enter fullscreen mode Exit fullscreen mode

If the comments above are not enough for a complete understanding of the topic, I highly recommend reading additional D3 resources. Additionally, I think live examples from Stackblitz, CodePen, etc. would be helpful for understanding D3 principles.

And now, after a lengthy explanation, let's take a look at how the example works.

alt text

It looks predictable but a bit ugly. We need to deal with the overlapping text. Also, this component should be responsive. If the user changes the screen size the component should be redrawn.

Step #3: Responsiveness & Smart Fruits

As always, I want to provide the complete code first. Stackblitz, link 3

import React, { useEffect, useState, useRef } from 'react';
import * as d3 from 'd3';
import { dotme, useWindowSize } from './utils';

function getNormalizedData(data, width) {
    // let's skip it because
    // this implementation hasn't changed comparing
    // with the previous implementation
}

export default function StackedRank({ data }) {
  const svgRef = useRef();
  const [fullWidth, fullHeight] = useWindowSize();
  const [normalizedData, setNormalizedData] = useState();
  const [width, setWidth] = useState();
  const [height, setHeight] = useState();

  const recalculateDimension = () => {
    // let's skip it because
    // this implementation hasn't changed comparing
    // with the previous implementation
  };

  const renderSvg = () => {
    const svg = d3.select(svgRef.current);

    svg.selectAll('*').remove();

    const color = d3
      .scaleOrdinal()
      .domain(Object.keys(normalizedData))
      .range(d3.schemeTableau10);

    svg
      .selectAll()
      .data(normalizedData)
      .enter()
      .append('g')
      .append('rect')
      .attr('x', (d) => d.x)
      .attr('width', (d) => d.width - 1)
      .attr('y', 0)
      .attr('height', 50)
      .attr('fill', (_, i) => color(i));

    svg
      .selectAll('text')
      .data(normalizedData)
      .join('text')
      .text((d) => d.fruit)
      .attr('x', (d) => d.x + 5)
      .attr('y', (d) => 30)
      .attr('width', (d) => d.width - 1)
      .attr('fill', 'white');

    svg.selectAll('text').call(dotme);
  };

  useEffect(() => {
    if (normalizedData) {
      renderSvg();
    }
  }, [normalizedData]);

  useEffect(() => {
    if (width && height) {
      setNormalizedData(getNormalizedData(data, width));
    }
  }, [width, height]);

  useEffect(() => {
    if (data) {
      recalculateDimension();
    }
  }, [data, fullWidth, fullHeight]);

  if (!width || !height || !normalizedData) {
    return <></>;
  }

  return <svg ref={svgRef} width={width} height={height} />;
}
Enter fullscreen mode Exit fullscreen mode

Responsiveness

Despite the fixed component height (50px), we need to recalculate its width according to the available screen width for each window resize. That's why I added a new hook. The hook is useWindowSize. You can find the related source here Stackblitz, link 3 - StackedRank.jsx

Let me highlight the essential points regarding responsibility.

  const [fullWidth, fullHeight] = useWindowSize();
Enter fullscreen mode Exit fullscreen mode

Get available screen dimensions fullWidth, fullHeight.

  useEffect(() => {
    if (data) {
      recalculateDimension();
    }
  }, [data, fullWidth, fullHeight]);
Enter fullscreen mode Exit fullscreen mode

Recalculate component size if the screen has changed.

Smart Fruits

Before we discuss smart texts, I recommend taking a look at the following solution: https://codepen.io/nixik/pen/VEZwYd. This is important because I used the dotme code as a prototype. The issue with the original dotme is that it limits a string by word criteria (see the original solution). However, in this example, the fruit names should be limited by character criteria. Let me explain my version of dotme.

export function dotme(texts) {
  texts.each(function () {
    const text = d3.select(this);
    // get an array of characters
    const chars = text.text().split('');

    // make a temporary minimal text contains one character (space) with ...
    let ellipsis = text.text(' ').append('tspan').text('...');
    // calculate temporary minimal text width
    const minLimitedTextWidth = ellipsis.node().getComputedTextLength();
    // make "ellipsis" text object
    ellipsis = text.text('').append('tspan').text('...');

    // calculate the total text width: text + ellipsis
    // one important note here: text.attr('width') has taken from the
    // following code fragment of "":
    /*
       svg
         .selectAll('text')
         .data(normalizedData)
         // ...
         .attr('width', (d) => d.width - 1)
    */
    // that's why we must define width attribute for the text if we want to get
    // behavior of the functionality
    const width =
      parseFloat(text.attr('width')) - ellipsis.node().getComputedTextLength();
    // total number of characters
    const numChars = chars.length;
    // make unlimited version of the string
    const tspan = text.insert('tspan', ':first-child').text(chars.join(''));

    // the following case covers the situation
    // when we shouldn't display the string at all event with ellipsis
    if (width <= minLimitedTextWidth) {
      tspan.text('');
      ellipsis.remove();
      return;
    }

    // make the limited string
    while (tspan.node().getComputedTextLength() > width && chars.length) {
      chars.pop();
      tspan.text(chars.join(''));
    }

    // if all characters are displayed we don't need to display ellipsis
    if (chars.length === numChars) {
      ellipsis.remove();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

I hope, that's it for dotme ;)

Usage the function above is quite simple. We just need to call the following:

svg.selectAll('text').call(dotme);
Enter fullscreen mode Exit fullscreen mode

Despite repeating this point, I need to highlight it again due to its importance. We must define the width attribute for the text.

    svg
      .selectAll('text')
      .data(normalizedData)
      .join('text')
       // ...
      .attr('width', (d) => d.width - 1)
      // ...
Enter fullscreen mode Exit fullscreen mode

Otherwise dotme provides wrong behavior. See the following code:

    const width =
      parseFloat(text.attr('width')) - ellipsis.node().getComputedTextLength();
Enter fullscreen mode Exit fullscreen mode

Now it's time to run the app. But before, I want to highlight one crucial point regarding D3 usage. Let's look at the following line of code:

svg.selectAll('*').remove();
Enter fullscreen mode Exit fullscreen mode

The code above clears all graphical stuff on the SVG. We should do it because we need to redraw the component, which means that previous SVG objects should be rejected. You can remove this line, rerun the app and change the window size. I recommend trying it if you want to feel how D3 works.

Here is a video of the final solution in action!

Watch the video

Thank you for your attention, and happy coding!

Need Help?

Founded in 2013, Valor Software is a software development and consulting company that specializes in helping businesses modernize their web platforms and best leverage technology.

By working with Valor Software, businesses can take advantage of the latest technologies and techniques to build modern web applications that are more adaptable to changing needs and demands while also ensuring best practices through unparalleled OSS access via our team and community partners.

Reach out today if you have any questions sales@valor-software.com

Top comments (1)

Collapse
 
szabgab profile image
Gabor Szabo

Welcome to DEV!