No Time Dad

A blog about web development written by a busy dad

Simple Bar Chart with D3 and React

The quest to learn more D3 continues. This time I’ll be focusing on creating a simple bar chart. Which is a funny thing because I know I could create this exact bar chart without using D3 at all, and instead just using React or even vanilla JavaScript. But that isn’t really the point for me. The point is to build a foundation of knowledge in D3 that can built on.

One of my goalsig with D3 and React is to keep the two libraries as separate as possible. What I’m trying to avoid is having a lot of the D3 logic mixing in with the component logic. I want to use React lifecycle methods to tell D3 what to do and when to do it. I’m basically creating a small wrapper library in D3 for the visualizations, then using React to render it.

My data for this example is stored in state but it won’t be changing, so it could just as easily be stored as a static variable. My reason for storing it in state is that it might later change and I want to keep the mental model of React’s state management concept fresh in my mind.

The visualizations I’ve created so far have been pretty basic. Three circles, to be exact. But, basic visualizations help me to have a general idea of what the html will look like and I can use that picture to work towards building the html dynamically using D3. Most of what I’ll be doing today is a variation on this D3 barchart tutorial but, of course, I’ll be using React to render it. Below is roughly the html I’ll be working towards (my data is slightly different).

<svg width="420" height="120" text-anchor="end">
  <g transform="translate(0,0)">
    <rect fill="steelblue" width="40" height="19"></rect>
    <text fill="white" x="37" y="9.5" dy=".35em">4</text>
  </g>
  <g transform="translate(0,20)">
    <rect fill="steelblue" width="80" height="19"></rect>
    <text fill="white" x="77" y="9.5" dy=".35em">8</text>
  </g>
  ...
</svg>

This example will introduce a new concept and library called d3-scale. I prefer to only add the modules that I need from D3 instead of the entire library. Scales represent points along an axis as a way to “fit” the data. In a file I’ll name d3-bar-chart.js I’ll add a few helper methods to generate the scales based on data passed to them from the component.

// d3-bar-chart.js
import { scaleLinear, scaleBand } from 'd3-scale';
import { max, range } from 'd3-array';
import { select } from 'd3-selection';

const generateXScale = (data, width) => (
  scaleLinear()
    // Possible input values
    .domain([0, max(data)])
    // Possible output values
    .range([0, width])
);

const generateYScale = (data) => (
  scaleBand()
    .domain(range(data.length))
    .range([0, 20 * data.length])
);

Once I have my scales, I can start building the bar chart svg itself. I’ll be using React’s useRef hook to create a reference element to pass to D3. Since I’m starting with an empty div container, I’ll need to first add an svg element. Once I have that, I can start adding the bars themselves.

// d3-bar-chart.js
...
export const InitBarChart = (elementRef, data, width) => {
  const yScale = generateYScale(data);
  select(elementRef.current)
    // Add the svg element
    .append('svg')
    // Add attributes to the svg element
    .attr('width', width)
    .attr('height', yScale.range()[1])
    .attr('text-anchor', 'end');
}
import React, { useRef, useState, useEffect } from 'react';
import { InitBarChart } from './d3-bar-chart.js';

export const BarChartComponent = () => {
  const [data] = useState([45, 65, 75, 95, 109, 121]);
  // Init the element reference to pass to D3
  const containerRef = useRef(null);

  // Call the D3 helper function in React's lifecycle methods
  useEffect(() => {
    InitBarChart(containerRef, data, 420);
  }, [data]);

  return (
    <div ref={containerRef}></div>
  );
}

Now I should be able to take a look at the rendered html and hopefully see my div with a child svg containing the attributes defined in InitBarChar.

<div>
  <svg width="420" height="120" text-anchor="end"></svg>
</div>

With the svg container element created, I can start adding the g elements that will be the container elements for the bars themselves. The g element is mostly used for grouping purposes and it allows me to define top level styles.

// d3-bar-chart.js
...
export const InitBarChart = (elementRef, data, width) => {
  const yScale = generateYScale(data);
  select(elementRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', yScale.range()[1])
    .attr('text-anchor', 'end');
  
  // Select all existing g elements
  const bar = svg.selectAll('g')
    // Bind to the data
    .data(data)
    // Enter and add new g elements based on the data
    .join('g')
    // Position each g element under one another
    .attr('transform', (d, i) => `translate(0,${yScale(i)})`);
}

The svg element from the previous section should now include six g elements, one for each item in the data array.

<div>
  <svg width="420" height="120" text-anchor="end">
    <g transform="translate(0,0)"></g>
    <g transform="translate(0,20)"></g>
    <g transform="translate(0,40)"></g>
    <g transform="translate(0,60)"></g>
    <g transform="translate(0,80)"></g>
    <g transform="translate(0,100)"></g>
  </svg>
</div>

The next thing I need to do now is add the bars for the bar graph. The bars will actually be rect elements, and I’ll use the yScale and xScale functions to determine the width and height. The original bar chart example removes a small amount of the height to differentiate the stacked bars, so I’ll do that too.

// d3-bar-chart.js
...
export const InitBarChart = (elementRef, data, width) => {
  const yScale = generateYScale(data);
  const xScale = generateXScale(data, width);
  const svg = select(elementRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', yScale.range()[1])
    .attr('text-anchor', 'end');

  const bar = svg.selectAll("g")
    .data(data)
    .join('g')
    .attr('transform', (d, i) => `translate(0,${yScale(i)})`);

  // Use the bar instance to add the rect elements
  bar.append('rect')
    // Set the bar color
    .attr('fill', 'royalblue')
    .attr('width', xScale)
    // Remove a small amount to "add space" to the bars
    .attr('height', yScale.bandwidth() - 1);

At this point I have something that can be seen on the page. The rendered html is shown below.

<div>
  <svg width="420" height="120" text-anchor="end">
    <g transform="translate(0,0)">
      <rect fill="royalblue" width="156.198347107438" height="19"></rect>
    </g>
    <g transform="translate(0,20)">
      <rect fill="royalblue" width="225.6198347107438" height="19"></rect>
    </g>
    <g transform="translate(0,40)">
      <rect fill="royalblue" width="260.3305785123967" height="19"></rect>
    </g>
    <g transform="translate(0,60)">
      <rect fill="royalblue" width="329.7520661157025" height="19"></rect>
    </g>
    <g transform="translate(0,80)">
      <rect fill="royalblue" width="378.3471074380165" height="19"></rect>
    </g>
    <g transform="translate(0,100)">
      <rect fill="royalblue" width="420" height="19"></rect>
    </g>
  </svg>
</div>

The last thing I need to do is add the text labels. The text is interesting to me because I can’t place it inside the rect element like I’d do with other html elements. It’s instead a sibling to the rect element and positional values are modified to place it inside the bar. In this bar chart, each text element is positioned at the end of the bar, which means the y attribute value in the text element is very similar to the height attribute value in the rect element.

...
export const InitBarChart = (elementRef, data, width) => {
  const yScale = generateYScale(data);
  const xScale = generateXScale(data, width);
  const svg = select(elementRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', yScale.range()[1])
    .attr('text-anchor', 'end');

  const bar = svg.selectAll('g')
    .data(data)
    .join('g')
    .attr('transform', (d, i) => `translate(0,${yScale(i)})`);

  bar.append('rect')
    .attr('fill', 'royalblue')
    .attr('width', xScale)
    .attr('height', yScale.bandwidth() - 1);

  // Use the bar reference to add text elements
  bar.append('text')
    // The text color should be white
    .attr('fill', 'white')
    // Determine the position on the x-axis with 3px of "padding"
    .attr('x', d => xScale(d) - 1)
    // Determine the position on the y-axis
    .attr('y', (yScale.bandwidth() - 1) / 2)
    // Lower the text position horizonally slightly
    .attr('dy', '0.35em')
    // Insert the text values from the data array
    .text(d => d);
}

The final rendered html for this bar chart now looks as follows:

<div>
  <svg width="420" height="120" text-anchor="end">
    <g transform="translate(0,0)">
      <rect fill="royalblue" width="156.198347107438" height="19"></rect>
      <text fill="white" x="153.198347107438" y="9.5" dy="0.35em">45</text>
    </g>
    <g transform="translate(0,20)">
      <rect fill="royalblue" width="225.6198347107438" height="19"></rect>
      <text fill="white" x="222.6198347107438" y="9.5" dy="0.35em">65</text>
    </g>
    <g transform="translate(0,40)">
      <rect fill="royalblue" width="260.3305785123967" height="19"></rect>
      <text fill="white" x="257.3305785123967" y="9.5" dy="0.35em">75</text>
    </g>
    <g transform="translate(0,60)">
      <rect fill="royalblue" width="329.7520661157025" height="19"></rect>
      <text fill="white" x="326.7520661157025" y="9.5" dy="0.35em">95</text>
    </g>
    <g transform="translate(0,80)">
      <rect fill="royalblue" width="378.3471074380165" height="19"></rect>
      <text fill="white" x="375.3471074380165" y="9.5" dy="0.35em">109</text>
    </g>
    <g transform="translate(0,100)">
      <rect fill="royalblue" width="420" height="19"></rect>
      <text fill="white" x="417" y="9.5" dy="0.35em">121</text>
    </g>
  </svg>
</div>

Conclusion & final code

D3 has been a wild ride for me so far. I have only made two very small and relatively easy visualizations, but it’s become a whole new way of thinking about elements. I do like working with D3 and React together so far, but I haven’t made anything truly complex just yet. Again, my main goal is to create nice visualizations without mixing D3 and React code. So far that has worked out well. I’m not sure if it will stay that way.

Final code
// d3-bar-chart.js
import { scaleLinear, scaleBand } from 'd3-scale';
import { max, range } from 'd3-array';
import { select } from 'd3-selection';

const generateXScale = (data, width) => (
  scaleLinear()
    .domain([0, max(data)])
    .range([0, width])
);

const generateYScale = (data) => (
  scaleBand()
    .domain(range(data.length))
    .range([0, 20 * data.length])
);

export const InitBarChart = (elementRef, data, width) => {
  const yScale = generateYScale(data);
  const xScale = generateXScale(data, width);
  const svg = select(elementRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', yScale.range()[1])
    .attr('text-anchor', 'end');

  const bar = svg.selectAll('g')
    .data(data)
    .join('g')
    .attr('transform', (d, i) => `translate(0,${yScale(i)})`);

  bar.append('rect')
    .attr('fill', 'royalblue')
    .attr('width', xScale)
    .attr('height', yScale.bandwidth() - 1);

  bar.append('text')
    .attr('fill', 'white')
    .attr('x', d => xScale(d) - 3)
    .attr('y', (yScale.bandwidth() - 1) / 2)
    .attr('dy', '0.35em')
    .text(d => d);
}
import React, { useRef, useState, useEffect } from 'react';
import { InitBarChart } from './d3-bar-chart.js';

export const BarChartComponent = () => {
  const [data] = useState([45, 65, 75, 95, 109, 121]);
  const containerRef = useRef(null);

  useEffect(() => {
    InitBarChart(containerRef, data, 420);
  }, [data]);

  return (
    <div ref={containerRef}></div>
  );
}

Outline (delete later)