No Time Dad

A blog about web development written by a busy dad

Simple Line Graph with React and D3

Simple wasn’t a good word to use to describe this line graph. It was such a headache creating this line graph. So much so, that I almost abandoned it altogether a few times. In the end, though, I did end up with a line graph and a better understanding of D3.

D3 is usually described as a “low level” graphing library. The reason for that is because it doesn’t make any assumptions about what you’re trying to build. It gives you the ability to create whatever you want. D3 has a module called d3-shape that will help you draw a line, but you won’t find anything in the library for creating a line graph.

It’s up to you to build the x-axis and y-axis, add the tick marks, create the scales, create the line, etc. Which makes D3 flexible and powerful, but also frustrating.

I think D3 is similar to Tailwind in this way. The Tailwindcss library itself isn’t going to give you a card element. Instead, it gives you all of the parts to create the border, create the spacing, define the size, etc.

Building the line graph

The goal here is to keep D3 and React as separate as possible. React should handle the rendering and D3 will tell it what to render. There isn’t much math involved in this graph, but D3 will handle any math that might be needed.

For the React component itself, I’ll be using the useRef, useState, and useEffect hooks. The useRef hook is used to create an element reference to pass to my D3 helper functions. The useState hook is used to store the data that the line graph is built on. Lastly, the useEffect hook, which is React’s lifecycle hook, is used to initialize the line graph.

The data stored in state will not be changing in this example, so it could in theory just be defined as a const but I’ll keep it in state just so the concept and usage of the useState hook stays fresh in my mind.

The struggles

Most of my struggles with this line graph came from the dates on the x-axis. It turns out that D3 wants actual Date objects instead of strings. This seems obvious in hindsight, but not so obvious when I’m banging my head against the wall trying to figure out why my x-axis isn’t showing any data. In addition to that, to show dates in “correct” timezone I had to use D3’s utcFormat instead of timeFormat. It keep showing the days as 1/31 instead of 1/01 otherwise.

The other issue I had was with line itself. It was showing up as a weird shape instead of a line. It took me forever to figure this out, but finally a stumbled on a stackoverflow question related to this problem, and it turns out that path elements have a black fill by default that makes it look like the first and last points are corrected, even though they aren’t.

The easiest way to get around this is to set the fill attribute to none. I can then change the color of the line by setting the stroke attribute value, and I can set the width of the line by setting the stroke-width attribute value.

The graph
The code
import React, { useRef, useState, useEffect } from 'react';
import { scaleLinear, scaleUtc } from 'd3-scale';
import { max, extent } from 'd3-array';
import { axisBottom, axisLeft } from 'd3-axis';
import { line } from 'd3-shape';
import { timeParse, utcFormat } from 'd3-time-format';
import { select } from 'd3-selection';

export const FinalGraph = () => {
  // Init the element reference
  const containerRef = useRef(null);
  // Random data
  const initialData = [
    { date: '2007-01-01', value: 100 },
    { date: '2007-02-01', value: 200 },
    { date: '2007-03-01', value: 300 },
    { date: '2007-04-01', value: 400 },
    { date: '2007-05-01', value: 500 },
    { date: '2007-06-01', value: 600 },
    { date: '2007-07-01', value: 500 },
    { date: '2007-08-01', value: 400 },
    { date: '2007-09-01', value: 300 },
    { date: '2007-10-01', value: 200 },
    { date: '2007-11-01', value: 100 },
    { date: '2007-12-01', value: 100 },
  ];

  const [data] = useState(initialData);
  const width = 600;
  const height = 300;

  // Init lifecycle hook
  useEffect(() => {
    const margin = { top: 20, right: 30, bottom: 30, left: 40 };
    InitGraph(data, margin, width, height, containerRef);
  }, [data]);

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

const InitGraph = (data, margin, width, height, elementRef) => {
  const parse = timeParse("%Y-%m-%d");
  // Ensure that the dates are Date objects instead of strings
  const formattedData = data.map(d => ({ date: parse(d.date), value: d.value }));

  // Define the x-axis scale
  const xScale = scaleUtc()
    .domain(extent(formattedData, d => d.date))
    .range([margin.left, width - margin.right]);

  // Define the y-axis scale
  const yScale = scaleLinear()
    .domain([0, max(formattedData, d => d.value)]).nice()
    .range([height - margin.bottom, margin.top])

  // Build the x-axis
  const xAxis = g => g
    .attr("transform", `translate(0,${height - margin.bottom})`)
    .call(
      axisBottom(xScale)
        // Format the date shown on the x-axis
        .tickFormat(utcFormat('%m/%d'))
    );

  // Build the y-axis 
  const yAxis = g => g
    .attr("transform", `translate(${margin.left},0)`)
    .call(axisLeft(yScale))

  // Define the line
  const initLine = line()
    // Ensure that the data is a number
    .defined(d => !isNaN(d.value))
    .x(d => xScale(d.date))
    .y(d => yScale(d.value))

  // Create the svg instead the ref passed from the React component
  const svg = select(elementRef.current)
    .append('svg')
    .attr('width', width)
    .attr('height', height);

  // Create the axis'
  svg.append('g').call(xAxis);
  svg.append('g').call(yAxis);

  // Create the line
  svg.append('path')
    .datum(formattedData)
    .attr('d', initLine)
    // Remove the fill!!
    .attr("fill", "none")
    // Change the line color
    .attr("stroke", "steelblue")
    // Change the line width
    .attr("stroke-width", 1.5);
}

Conclusion

I wanted to go more in-depth on the code and why certain lines are written the way they are, but honestly, this unassuming line graph got the best of me. I want to just move on from it. It’s sort of rattled my confidence in my ability to use D3 in any sort of meaningful way.

I don’t mean to imply that I’m giving up on D3, but I think this has opened my eyes to how steep its learning curve is. I understand now why there are so many libraries written on top of D3 that abstract a lot of the complexity away. I don’t think D3 isn’t worth learning though, so I’ll keep on going with it for now.