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
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
Now that we've got a basic project setup, we can start talking about D3
Scales (d3-scale
)
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 },
]
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)
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
The
invert
method is useful for things like calculating avalue
from a mouseposition
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]);
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);
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);
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);
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}`;
And then, using that value in the svg
element:
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:
- Add Bars to the SVG
- Add Y Labels to the SVG
- 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()}
/>
));
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>
);
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>
));
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>
);
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>
));
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>
);
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) => 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) => 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 = ({}) => {
const viewBox = `0 ${margin.top} ${width} ${height - margin.top}`;
const yLabels = data.map((d) => (
{d.name}
));
const bars = data.map((d) => (
));
const xLabels = data.map((d) => (
{d.count}
));
return (
{yLabels}
{bars}
{xLabels}
);
};
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>
));
And then render this in the svg
as:
return (
<svg viewBox={viewBox}>
<g>{xGrid}</g>
{ /* previous graph content */ }
</svg>
);
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;
};
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];
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]);
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;
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 DateTime
s. We can create a formatter for a DateTime
as follows:
const formatter = d3.timeFormat("%Y-%m")
We can then use the formatter like so:
formatter(dateTime)
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>
));
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>
));
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>
);
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)