Simple bar chart with React and D3 πŸ“Š

Introduction

Recently I've started working on an expense tracking application for my personal use, and in order to visualize data better I've decided to add some bar charts to it.

I did some research and found a lot of helpful libraries e.g. recharts or react-vis, but I thought for my case it would be an overkill, also it seems like a great opportunity to learn something new, so I've decided to use D3.

What is D3?

D3 stands for Data-Driven Documents and as the docs states:

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

After getting familiar with it, I got really excited by how powerful this library is and how many various cases this can help you to solve. Just check out this gallery and tell me you're not impressed πŸ˜…

Before we start

First things first, let's install D3 and its type declarations.

yarn add d3
yarn add --dev @types/d3

Also, let's initialize some dummy data to fill our chart.

interface Data {
  label: string;
  value: number;
}

const DATA: Data[] = [
  { label: "Apples", value: 100 },
  { label: "Bananas", value: 200 },
  { label: "Oranges", value: 50 },
  { label: "Kiwis", value: 150 }
];

Now we're ready to jump to the next section, so buckle up!

Bar chart

Of course, we want our bar chart to be reusable through the whole application. To achieve that, let's declare it as a separate component that will take data prop and return SVG elements to visualize given data.

interface BarChartProps {
  data: Data[];
}

function BarChart({ data }: BarChartProps) {
  const margin = { top: 0, right: 0, bottom: 0, left: 0 };
  const width = 500 - margin.left - margin.right;
  const height = 300 - margin.top - margin.bottom;

  return (
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}
    >
      <g transform={`translate(${margin.left}, ${margin.top})`}></g>
    </svg>
  );
}

Great, we have our SVG with declared width and height attributes. So far, so good. But you might wonder what is this g element for. Basically, you can think of it as a container for elements that will come next - x-axis, y-axis and bars that will represent our data. By manipulating its transform attribute with margin values, we will create some space to properly render all the above-mentioned elements.

Bottom axis

Before we render our horizontal axis, we have to remember about scales. Scales are functions that are responsible for mapping data values to visual variables. I don't want to dive too deep into this topic, but if you're interested in further reading, you can check out scales documentation. We want our x-axis to display labels from data, so for this we will use scaleBand.

const scaleX = scaleBand()
  .domain(data.map(({ label }) => label))
  .range([0, width]);

Now we can create AxisBottom component which will render g element that will be used for drawing horizontal axis by calling axisBottom function on it.

interface AxisBottomProps {
  scale: ScaleBand<string>;
  transform: string;
}

function AxisBottom({ scale, transform }: AxisBottomProps) {
  const ref = useRef<SVGGElement>(null);

  useEffect(() => {
    if (ref.current) {
      select(ref.current).call(axisBottom(scale));
    }
  }, [scale]);

  return <g ref={ref} transform={transform} />;
}

After using AxisBottom in our BarChart component, the code will look like this πŸ‘‡

export function BarChart({ data }: BarChartProps) {
  const margin = { top: 0, right: 0, bottom: 20, left: 0 };
  const width = 500 - margin.left - margin.right;
  const height = 300 - margin.top - margin.bottom;

  const scaleX = scaleBand()
    .domain(data.map(({ label }) => label))
    .range([0, width]);

  return (
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}
    >
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
      </g>
    </svg>
  );
}

Notice how we added some bottom margin and set transform property of AxisBottom component to place it at the very bottom of our SVG container, since originally this would be rendered in the top-left corner.

Here's the result πŸ‘€

Left axis

The process of creating the vertical axis is very similar to what we did earlier, but this time we will use scaleLinear for scale. On our y-axis, we want to display ticks for values from our data. Ticks are just "steps" between minimum and a maximum value in a given domain. To do that, we will pass [0, max] for our domain and [height, 0] for range. Notice how height goes first - it's because we want ticks to have maximum value on top of our y-axis, not at the bottom.

const scaleY = scaleLinear()
  .domain([0, Math.max(...data.map(({ value }) => value))])
  .range([height, 0]);

Now we're ready to start working on AxisLeft component. It's almost the same what we did in AxisBottom but this time we will use axisLeft function to draw our vertical axis.

interface AxisLeftProps {
  scale: ScaleLinear<number, number, never>;
}

function AxisLeft({ scale }: AxisLeftProps) {
  const ref = useRef<SVGGElement>(null);

  useEffect(() => {
    if (ref.current) {
      select(ref.current).call(axisLeft(scale));
    }
  }, [scale]);

  return <g ref={ref} />;
}

After using it in BarChart the code will look like this πŸ‘‡

export function BarChart({ data }: BarChartProps) {
  const margin = { top: 10, right: 0, bottom: 20, left: 30 };
  const width = 500 - margin.left - margin.right;
  const height = 300 - margin.top - margin.bottom;

  const scaleX = scaleBand()
    .domain(data.map(({ label }) => label))
    .range([0, width]);
  const scaleY = scaleLinear()
    .domain([0, Math.max(...data.map(({ value }) => value))])
    .range([height, 0]);

  return (
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}
    >
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
        <AxisLeft scale={scaleY} />
      </g>
    </svg>
  );
}

This time we added some top and left margin to make it visible on our SVG, but since it's initially placed in top-left corner we didn't have to set transform property.

Here's how it looks πŸ‘€

Bars

Time for rendering bars, it's my favourite part. In this component we will use scaleX and scaleY we declared earlier to compute x, y, width and height attributes for each value from our data. For rendering bar we will use SVG rect element.

interface BarsProps {
  data: BarChartProps["data"];
  height: number;
  scaleX: AxisBottomProps["scale"];
  scaleY: AxisLeftProps["scale"];
}

function Bars({ data, height, scaleX, scaleY }: BarsProps) {
  return (
    <>
      {data.map(({ value, label }) => (
        <rect
          key={`bar-${label}`}
          x={scaleX(label)}
          y={scaleY(value)}
          width={scaleX.bandwidth()}
          height={height - scaleY(value)}
          fill="teal"
        />
      ))}
    </>
  );
}

After adding this to BarChart the final version of it will look like this πŸ‘‡

export function BarChart({ data }: BarChartProps) {
  const margin = { top: 10, right: 0, bottom: 20, left: 30 };
  const width = 500 - margin.left - margin.right;
  const height = 300 - margin.top - margin.bottom;

  const scaleX = scaleBand()
    .domain(data.map(({ label }) => label))
    .range([0, width])
    .padding(0.5);
  const scaleY = scaleLinear()
    .domain([0, Math.max(...data.map(({ value }) => value))])
    .range([height, 0]);

  return (
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}
    >
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
        <AxisLeft scale={scaleY} />
        <Bars data={data} height={height} scaleX={scaleX} scaleY={scaleY} />
      </g>
    </svg>
  );
}

The things that changed is of course adding Bars, but besides that we used padding method on our scaleX to create some space between rectangles and improve chart readability.

Demo

Feel free to fork this sandbox and play around with it. Maybe add separate colour for each bar, handle displaying negative values on it, add some more data, try to create horizontal bar chart etc.

Also, if you would like to learn more I encourage you to check out this tutorial by Amelia Wattenberger, it's great.

Thanks for reading! πŸ‘‹

28