Build a Bar Chart with D3 and React
D3, or Data Driven Documents, is the defacto standard for data visualization on the web. It’s an incredibly powerful library that gives you all the tools you need to build just about visualization you can dream of.
If you’d like to go through this tutorial in video form, you can check it out in my 30 minute course on egghead.io
D3 is not a collection of predefined charts. Instead, it’s a robust set of utilities you can use to ingest and transform data, to map that data to screen values, and ultimately to manipulate the DOM to render your visuals.
When working with React, D3’s desire to manipulate the DOM is at odds with React’s goal of rendering the UI as a function of state. So how do we handle this? Simply put, we use D3 for everything up to the point where we want to render our output, and then we hand all that data to React for rendering.
In this post, we’ll walk through a relatively small example where we build a Bar Chart. We’ll take a step-by-step approach and I’ll do my best to explain all the pieces along the way. At the end, you’ll have a foundation to work on to combine D3 and React to add data visualizations to your React apps.
Even though this is a “small” example, there is a lot going on here, as I tried to cover all the important bits pretty thoroughly.
Getting Setup
Let’s get going! We’ll start with a React project with D3 added as a dependency.
Create a Project
You’ll want a project that has react
, react-dom
, and d3
installed. You can use Create React App (CRA), Next, Vite with the React template, it’s completely up to you. For simplicity, you can start a CodeSandbox project using the React template and add d3
as a dependency.
For these examples, the file structure is based on using CodeSandbox, which will match that of a CRA setup. If you are using a different starting point, you may need to adjust file paths to fit your setup.
Initial Setup
To keep things simple, we’ll create our visualization for a small set of sample data that we’ll define directly in our JavaScript. We’ll also put the majority of our code in a Chart
component as we walk through the steps, and we’ll render that in the App
component.
// App.js
import './styles.css'
import { Chart } from './Chart'
const data = [
{ region: 'East', value: 100 },
{ region: 'West', value: 132 },
{ region: 'North', value: 221 },
{ region: 'South', value: 87 },
]
export default function App() {
return (
<div>
<h1>A Bar Chart with React & D3</h1>
<Chart data={data} />
</div>
)
}
// Chart.js
export function Chart(props) {
return <div>Chart goes here</div>
}
Let’s Create Our Canvas
All of our work in the upcoming steps will result in drawing and positioning shapes in an SVG. To get started we’ll define all the dimensions we want to work with and within the SVG, we’ll define the bounds where our data elements will be rendered.
Defining Dimensions
We’ll start by defining a dimensions
object to contain our height
, width
, and all the margin
values we need to layout our svg
. We’ll define this in App.js
and pass it into our Chart
component as a prop.
// App.js
+const dimensions = {
+ width: 500,
+ height: 350,
+ margin: { top: 20, left: 50, right: 5, bottom: 25 }
+}
export default function App() {
return (
<div>
<h1>A Bar Chart with React & D3</h1>
- <Chart data={data} />
+ <Chart data={data} dimensions={dimensions} />
</div>
)
}
Render the svg
and the Chart Bounds
Now that we have the dimensions defined, we can render the SVG container, we can calculate our bounds, and we can render the bounded area that will hold our data points.
Let’s switch over to the Chart
component and make use of this dimensions
prop.
// Chart.js
export function Chart(props) {
+ const { data, dimensions } = props
+ const { width, height, margin } = dimensions
+
+ const boundedHeight = height - margin.bottom - margin.top
+ const boundedWidth = width - margin.right - margin.left
+
return <div>Chart goes here</div>
}
We start by grabbing the key dimensions
values from props
and calculating the width and height for our bounds
. This is the area where we’ll draw our bars. The remaining space (the margins) will be where we put our axes.
With these values calculated, let’s render our svg
.
export function Chart(props) {
const { data, dimensions } = props
const { width, height, margin } = dimensions
const boundedHeight = height - margin.bottom - margin.top
const boundedWidth = width - margin.right - margin.left
- return <div>Chart goes here</div>
+ return (
+ <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
+ <rect fill="gray" width="100%" height="100%" />
+ <g transform={`translate(${margin.left}, ${margin.top})`}>
+ <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
+ </g>
+ </svg>
+ )
}
Here, we’re adding in an svg
element that uses our height
and width
dimensions. Directly inside the svg
, I’ve defined a rect
element with full width
and height
, and a gray fill
. This is just to make our svg
visible for now, we’ll remove this later.
I’ve also defined a group (g
) element. This has a transform
applied to it, to shift it right and down based on our margins. This g
will represent our entire bounds
area.
The transform
not only positions the group, it also makes any contained elements positioning relative to the group’s top left, which is now the 0,0
coordinate for any of its children.
This group currently contains a rect
with a light gray fill
and our bounded dimensions just to show our overall layout. We’ll remove this rect
in a future step.
Now, we can see where our bounds are and what space is left for our margins. We’re still not using the boundedWidth
or boundedHeight
in any permanent code, but they’ll come into play more when we define our scales.
Scales in D3
This section is an overview of scales in D3. If you want to skip it and just continue with the example, you can jump to the next step in our walk through
In our work with D3, we’ll make heavy use of various scales. Scales are the mechanism by which we take values from our data domain, and map those values to screen values.
When I say screen values, I mean some value that will translate into the way some element is rendered to the screen. In many cases, that means a number that represents the height
or width
of an element or the x
and y
values that determine an element’s position. Screen values could also include opacity, color, or shape. Pretty much any variable you might want to control in your output can be derived from your data using one of D3’s scales.
An Example Linear Scale
Before we get back to our chart, let’s look at an example of a linear scale in D3 with scaleLinear()
. A linear scale is good for mapping a set of continuous values in your data domain to continuous values in your range of screen values.
In this example, we’ll map from a domain between 1 and 10 to pixel values between 0 and 100. We’ll use this scale for an x-axis for this hypothetical data set.
const xScale = d3.scaleLinear().domain([1, 10]).range([0, 100])
Our data domain (1-10) is passed into the domain
method as an array. We then pass our range (1-100) as an array to the range
method. This code returns a scale, which we can call directly.
xScale(1) // 0
xScale(5) // 44.44444444444444
xScale(10) // 100
You can see here that the far ends of the scale are exactly what we see in the data we pass in. The low domain
value, maps directly to the low value of the range
. The same holds true for the high value in each. For any value in between, the scale calculates the appropriate value in the continuous range of values. In the case, we can imagine the width of our bounds is 100px
. Passing in 5
as a data point would map to 44.44444444444444
pixels from the left, allowing us to place an element relative to the value of the item it represents.
Other Scales
D3 has several kinds of scales for various cases. We’ll use a couple more scales in this example. You can check out all the scales here.
Creating the xScale for Our Chart
With some familiarity of scales in hand, let’s define the xScale
we’ll need to draw our bar chart!
Chart updates
Let’s walk through the changes we want to make to Chart.js
. We’re going to create our xScale
, then we’re going to apply the xScale
to Chart
and draw a single data point. Along the way, we’ll touch on accessor functions too.
Create the xScale
First, we’ll import scaleLinear
and the max
utility from d3
.
import { scaleLinear, max } from 'd3'
Then we can use these to define our xScale
inside the Chart
function body. We use scaleLinear
to create a scale where our domain is between 0
and the max
value of the value
key across the objects in our data
array. We also set our range to give us corresponding pixel values between 0
and the boundedWidth
.
const xScale = scaleLinear()
.domain([0, max(data, (d) => d.value)])
.range([0, boundedWidth])
Many of the utilities in d3
accept accessor
functions. Here, we use the accessor function (d) => d.value
as the second argument to max
. This tells max
to base it’s comparisons and return value on the value
key of each item.
Apply the xScale
to Draw a Data Point
Now that we have a scale for our x-axis, let’s draw a bar to represent one of our data points. For now, we’ll just pluck off the first data point and we’ll use it to draw a bar (rect
) in our bounds.
Inside our bounds
(the g
with the transform), we’ll define a new rect
element using the xScale
to calculate the width
from our data
// put this **after** the lightgray `rect` so it isn't covered up
<rect fill="black" height="50px" width={xScale(data[0].value)} />
That data access code is a bit ugly. Don’t worry, we’ll clean this up as we go. The full return for our Chart
component should currently look like this.
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<rect fill="gray" width="100%" height="100%" />
<g transform={`translate(${margin.left}, ${margin.top})`}>
<rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
+ <rect fill="black" height="50px" width={xScale(data[0].value)} />
</g>
</svg>
)
You’ll notice that we’re directly accessing value
from a data point. Earlier, we defined an accessor function that does that. Let’s refactor this a bit so we can reuse that accessor function. Since this one accesses our data for the x-axis, let’s call it xAccessor
, and we’ll define it just before our xScale
.
const xAccessor = (d) => d.value
Now let’s use this function in our xScale
definition and rendering code
const xScale = scaleLinear()
- .domain([0, max(data, (d) => d.value)])
+ .domain([0, max(data, xAccessor)])
.range([0, boundedWidth])
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<rect fill="gray" width="100%" height="100%" />
<g transform={`translate(${margin.left}, ${margin.top})`}>
<rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
- <rect fill="black" height="50px" width={xScale(data[0].value)} />
+ <rect fill="black" height="50px" width={xScale(xAccessor(data[0]))} />
</g>
</svg>
)
This cleans up the code a bit, but this isn’t the best place for this. Our data is being passed into the Chart
component from App. Presumably, App
knows more about the data than Chart
. It would make more sense to define this accessor function outside the Chart
component and then pass it in as a prop. Now the consuming component that is responsible for the data can also be responsible for defining the accessor functions, making this Chart
component slightly more reusable.
Let’s refactor Chart
and then update App
!
In Chart
, we’ll remove the definition of xAccessor
and update our destructuring of props
to include xAccessor
.
-const { data, dimensions } = props
+const { data, dimensions, xAccessor } = props
-const xAccessor = (d) => d.value
App Updates
Now in App.js
, we’ll define the xAccessor
and pass it down to Chart
as a prop.
import './styles.css'
import { Chart } from './Chart'
const data = [
{ region: 'East', value: 100 },
{ region: 'West', value: 132 },
{ region: 'North', value: 221 },
{ region: 'South', value: 87 }
]
+const xAccessor = (d) => d.value
const dimensions = {
width: 500,
height: 350,
margin: { top: 20, left: 50, right: 5, bottom: 25 }
}
export default function App() {
return (
<div>
<h1>A Bar Chart with React & D3</h1>
- <Chart data={data} dimensions={dimensions} />
+ <Chart data={data} dimensions={dimensions} xAccessor={xAccessor} />
</div>
)
}
With all the xScale code in place, we can see how that renders out our first data point as a black bar around 200 pixels wide. We still have some work to do to make this a useful visualization, but we’re well on our way. Let’s move on to creating our yScale
in the next step!
Creating the yScale for Our Chart
Now that we have our xScale
defined, let’s move on to the yScale
. This time we’ll look at a different type of scale to handle our y-axis. First, we’ll start off by defining an accessor function.
Define a yAccessor
We used an accessor function to get value
from a data object for our xScale
, and we’ll use one for grabbing the region
key for the yScale
. Let’s define that in App.js
along side our xAccessor
.
const xAccessor = (d) => d.value
+const yAccessor = (d) => d.region
And we’ll pass it down to Chart
as a prop.
-<Chart data={data} dimensions={dimensions} xAccessor={xAccessor} />
+<Chart
+ data={data}
+ dimensions={dimensions}
+ xAccessor={xAccessor}
+ yAccessor={yAccessor}
+ />
Create the yScale
We no longer have a continuous domain, instead, we have a set of discrete values. We still want to map those to the continuous range of pixel values in our svg
’s height, so for this one we’re going to reach for a band scale using d3.scaleBand()
.
This will allow us to map our 4 specific values into evenly distributed pixel values based on our dimensions. Essentially, we’re letting D3 figure out:
- How many elements we have
- How much space we have in total for those elements
- How much space each element gets
Once we have this scale, we can pass in a region name, and get back a value we can use to position and size our elements.
First, let’s import scaleBand
from d3
by updating the import
at the top of Chart.js
.
-import { scaleLinear, max } from 'd3'
+import { scaleBand, scaleLinear, max } from 'd3'
We’ll grab the yAccessor
from props at the top of our function body for Chart
.
-const { data, dimensions, xAccessor } = props
+const { data, dimensions, xAccessor, yAccessor } = props
Then we can define our yScale
in Chart.js
.
// Chart.js
const yScale = scaleBand().domain(data.map(yAccessor)).range([boundedHeight, 0])
Use the yScale
We’re going to use our new scale to position our bars along the y-axis, but before that we’ll grab the width for each band, using the bandwidth()
method on our yScale
.
// Chart.js
const bandwidth = yScale.bandwidth()
The value of bandwidth
happens to be 66.95121951219512
with our current setup, but we don’t need to worry about that. If any of the input for our scale changes in the future (dimensions, number of data points, etc.) this value will be calculated for us based on the new inputs.
With the bandwidth
value handy, let’s replace our single bar representing the first data point with a map
over the data
array so we can place a bar on our bounds
for each of our data points.
This is what the new return
from our Chart
component will look like.
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<rect fill="gray" width="100%" height="100%" />
<g transform={`translate(${margin.left}, ${margin.top})`}>
<rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
{data.map((datum) => (
<rect
key={yAccessor(datum)}
fill="black"
height={bandwidth}
transform={`translate(0, ${yScale(yAccessor(datum))})`}
width={xScale(xAccessor(datum))}
/>
))}
</g>
</svg>
)
We’re using data.map
to render a rect
for each item. We’re using the identifier datum
here (the singular form of data) for each item in the array. We’re using bandwidth
for our height
. We’ve also used a transform
in combination with our yAccessor
and yScale
to position each bar along the y-axis. We still have width
defined based on our xScale
via data obtained with the xAccessor
. Lastly, we added a key
prop since we’re rendering a list of children in React. In this case, we’re just using the region
value for the key via the yAccessor
.
Here’s a working demo of the Chart
at this stage.
At this point, we’re rendering what is possibly the world’s ugliest chart! We’ll address that shortly, but first we have some ugly code we could clean up.
Scaled Accessors
We’re nesting accessor calls inside calls to our scales. We’ll see this pattern enough that we should create functions for it. Let’s create scaled accessor functions to clean this up a bit.
Just before our return
, after our scale definitions, let’s define the following.
const scaledX = (d) => xScale(xAccessor(d))
const scaledY = (d) => yScale(yAccessor(d))
Then we can refactor our rendering code to use these
{data.map((datum) => (
<rect
key={yAccessor(datum)}
fill="black"
height={bandwidth}
- transform={`translate(0, ${yScale(yAccessor(datum))})`}
- width={xScale(xAccessor(datum))}
+ transform={`translate(0, ${scaledY(datum)})`}
+ width={scaledX(datum)}
/>
))}
Our chart is still pretty ugly, but the code is a little easier to read now 😅.
Let’s move on to defining and rendering our axes.
Render the X Axis
When using D3 for your DOM manipulation, there are handy helper functions available that will generate an axis for you. Since we’re rendering with React, we’re going to continue to use D3 for data only, and we’ll handle all the rendering in React.
Initial Setup
We’re going to create a new component for our x-axis. We’ll start by creating a new file in our project. We’ll call it BottomAxis.js
since the x-axis will run across the bottom of our chart.
For now, we’ll define BottomAxis
by just returning an svg
group.
export function BottomAxis(props) {
return <g>{/*Render the axis here*/}</g>
}
Then in Chart.js
, we’ll import the BottomAxis
component
import { BottomAxis } from './BottomAxis'
And finally, we’ll render it in Chart
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<rect fill="gray" width="100%" height="100%" />
<g transform={`translate(${margin.left}, ${margin.top})`}>
<rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
{data.map((datum) => (
<rect
fill="black"
height={bandwidth}
transform={`translate(0, ${scaledY(datum)})`}
width={scaledX(datum)}
/>
))}
</g>
+ <BottomAxis />
</svg>
)
We’re dropping it in the svg
, outside of the group that defines our bounds. In the next step, we’ll position the axis with it’s own transform
.
Position the Axis
The first element in our BottomAxis
is a g
. We’ll set a transform
on this element, and then everything inside the group will be rendered relative to the group’s new 0,0
coordinate.
To position the axis, we’ll need to provide it with some values. It’s going to need our margin
object and the boundedHeight
.
In Chart.js
, we’ll pass this data to BottomAxis
as props
// Chart.js
-<BottomAxis />
+<BottomAxis margin={margin} boundedHeight={boundedHeight} />
We’ll update BottomAxis
to use those values and it’ll end up looking like this
// BottomAxis.js
export function BottomAxis(props) {
const { margin, boundedHeight } = props
return (
<g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
{/*Render the axis here*/}
</g>
)
}
Here we’re grabbing margin
and boundedHeight
from props
. We use a transform
with translate
to set the group’s x
position to match our left margin
. Our y
is translated to the boundedHeight
plus the top margin
. This will push the group that contains this axis down into the bottom margin, just below our bounds. Of course, we can’t see this because we aren’t rendering any visible elements. Let’s throw a rect
in our group just to see if everything lines up like we expect
// BottomAxis.js
export function BottomAxis(props) {
const { margin, boundedHeight } = props
return (
<g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
- {/*Render the axis here*/}
+ <rect fill="lightblue" height={margin.bottom} width="445px" />
</g>
)
}
I’ve added this rect
with a lightblue
background. I’ve made the height
match the bottom margin and I’ve hard-coded the boundedWidth
which happens to be 445px
with our current values. I’m not using the value in props
here because this rect
is temporary and we won’t need that value for the finished product.
With this rectangle in place, we can see it lines up with the bottom left edge of our bounds
and fills the space reserved for the bottom margin. This is exactly what we want!
Render Ticks
Now that our BottomAxis
is properly positioned, we need to render tick marks to represent data values across the axis. For this, we’re going to pass our xScale
down to the component as a prop from Chart
.
// Chart.js
-<BottomAxis margin={margin} boundedHeight={boundedHeight} />
+<BottomAxis
+ margin={margin}
+ boundedHeight={boundedHeight}
+ xScale={xScale}
+/>
Then we can update BottomAxis
to grab the xScale
from props
.
// BottomAxis.js
export function BottomAxis(props) {
- const { margin, boundedHeight } = props
+ const { margin, boundedHeight, xScale } = props
We can use the ticks()
method on our xScale
to get back an array of evenly spaced values based on the scale’s domain values.
xScale.ticks() // [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220]
Let’s use the array provided by ticks()
to render some tick marks. We’ll replace the hard-coded rectangle in our g
by mapping over xScale.ticks()
and for each tick
, we’ll render a g
that contains a rect
element. Each rect
will be positioned with a transform
. Our updated BottomAxis
component will now look like this
// BottomAxis
export function BottomAxis(props) {
const { margin, boundedHeight, xScale } = props
return (
<g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
<rect fill="lightblue" height={margin.bottom} width="445px" />
+ {xScale.ticks().map((tick) => (
+ <g key={tick}>
+ <rect
+ height="15"
+ width="3"
+ transform={`translate(${xScale(tick)})`}
+ />
+ </g>
+ ))}
</g>
)
}
We put each tick in a g
so we can add labels in the next step.
At this point, our example includes a properly positioned BottomAxis
component (as represented by the light blue rect
) and that axis is rendering a series of evenly spaced ticks.
Render Labels
Our ticks are in place, but there is no way to associate a value with a tick, so we’ll add some labels.
Let’s add a text
element in each of our tick groups.
// BottomAxis.js
return (
<g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
{xScale.ticks().map((tick) => (
<g key={tick}>
<rect
height="15"
width="3"
transform={`translate(${xScale(tick)})`}
/>
+ <text
+ fontSize=".75rem"
+ dominantBaseline="hanging"
+ textAnchor="middle"
+ transform={`translate(${xScale(tick) + 1.5}, 17)`}
+ >
+ {tick}
+ </text>
</g>
))}
</g>
)
There’s a lot happening here, so let’s walk through what we’ve done.
Adding the text
element with tick
as the value
We’ve added a text
element, and used the tick
value from our loop as the text value.
Size and position
We’re using the fontSize
attribute and relative sizing to make our font size a bit smaller than the default. We’ve set the dominantBaseline
property to hanging
so our placement applies from the top of the text
element, rather than the bottom.
To center the text
on the tick mark, we’ve set textAnchor
to middle
, essentially centering the text (by setting it’s 0
x coordinate to the center), and in our transform
we’ve added half the width of a tick mark to our tick’s x position (as determined by the xScale
). Without this, the text would be slightly left off center on the tick mark.
We’ve also adjusted the y
position in our transform
. We’ve hard coded 17
, which is the height of a tick mark, plus a couple pixels for padding. The combination of dominantBaseline
and the y
transform sets our text just below the tick marks. I’ll leave it as exercise to the reader if you’d like to make these items more dynamic.
Adjusting Our Margins
Now that we’re rendering out our tick marks for the x-axis, it appears that we haven’t left enough space in the margins for all of this. The good news is, because we’ve set everything up to be calculated based on our dimensions
object via scales, we can go back to App.js
, adjust our margins and everything else will be updated based on the new dimensions
.
Back in App.js
, let’s add some more breathing room. We’ll bump the bottom margin up ten pixels to 35
, and we’ll do the same for the right margin and take it up to 15
.
const dimensions = {
width: 500,
height: 350,
- margin: { top: 20, left: 50, right: 5, bottom: 25 }
+ margin: { top: 20, left: 50, right: 15, bottom: 35 }
}
And like magic, everything in our chart shifts to work based on the updated margins, no additional work required!
You might notice that the rectangle background for our BottomAxis
is spilling over into the right margin. This is fine. The rect
element has a hard coded width and the margin is there to reserve that space for our labels. It’ll look fine once we remove all the placeholder rect
elements.
nice!
You may have noticed that one of our bars has a data value that extends past the last tick in our axis. The value
for the north region
is 221
, but our axis stops at 220
.
If our axis is defined based on our scale, and we specified our data domain in our scale definition, why does our axis stop short of our top end value?
When we provide our domain to generate a scale, we’re not defining explicit expectations so much as providing guidance that will be used by an algorithm to give us a clean set of round numbers to work with.
Our ticks
are optimized for human readable values. Unfortunately, in this case, we have a data point that falls just outside the round numbers D3 gives us. This problem is easily fixed by adding the nice()
method to our xScale
definition. Adding nice
will give us some breathing rooms by extending our ticks
to include our full data domain and rounding up.
// Chart.js
const xScale = scaleLinear()
.domain([0, max(data, xAccessor)])
+ .nice()
.range([0, boundedWidth])
Just like that, our BottomAxis
values shift, our bars shrink to line up with the new mapping based on our xScale
and the chart is starting to really come together.
Render the Y Axis
We have some indication of what the width of the bars represents (roughly), but we still don’t know what each bar represents. Let’s add some labels down the left site of our chart.
Initial Setup for the Left Axis
Just like with BottomAxis
, we’ll define the y-axis in a new component, we’ll call this one LeftAxis.js
// LeftAxis.js
export function LeftAxis(props) {
return <g>{/* Render Left Axis Labels */}</g>
}
We’ll import it into Chart.js
// Chart.js
import { LeftAxis } from './LeftAxis'
Then we can render it
// Chart.js
<BottomAxis
margin={margin}
boundedHeight={boundedHeight}
xScale={xScale}
/>
+ <LeftAxis />
</svg>
We’re also going to want to position this axis, so let’s pass in our margin
object, and we’ll need the yScale
in the next step, so let’s also add that now.
// Chart.js
-<LeftAxis />
+<LeftAxis margin={margin} yScale={yScale} />
Now we can use our margin
object to position our g
for the LeftAxis
. We’ll destructure props
, add a transform
that sets the y
position to account for the top margin
, and since we can’t see anything with just a group, let’s add a temporary rect
to verify our positioning.
// LeftAxis.js
export function LeftAxis(props) {
const { margin, yScale } = props
return (
<g transform={`translate(0, ${margin.top})`}>
<rect fill="lightblue" height="295" width={margin.left} />
</g>
)
}
Render Labels
Now that we’ve properly positioned the LeftAxis
, let’s render labels for our regions.
We’ll start by grabbing the domain()
from our yScale
. This gives us our labels.
yScale.domain() // ['East', 'West', 'North', 'South']
First Pass
Let’s map over the values returned from domain
and render text
elements to them. We’ll call each individual item band
here since we’re looking at the individual bands in our band scale. We’ll use the band
as the text value and apply a transform
by applying the yScale
to our band
value. We also need a key
prop since we’re looping through values, so we’ll just use band
.
The LeftAxis
component should look like this now.
// LeftAxis.js
export function LeftAxis(props) {
const { margin, yScale } = props
return (
<g transform={`translate(0, ${margin.top})`}>
{yScale.domain().map((band) => (
<text key={band} transform={`translate(0, ${yScale(band)})`}>
{band}
</text>
))}
</g>
)
}
When we render this, we’ll see our labels running down the left side of the chart, and the bottom of the text will align with the top of its corresponding bar.
Additional Formatting & Positioning
Let’s format our text elements a bit and position them so they’re center aligned with the bar they represent. We’ll start by adjusting our transform
. We know the value we get back from yScale(band)
will put the text at the top of the bar. To center it vertically, we’ll add half the bandwidth
value. So our transform
becomes
transform={`translate(0, ${yScale(band) + yScale.bandwidth() / 2})`}
This will align the bottom of the text with our bar. To finish the alignment, we’ll change the dominantBaseline
property to middle
. Finally, we’ll make the labels a little more prominent by changing fontWeight
to bold
.
// LeftAxis.js
export function LeftAxis(props) {
const { margin, yScale } = props
return (
<g transform={`translate(0, ${margin.top})`}>
{yScale.domain().map((band) => (
- <text key={band} transform={`translate(0, ${yScale(band)})`}>{band}</text>
+ <text
+ key={band}
+ transform={`translate(0, ${yScale(band) + yScale.bandwidth() / 2})`}
+ dominantBaseline="middle"
+ fontWeight="bold"
+ >
+ {band}
+ </text>
))}
</g>
)
}
Padding
Our bars are all very close to one another which is not great for readability. We can fix this while letting D3 continue to do the math for us. The band scale in D3 allows us to specify padding right on the scale definition.
All we need to do is add the padding
method to our yScale
definition. Padding takes a value between 0
and 1
. Let’s use 0.1
to create some padding, while retaining nice wide bars. Play around with this value and find one that appeals to you.
// Chart.js
const yScale = scaleBand()
.domain(data.map(yAccessor))
.range([boundedHeight, 0])
+ .padding(0.1)
UI Cleanup & Additional Labels
Remove Those Ugly rect
s
Finally! We get to start making this thing look less terrible. Since we have our axes defined and rendered, our data points are in our bounds, we don’t need the visual reference (noise?) of the ugly rect
elements we started with. Let’s just delete those.
// Chart.js
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
- <rect fill="gray" width="100%" height="100%" />
<g transform={`translate(${margin.left}, ${margin.top})`}>
- <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
{data.map((datum) => (
<rect
key={yAccessor(datum)}
fill="black"
height={bandwidth}
transform={`translate(0, ${scaledY(datum)})`}
width={scaledX(datum)}
/>
))}
</g>
<BottomAxis
margin={margin}
boundedHeight={boundedHeight}
xScale={xScale}
/>
<LeftAxis margin={margin} yScale={yScale} />
</svg>
)
That’s looking better, but there’s still some room for improvement here.
Additional Labels
Looking at the axis in relation to the bars gives us an indication of where values fall roughly, but we don’t have exact figures depicted anywhere. Let’s add some additional labels so we can see values relative to each other, but also quickly identify the exact value of individual bars.
When we map over the data in the Chart
component, let’s wrap our rect
in a g
. We’ll move the key
prop to the g
and we’ll move the transform
along with it.
// Chart.js
<g transform={`translate(${margin.left}, ${margin.top})`}>
{data.map((datum) => (
- <rect
- key={yAccessor(datum)}
- fill="black"
- height={bandwidth}
- transform={`translate(0, ${scaledY(datum)})`}
- width={scaledX(datum)}
- />
+ <g
+ key={yAccessor(datum)}
+ transform={`translate(0, ${scaledY(datum)})`}
+ >
+ <rect fill="black" height={bandwidth} width={scaledX(datum)} />
+ </g>
))}
</g>
That should leave everything looking the same, we’ve just shifted the y
positioning out to the group.
Now we’ll add a text
element right after the rect
inside the group we just defined.
// Chart.js
<text
fontWeight="bolder"
dominantBaseline="hanging"
transform={`translate(${xScale(xAccessor(datum)) + 4})`}
>
{xAccessor(datum)}
</text>
We made the font bold, and set the dominantBaseline
to hanging so the top of the text aligns with the top of the bar. We also applied a transform
using our scaledX
accessor to position the text 4px
to the right of the bar’s width. Finally, using the xAccessor
, we use value
from our datum as the text label.
With all of these changes applied, the full return
for Chart
should now look like this
// Chart.js
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<g transform={`translate(${margin.left}, ${margin.top})`}>
{data.map((datum) => (
<g key={yAccessor(datum)} transform={`translate(0, ${scaledY(datum)})`}>
<rect fill="black" height={bandwidth} width={scaledX(datum)} />
<text
fontWeight="bolder"
dominantBaseline="hanging"
transform={`translate(${scaledX(datum) + 4})`}
>
{xAccessor(datum)}
</text>
</g>
))}
</g>
<BottomAxis margin={margin} boundedHeight={boundedHeight} xScale={xScale} />
<LeftAxis margin={margin} yScale={yScale} />
</svg>
)
It’s getting better, but I think this chart could use a little color!
Add a Color Scale
Scales aren’t just for numbers and labels, we can also define a color scale. We’ll keep this one simple, and map our data items to an array of 4 color values.
Define the Color Scale
We’ll use an ordinal scale this time, to map items in a discrete domain (regions) to values in a discrete range (a list of hex color values).
We’ll start by importing scaleOrdinal
from D3.
// Chart.js
-import { scaleBand, scaleLinear, max } from 'd3'
+import { scaleBand, scaleLinear, scaleOrdinal, max } from 'd3'
Then we’ll add the following scale definition in the Chart
component’s function body.
// Chart.js
const colorScale = scaleOrdinal()
.domain(data.map(yAccessor))
.range(['#005f73', '#0a9396', '#ee9b00', '#ae2012'])
Apply the Color Scale
And now that we have a color scale to work with, let’s apply it to the group that contains both the bar and the label for a region’s value. We’ll also remove the explicit fill from the rect
and allow it to inherit the fill from the containing g
.
// Chart.js
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
<g transform={`translate(${margin.left}, ${margin.top})`}>
{data.map((datum) => (
<g
key={yAccessor(datum)}
transform={`translate(0, ${scaledY(datum)})`}
+ fill={colorScale(yAccessor(datum))}
>
- <rect fill="black" height={bandwidth} width={scaledX(datum)} />
+ <rect height={bandwidth} width={scaledX(datum)} />
<text
fontWeight="bolder"
dominantBaseline="hanging"
transform={`translate(${scaledX(datum) + 4})`}
>
{xAccessor(datum)}
</text>
</g>
))}
</g>
<BottomAxis
margin={margin}
boundedHeight={boundedHeight}
xScale={xScale}
/>
<LeftAxis margin={margin} yScale={yScale} />
</svg>
)
And with those changes in place, we have a decent looking Chart that accurately represents our sample data 🎉.
Conclusion
Phew! That was a lot! 😅
While we didn’t build the world’s most exciting data visualization, we did cover a lot of ground and we hit on some key fundamentals of D3. Many of the steps we took here are going to apply to just about any data visualization you create using D3.
We’ll always want to give our drawing area some structure, creating areas for axes, and leaving space for showing data points. We’ll always want to use one or more of the various scales to map from data to pixels. Accessors are widely supported in the various D3 utilities, so we’ll typically create those. And finally, we’ll use these values to draw a compelling graphic visualization of whatever data we’re trying to communicate.