DEV Community

Nabeel Valley
Nabeel Valley

Posted on • Originally published at nabeelvalley.co.za

Build Data Visualizations with React

If you want to take a look at the full code alongside the resulting SVG then head on over to my website

React is a library for building reactive user interfaces using JavaScript (or Typescript) and D3 (short for Data-Driven Documents) is a set of libraries for working with visualizations based on data

Before getting started, I would recommend familiarity with SVG, React, and D3

Some good references for SVG are on the MDN SVG Docs

A good place to start for React would be the React Docs or my React Notes

And lastly, the D3 Docs

Getting Stared

To follow along, you will need to install Node.js and be comfortable using the terminal

I'm going to be using a React App with TypeScript initialized with Vite as follows:

yarn create vite
Enter fullscreen mode Exit fullscreen mode

And then selecting the react-ts option when prompted. Next, install d3 from the project root with:

yarn add d3
yarn add --dev @types/d3
Enter fullscreen mode Exit fullscreen mode

Now that we've got a basic project setup, we can start talking about D3

Scales (d3-scale)

d3-scale Documentation

Broadly, scales allow us to map from one set of values to another set of values,

Scales in D3 are a set of tools which map a dimension of data to a visual variable. They help us go from something like count in our data to something like width in our rendered SVG

We can create scales for a sample dataset like so:

type Datum = {
  name: string
  count: number
}

export const data: Datum[] = [
  { name: "🍊", count: 21 },
  { name: "🍇", count: 13 },
  { name: "🍏", count: 8 },
  { name: "🍌", count: 5 },
  { name: "🍐", count: 3 },
  { name: "🍋", count: 2 },
  { name: "🍎", count: 1 },
  { name: "🍉", count: 1 },
]
Enter fullscreen mode Exit fullscreen mode

Also, a common thing to do when working with scales is to define margins around out image, this is done simply as an object like so:

const margin = {
  top: 20,
  right: 20,
  bottom: 20,
  left: 35,
};

This just helps us simplify some position/layout things down the line

Scales work by taking a value from the domain (data space) and returning a value from range (visual space):

const width = 600;
const height = 400;

const x = d3
  .scaleLinear()
  .domain([0, 10])    // values of the data space
  .range([0, width])  // values of the visual space

const position = x(3) // position = scale(value)
Enter fullscreen mode Exit fullscreen mode

Additionally, there's also the invert method which goes the other way - from range to domain

const position = x(3)      // position === 30
const value = x.invert(30) // value === 3
Enter fullscreen mode Exit fullscreen mode

The invert method is useful for things like calculating a value from a mouse position

D3 has different Scale types:

  • Continuous (Linear, Power, Log, Identity, Time, Radial)
  • Sequential
  • Diverging
  • Quantize
  • Quantile
  • Threshold
  • Ordinal (Band, Point)

Continuous Scales

These scales map continuous data to other continuous data

D3 has a few different continuous scale types:

  • Linear
  • Power
  • Log
  • Identity
  • Radial
  • Time
  • Sequential Color

For my purposes at the moment I'm going to be looking at the methods for Linear and Sequential Color scales, but the documentation explains all of the above very thoroughly and is worth a read for additional information on their usage

Linear

We can use a linear scale in the fruit example for mapping count to an x width:

const maxX = d3.max(data, (d) => d.count) as number;

const x = d3
  .scaleLinear<number>()
  .domain([0, maxX])
  .range([margin.left, width - margin.right]);
Enter fullscreen mode Exit fullscreen mode

If we don't want the custom domain to range interpolation we can create a custom interpolator. An interpolator is a function that takes a value from the domain and returns the resulting range value

D3 has a few different interpolators included for tasks such as interpolating colors or rounding values

We can create a custom color domain to interpolate over and use the interpolateHsl or interpolateRgb functions:

const color = d3
  .scaleLinear<string>()
  .domain([0, maxX])
  .range(["pink", "lightgreen"])
  .interpolate(d3.interpolateHsl);
Enter fullscreen mode Exit fullscreen mode

Sequential Color

If for some reason we want to use the pre-included color scales

The scaleSequential scale is a method that allows us to map to a color range using an interpolator.

D3 has a few different interpolators we can use with this function like d3.interpolatePurples, d3.interpolateRainbow or d3.interpolateCool among others which look quite nice

We can create a color scale using the d3.interpolatePurples which will map the data to a scale of purples:

const color = d3
  .scaleSequential()
  .domain([0, maxX])
  .interpolator(d3.interpolatePurples);
Enter fullscreen mode Exit fullscreen mode

These can be used instead of the scaleLinear with interpolateHsl for example above but to provide a pre-calibrated color scale

Ordinal Scales

Ordinal scales have a discrete domain and range and are used for the mapping of discrete data. These are a good fit for mapping a scale with categorical data. D3 offers us the following scales:

  • Band Scale
  • Point Scale

Band Scale

A Band Scale is a type of Ordinal Scale where the output range is continuous and numeric

We can create a mapping for where each of our labels should be positioned with scaleBand:

const names = data.map((d) => d.name);

const y = d3
  .scaleBand()
  .domain(names)
  .range([margin.top, height - margin.bottom])
  .padding(0.1);
Enter fullscreen mode Exit fullscreen mode

The domain can be any size array, unlike in the case of continuous scales where the are usually start and end values

Building a Bar Graph

When creating visuals with D3 there are a few different ways we can output to SVG data. D3 provides us with some methods for creating shapes and elements programmatically via a builder pattern - similar to how we create scales.

However, there are also cases where we would want to define out SVG elements manually, such as when working with React so that the react renderer can handle the rendering of the SVG elements and we can manage our DOM structure in a way that's a bit more representative of the way we work in React

The SVG Root

Every SVG image has to have an svg root element. To help ensure that this root scales correctly we also use it with a viewBox attribute which specifies which portion of the SVG is visible since the contents can go outside of the bounds of the View Box and we may not want to display this overflow content by default

Using the definitions for margin, width and height from before we can get the viewBox for the SVG we're trying to render like so:

const viewBox = `0 ${margin.top} ${width} ${height - margin.top}`;
Enter fullscreen mode Exit fullscreen mode

And then, using that value in the svg element:

return (
  <svg viewBox={viewBox}>
    {/* we will render the graph in here */}
  </svg>
)
Enter fullscreen mode Exit fullscreen mode

At this point we don't really have anything in the SVG, next up we'll do the following:

  1. Add Bars to the SVG
  2. Add Y Labels to the SVG
  3. Add X Labels to the SVG

Bars

We can create Bars using the following:

const bars = data.map((d) => (
  <rect
    key={y(d.name)}
    fill={color(d.count)}
    y={y(d.name)}
    x={x(0)}
    width={x(d.count) - x(0)}
    height={y.bandwidth()}
  />
));
Enter fullscreen mode Exit fullscreen mode

We make use of the x and y functions which help us get the positions for the rect as well as y.bandWidth() and x(d.count) to height and width for the element

We can then add that into the SVG using:

return (
  <svg viewBox={viewBox}>
    <g>{bars}</g>
  </svg>
);
Enter fullscreen mode Exit fullscreen mode

Y Labels

Next, using similar concepts as above, we can add the Y Labels:

const yLabels = data.map((d) => (
  <text key={y(d.name)} y={y(d.name)} x={0} dy="0.35em">
    {d.name}
  </text>
));
Enter fullscreen mode Exit fullscreen mode

Next, we can add this into the SVG, and also wrapping the element in a g with a some basic text alignment and translation for positioning it correctly:

return (
  <svg viewBox={viewBox}>
    <g
      fill="steelblue"
      textAnchor="end"
      transform={`translate(${margin.left - 5}, ${y.bandwidth() / 2})`}
    >
      {yLabels}
    </g>
    <g>{bars}</g>
  </svg>
);
Enter fullscreen mode Exit fullscreen mode

X Labels

Next, we can add the X Labels over each rect using:

const xLabels = data.map((d) => (
  <text key={y(d.name)} y={y(d.name)} x={x(d.count)} dy="0.35em">
    {d.count}
  </text>
));
Enter fullscreen mode Exit fullscreen mode

And the resulting code looks like this:

return (
  <svg viewBox={viewBox}>
    <g
      fill="steelblue"
      textAnchor="end"
      transform={`translate(${margin.left - 5}, ${y.bandwidth() / 2})`}
    >
      {yLabels}
    </g>
    <g>{bars}</g>
    <g
      fill="white"
      textAnchor="end"
      transform={`translate(-6, ${y.bandwidth() / 2})`}
    >
      {xLabels}
    </g>
  </svg>
);
Enter fullscreen mode Exit fullscreen mode

Final Result

The code for the entire file/graph can be seen below:

Fruit.tsx

import React from "react";
import * as d3 from "d3";
import { data } from "../data/fruit";

const width = 600;
const height = 400;

const margin = {
  top: 20,
  right: 20,
  bottom: 20,
  left: 35,
};

const maxX = d3.max(data, (d) =&gt; d.count) as number;

const x = d3
  .scaleLinear()
  .domain([0, maxX])
  .range([margin.left, width - margin.right])
  .interpolate(d3.interpolateRound);

const names = data.map((d) =&gt; d.name);

const y = d3
  .scaleBand()
  .domain(names)
  .range([margin.top, height - margin.bottom])
  .padding(0.1)
  .round(true);

const color = d3
  .scaleSequential()
  .domain([0, maxX])
  .interpolator(d3.interpolateCool);

export const Fruit: React.FC = ({}) =&gt; {
  const viewBox = `0 ${margin.top} ${width} ${height - margin.top}`;

  const yLabels = data.map((d) =&gt; (

      {d.name}

  ));

  const bars = data.map((d) =&gt; (

  ));

  const xLabels = data.map((d) =&gt; (

      {d.count}

  ));

  return (


        {yLabels}

      {bars}

        {xLabels}


  );
};
Enter fullscreen mode Exit fullscreen mode

Ticks and Grid Lines

Note that D3 includes a d3-axis package but that doesn't quite work given that we're manually creating the SVG using React and not D3

We may want to add Ticks and Grid Lines on the X-Axis, we can do this using the scale's ticks method like so:

const xGrid = x.ticks().map((t) => (
  <g key={t}>
    <line
      stroke="lightgrey"
      x1={x(t)}
      y1={margin.top}
      x2={x(t)}
      y2={height - margin.bottom}
    />
    <text fill="darkgrey" textAnchor="middle" x={x(t)} y={height}>
      {t}
    </text>
  </g>
));
Enter fullscreen mode Exit fullscreen mode

And then render this in the svg as:

return (
<svg viewBox={viewBox}>
  <g>{xGrid}</g>
  { /* previous graph content */ }
</svg>
);
Enter fullscreen mode Exit fullscreen mode

Building a Line Graph

We can apply all the same as in the Bar Graph before to draw a Line Graph. The example I'll be using consists of a Datum as follows:

export type Datum = {
  date: Date;
  temp: number;
};
Enter fullscreen mode Exit fullscreen mode

Given that the X-Axis is a DateTime we will need to do some additional conversions as well as formatting

Working with Domains

In the context of this graph it would also be useful to have an automatically calculated domain instead of a hardcoded one as in the previous example

We can use the d3.extent function to calculate a domain:

const dateDomain = d3.extent(data, (d) => d.date) as [Date, Date];
const tempDomain = d3.extent(data, (d) => d.temp).reverse() as [number, number];
Enter fullscreen mode Exit fullscreen mode

We can then use this domain definitions in a scale:

const tempScale = d3
  .scaleLinear<number>()
  .domain(tempDomain)
  .range([margin.top, height - margin.bottom])
  .interpolate(d3.interpolateRound);

const dateScale = d3
  .scaleTime()
  .domain(dateDomain)
  .range([margin.left, width - margin.right]);
Enter fullscreen mode Exit fullscreen mode

Create a Line

The d3.line function is useful for creating a d attribute for an SVG path element which defines the line segments

The line function requires x and y mappings. The line for the graph path can be seen as follows:

const line = d3
  .line<Datum>()
  .x((d) => dateScale(d.date))
  .y((d) => tempScale(d.temp))(data) as string;
Enter fullscreen mode Exit fullscreen mode

We also include the Datum type in the above to scope down the type of data allowed in the resulting function

Formatting

D3 includes functions for formatting DateTimes. We can create a formatter for a DateTime as follows:

const formatter = d3.timeFormat("%Y-%m")
Enter fullscreen mode Exit fullscreen mode

We can then use the formatter like so:

formatter(dateTime)
Enter fullscreen mode Exit fullscreen mode

Grid Lines

We can define the X Axis and grid lines similar to how we did it previously:

const xGrid = dateTicks.map((t) => (
  <g key={t.toString()}>
    <line
      stroke="lightgrey"
      x1={dateScale(t)}
      y1={margin.top}
      x2={dateScale(t)}
      y2={height - margin.bottom}
      strokeDasharray={4}
    />
    <text fill="darkgrey" textAnchor="middle" x={dateScale(t)} y={height}>
      {formatter(t)}
    </text>
  </g>
));
Enter fullscreen mode Exit fullscreen mode

And the Y Axis grid lines:

const yGrid = tempTicks.map((t) => (
  <g key={t.toString()}>
    <line
      stroke="lightgrey"
      y1={tempScale(t)}
      x1={margin.left}
      y2={tempScale(t)}
      x2={width - margin.right}
      strokeDasharray={4}
    />
    <text
      fill="darkgrey"
      textAnchor="end"
      y={tempScale(t)}
      x={margin.left - 5}
    >
      {t}
    </text>
  </g>
));
Enter fullscreen mode Exit fullscreen mode

Final result

Using all the values that have been defined above, we can create the overall graph and grid lines like so:

return (
  <svg viewBox={viewBox}>
    <g>{xGrid}</g>
    <g>{yGrid}</g>
    <path d={line} stroke="steelblue" fill="none" />
  </svg>
);
Enter fullscreen mode Exit fullscreen mode

Unfortanately, since DEV seems to be breaking my code samples, you'll have to jump over to my website if you want to see the final code and SVG samples here

Top comments (0)