Visualizations with React

11 February 2022

Updated: 03 September 2023

Data Visualization with D3 and React

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:

Terminal window
1
yarn create vite

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

Terminal window
1
yarn add d3
2
yarn add --dev @types/d3

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:

1
type Datum = {
2
name: string
3
count: number
4
}
5
6
export const data: Datum[] = [
7
{ name: '🍊', count: 21 },
8
{ name: 'πŸ‡', count: 13 },
9
{ name: '🍏', count: 8 },
10
{ name: '🍌', count: 5 },
11
{ name: '🍐', count: 3 },
12
{ name: 'πŸ‹', count: 2 },
13
{ name: '🍎', count: 1 },
14
{ name: 'πŸ‰', count: 1 },
15
]

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:

1
const margin = {
2
top: 20,
3
right: 20,
4
bottom: 20,
5
left: 35,
6
}

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):

1
const width = 600
2
const height = 400
3
4
const x = d3
5
.scaleLinear()
6
.domain([0, 10]) // values of the data space
7
.range([0, width]) // values of the visual space
8
9
const position = x(3) // position = scale(value)

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

1
const position = x(3) // position === 30
2
const value = x.invert(30) // value === 3

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:

1
const maxX = d3.max(data, (d) => d.count) as number
2
3
const x = d3
4
.scaleLinear<number>()
5
.domain([0, maxX])
6
.range([margin.left, width - margin.right])

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:

1
const color = d3
2
.scaleLinear<string>()
3
.domain([0, maxX])
4
.range(['pink', 'lightgreen'])
5
.interpolate(d3.interpolateHsl)

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:

1
const color = d3
2
.scaleSequential()
3
.domain([0, maxX])
4
.interpolator(d3.interpolatePurples)

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:

1
const names = data.map((d) => d.name)
2
3
const y = d3
4
.scaleBand()
5
.domain(names)
6
.range([margin.top, height - margin.bottom])
7
.padding(0.1)

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:

1
const viewBox = `0 ${margin.top} ${width} ${height - margin.top}`

And then, using that value in the svg element:

1
return <svg viewBox={viewBox}>{/* we will render the graph in here */}</svg>

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:

1
const bars = data.map((d) => (
2
<rect
3
key={y(d.name)}
4
fill={color(d.count)}
5
y={y(d.name)}
6
x={x(0)}
7
width={x(d.count) - x(0)}
8
height={y.bandwidth()}
9
/>
10
))

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:

1
return (
2
<svg viewBox={viewBox}>
3
<g>{bars}</g>
4
</svg>
5
)

At this point, the resulting SVG will look like this:

Y Labels

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

1
const yLabels = data.map((d) => (
2
<text key={y(d.name)} y={y(d.name)} x={0} dy="0.35em">
3
{d.name}
4
</text>
5
))

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:

1
return (
2
<svg viewBox={viewBox}>
3
<g
4
fill="steelblue"
5
textAnchor="end"
6
transform={`translate(${margin.left - 5}, ${y.bandwidth() / 2})`}
7
>
8
{yLabels}
9
</g>
10
<g>{bars}</g>
11
</svg>
12
)

The state of the SVG at this point is:

🍏 🍌 🍊 πŸ‹ πŸ‡ 🍎 πŸ‰ 🍐

X Labels

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

1
const xLabels = data.map((d) => (
2
<text key={y(d.name)} y={y(d.name)} x={x(d.count)} dy="0.35em">
3
{d.count}
4
</text>
5
))

And the resulting code looks like this:

1
return (
2
<svg viewBox={viewBox}>
3
<g
4
fill="steelblue"
5
textAnchor="end"
6
transform={`translate(${margin.left - 5}, ${y.bandwidth() / 2})`}
7
>
8
{yLabels}
9
</g>
10
<g>{bars}</g>
11
<g
12
fill="white"
13
textAnchor="end"
14
transform={`translate(-6, ${y.bandwidth() / 2})`}
15
>
16
{xLabels}
17
</g>
18
</svg>
19
)

And the final SVG:

🍏 🍌 🍊 πŸ‹ πŸ‡ 🍎 πŸ‰ 🍐 8 5 21 2 13 1 1 3

Final Result

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

Fruit.tsx
1
import React from 'react'
2
import * as d3 from 'd3'
3
import { data } from '../data/fruit'
4
5
const width = 600
6
const height = 400
7
8
const margin = {
9
top: 20,
10
right: 20,
11
bottom: 20,
12
left: 35,
13
}
14
15
const maxX = d3.max(data, (d) => d.count) as number
16
17
const x = d3
18
.scaleLinear<number>()
19
.domain([0, maxX])
20
.range([margin.left, width - margin.right])
21
.interpolate(d3.interpolateRound)
22
23
const names = data.map((d) => d.name)
24
25
const y = d3
26
.scaleBand()
27
.domain(names)
28
.range([margin.top, height - margin.bottom])
29
.padding(0.1)
30
.round(true)
31
32
const color = d3
33
.scaleSequential()
34
.domain([0, maxX])
35
.interpolator(d3.interpolateCool)
36
37
export const Fruit: React.FC = ({}) => {
38
const viewBox = `0 ${margin.top} ${width} ${height - margin.top}`
39
40
const yLabels = data.map((d) => (
41
<text key={y(d.name)} y={y(d.name)} x={0} dy="0.35em">
42
{d.name}
43
</text>
44
))
45
46
const bars = data.map((d) => (
47
<rect
48
key={y(d.name)}
49
fill={color(d.count)}
50
y={y(d.name)}
51
x={x(0)}
52
width={x(d.count) - x(0)}
53
height={y.bandwidth()}
54
/>
55
))
56
57
const xLabels = data.map((d) => (
58
<text key={y(d.name)} y={y(d.name)} x={x(d.count)} dy="0.35em">
59
{d.count}
60
</text>
61
))
62
63
return (
64
<svg viewBox={viewBox}>
65
<g
66
fill="steelblue"
67
textAnchor="end"
68
transform={`translate(${margin.left - 5}, ${y.bandwidth() / 2})`}
69
>
70
{yLabels}
71
</g>
72
<g>{bars}</g>
73
<g
74
fill="white"
75
textAnchor="end"
76
transform={`translate(-6, ${y.bandwidth() / 2})`}
77
>
78
{xLabels}
79
</g>
80
</svg>
81
)
82
}

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’s string-based rendering

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:

1
const xGrid = x.ticks().map((t) => (
2
<g key={t}>
3
<line
4
stroke="lightgrey"
5
x1={x(t)}
6
y1={margin.top}
7
x2={x(t)}
8
y2={height - margin.bottom}
9
/>
10
<text fill="darkgrey" textAnchor="middle" x={x(t)} y={height}>
11
{t}
12
</text>
13
</g>
14
))

And then render this in the svg as:

1
return (
2
<svg viewBox={viewBox}>
3
<g>{xGrid}</g>
4
{/* previous graph content */}
5
</svg>
6
)

The result will look like this:

0 2 4 6 8 10 12 14 16 18 20 🍏 🍌 🍊 πŸ‹ πŸ‡ 🍎 πŸ‰ 🍐 8 5 21 2 13 1 1 3

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:

1
export type Datum = {
2
date: Date
3
temp: number
4
}

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:

1
const dateDomain = d3.extent(data, (d) => d.date) as [Date, Date]
2
const tempDomain = d3.extent(data, (d) => d.temp).reverse() as [number, number]

We can then use this domain definitions in a scale:

1
const tempScale = d3
2
.scaleLinear<number>()
3
.domain(tempDomain)
4
.range([margin.top, height - margin.bottom])
5
.interpolate(d3.interpolateRound)
6
7
const dateScale = d3
8
.scaleTime()
9
.domain(dateDomain)
10
.range([margin.left, width - margin.right])

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:

1
const line = d3
2
.line<Datum>()
3
.x((d) => dateScale(d.date))
4
.y((d) => tempScale(d.temp))(data) as string

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:

1
const formatter = d3.timeFormat('%Y-%m')

We can then use the formatter like so:

1
formatter(dateTime)

Grid Lines

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

1
const xGrid = dateTicks.map((t) => (
2
<g key={t.toString()}>
3
<line
4
stroke="lightgrey"
5
x1={dateScale(t)}
6
y1={margin.top}
7
x2={dateScale(t)}
8
y2={height - margin.bottom}
9
strokeDasharray={4}
10
/>
11
<text fill="darkgrey" textAnchor="middle" x={dateScale(t)} y={height}>
12
{formatter(t)}
13
</text>
14
</g>
15
))

And the Y Axis grid lines:

1
const yGrid = tempTicks.map((t) => (
2
<g key={t.toString()}>
3
<line
4
stroke="lightgrey"
5
y1={tempScale(t)}
6
x1={margin.left}
7
y2={tempScale(t)}
8
x2={width - margin.right}
9
strokeDasharray={4}
10
/>
11
<text fill="darkgrey" textAnchor="end" y={tempScale(t)} x={margin.left - 5}>
12
{t}
13
</text>
14
</g>
15
))

Final result

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

1
return (
2
<svg viewBox={viewBox}>
3
<g>{xGrid}</g>
4
<g>{yGrid}</g>
5
<path d={line} stroke="steelblue" fill="none" />
6
</svg>
7
)

The final code can be seen below:

Temperature.tsx
1
import React from 'react'
2
import * as d3 from 'd3'
3
import { data, Datum } from '../data/temperature'
4
5
const width = 600
6
const height = 400
7
8
const margin = {
9
top: 20,
10
right: 50,
11
bottom: 20,
12
left: 50,
13
}
14
15
const tempDomain = d3.extent(data, (d) => d.temp).reverse() as [number, number]
16
17
const tempScale = d3
18
.scaleLinear<number>()
19
.domain(tempDomain)
20
.range([margin.top, height - margin.bottom])
21
.interpolate(d3.interpolateRound)
22
23
const tempTicks = tempScale.ticks()
24
25
const dateDomain = d3.extent(data, (d) => d.date) as [Date, Date]
26
27
const dateScale = d3
28
.scaleTime()
29
.domain(dateDomain)
30
.range([margin.left, width - margin.right])
31
32
const dateTicks = dateScale.ticks(5).concat(dateScale.domain())
33
34
const line = d3
35
.line<Datum>()
36
.x((d) => dateScale(d.date))
37
.y((d) => tempScale(d.temp))(data) as string
38
39
const formatter = d3.timeFormat('%Y-%m')
40
41
export const Temperature: React.FC = ({}) => {
42
const viewBox = `0 0 ${width} ${height}`
43
44
const xGrid = dateTicks.map((t) => (
45
<g key={t.toString()}>
46
<line
47
stroke="lightgrey"
48
x1={dateScale(t)}
49
y1={margin.top}
50
x2={dateScale(t)}
51
y2={height - margin.bottom}
52
strokeDasharray={4}
53
/>
54
<text fill="darkgrey" textAnchor="middle" x={dateScale(t)} y={height}>
55
{formatter(t)}
56
</text>
57
</g>
58
))
59
60
const yGrid = tempTicks.map((t) => (
61
<g key={t.toString()}>
62
<line
63
stroke="lightgrey"
64
y1={tempScale(t)}
65
x1={margin.left}
66
y2={tempScale(t)}
67
x2={width - margin.right}
68
strokeDasharray={4}
69
/>
70
<text
71
fill="darkgrey"
72
textAnchor="end"
73
y={tempScale(t)}
74
x={margin.left - 5}
75
>
76
{t}
77
</text>
78
</g>
79
))
80
81
return (
82
<svg viewBox={viewBox}>
83
<g>{xGrid}</g>
84
<g>{yGrid}</g>
85
<g>
86
<path d={line} stroke="steelblue" fill="none" />
87
</g>
88
</svg>
89
)
90
}

And the resulting SVG will then look something like this:

2011-10 2011-10 2011-10 2011-10 2011-10 2011-10 62.5 62 61.5 61 60.5 60 59.5 59 58.5 58 57.5 57