With that done, let's render some data.
Create a new function in main.js, renderChart():
function renderChart(data) {
chart.attr('width', window.innerWidth)
.attr('height', window.innerHeight);
}
All this does is take our earlier chart variable and set its width and height to that of the window. We're almost at the point of getting some bars onto that graph; hold tight!
First, however, we need to define our scales, which decide how D3 maps data values to pixel values. Put another way, a scale is simply a function that maps an input range to an output domain. This can be annoying to remember, so I'm going to shamelessly steal an exercise from Scott Murray's excellent tutorial on scales from Interactive Data Visualization for the Web:
When I say "input," you say "domain." Then I say "output," and you say "range." Ready? Okay:
Input! Domain!
Output! Range!
Input! Domain!
Output! Range!
Got it? Great.
It seems silly, but I frequently find myself muttering the above when I have a deadline and am working on a chart late at night. Give it a go!
Next, add this code to renderChart():
const x = d3.scaleBand()
.domain(data.map(d => d.region))
.rangeRound([50, window.innerWidth - 50])
.padding(0.1);
The x scale is now a function that maps inputs from a domain composed of our region names to a range of values between 50 and the width of your viewport (minus 50), with some spacing defined by the 0.1 value given to .padding(). What we've created is a band scale, which is like an ordinal scale, but the output is divided into sections. We'll talk more about scales later on in the book.
In this example, we use a uniform value of 50 for our margins, which we pass to our scales and elsewhere. Any arbitrary number passed in code is often referred to as a magic number, insomuch that, to anyone reading your code, it just looks like a random value that magically makes it work. This is bad; don't do this--it makes your code harder to read, and it means that you have to find and replace every value if you want to change it. I only do so here to demonstrate this fact. Throughout the rest of the book, we'll define things, such as margins more intelligently; stay tuned!
Still inside renderChart(), we define another scale named y:
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.meanPctTurnout)])
.range([window.innerHeight - 50, 0]);
Similarly, the y scale is going to map a linear domain (which runs from zero to the max value of our data, the latter of which we acquire using d3.max) to a range between window.innerHeight (minus our 50 pixel margin) and 0. Inverting the range is important because D3 considers the top of a graph to be y=0. If ever you find yourself trying to troubleshoot why a D3 chart is upside down, try switching the range values in one of your scales.
Now, we define our axes. Add this just after the preceding line, inside renderChart:
const xAxis = d3.axisBottom().scale(x);
const yAxis = d3.axisLeft().scale(y);
We've told each axis what scale to use when placing ticks and which side of the axis to put the labels on. D3 will automatically decide how many ticks to display, where they should go, and how to label them. Since most D3 elements are objects and functions at the same time, we can change the internal state of both scales without assigning the result to anything. The domain of x is a list of discrete values. The domain of y is a range from 0 to the d3.max of our dataset, the largest value.
Now, we will draw the axes on our graph:
chart.append('g')
.attr('class', 'axis')
.attr('transform',
`translate(0, ${window.innerHeight - 50})`)
.call(xAxis);
Hot new ES2015 feature alert! Above, the transform argument is in backticks (`), which are template literal strings. They're just like normal strings, except for two differences: you can use newline characters in them, and you can also run arbitrary JavaScript expressions in them via the ${} syntax. Above, we merely echo out the value of window.innerHeight, but you can write any expression that returns a string-like value, for instance, using Array.prototype.join to output the contents of an array; it's really handy!
We've appended an element called g to the graph, given it the axis CSS class, and moved the element to a place in the bottom-left corner of the graph with the transform attribute.
Finally, we call the xAxis function and let D3 handle the rest.
The drawing of the other axis works exactly the same, but with different arguments:
chart.append('g')
.attr('class', 'axis')
.attr('transform', 'translate(50, 0)')
.call(yAxis);
Now that our graph is labeled, it's finally time to draw some data:
chart.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => x(d.region))
.attr('y', d => y(d.meanPctTurnout))
.attr('width', x.bandwidth())
.attr('height', d =>
(window.innerHeight - 50) - y(d.meanPctTurnout));
Okay, there's plenty going on here, but this code is saying something very simple. This is what is says:
- For all rectangles (rect) in the graph, load our data
- Go through it
- For each item, append a rect
- Then, define some attributes to it
Ignore the fact that there aren't any rectangles initially; what you're doing is creating a selection that is bound to data and then operating on it. I can understand that it feels a bit weird to operate on nonexistent elements (this was personally one of my biggest stumbling blocks when I was learning D3), but it's an idiom that shows its usefulness later on when we start adding and removing elements due to changing data.
The x scale helps us calculate the horizontal positions, and bandwidth() gives the width of the bar. The y scale calculates vertical positions, and we manually get the height of each bar from y to the bottom. Note that whenever we needed a different value for every element, we defined an attribute as a function (x, y, and height); otherwise, we defined it as a value (width).
Let's add some flourish and make each bar grow out of the horizontal axis. Time to dip our toes into animations!
Modify the code you just added to resemble the following; I've highlighted the lines that are different:
chart.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', d => x(d.region))
.attr('y', window.innerHeight - 50)
.attr('width', x.bandwidth())
.attr('height', 0)
.transition()
.delay((d, i) => i * 20)
.duration(800)
.attr('y', d => y(d.meanPctTurnout))
.attr('height', d =>
(window.innerHeight - 50) - y(d.meanPctTurnout));
The difference is that we statically put all bars at the bottom (window.innerHeight - 50) with a height of zero and then entered a transition with .transition(). From here on, we define the transition that we want.
First, we wanted each bar's transition delayed by 20 milliseconds using i*20. Most D3 callbacks will return the datum (or whatever datum has been bound to this element, which is typically set to d) and the index (or the ordinal number of the item currently being evaluated, which is typically i) while setting the this argument to the currently selected DOM element. If we were using, say, classes, this last point would be fairly important; otherwise, we'd be evaluating the rect SVGElement object instead of whatever context we actually want to use. However, because we're mainly going to use factory functions for everything, figuring out which context is assigned to this is far less of a worry.
This gives the histogram a neat effect, gradually appearing from left to right instead of jumping up at once. Next, we say that we want each animation to last just shy of a second, with .duration(800). At the end, we define the final values for the animated attributes--y and height are the same as in the previous code--and D3 will take care of the rest.
Save your file and refresh. If everything went according to plan, you should have a chart that looks like the :
According to this, voter turnout was fairly high during the EU referendum, with the south-west having the highest turnout. Hey, look at this; we kind of just did some data journalism here! Remember that you can look at the entire code on GitHub at http://github.com/aendrew/learning-d3-v4/tree/chapter1 if you didn't get something similar to the preceding screenshot.
We still need to do just a bit more, mainly using CSS to style the SVG elements.
We could have just gone to our HTML file and added CSS, but then that means opening that yucky index.html file. Also, where's the fun in writing HTML when we're learning some newfangled JavaScript?
First, create an index.css file in your styles/ directory:
html, body {
padding: 0;
margin: 0;
}
.axis path, .axis line {
fill: none;
stroke: #eee;
shape-rendering: crispEdges;
}
.axis text {
font-size: 11px;
}
.bar {
fill: steelblue;
}
Then, just add the following line to the top of main.js:
import * as styles from 'styles/index.css';
I know. Crazy, right? No <style> tags needed!
It's worth noting anything involving
require() or
import that isn't a JS file is the result of a Webpack loader. Although the author of this text is a fan of Webpack, all we're doing is importing the styles into
main.js with Webpack instead of requiring them globally via a
<style> tag. This is cool because, instead of uploading a dozen files when deploying your finished code, you effectively deploy one optimized bundle. You can also scope CSS rules to be particular to when they're being included and all sorts of other nifty stuff; for more information, refer to
https://github.com/webpack-contrib/css-loader.
Looking at the preceding CSS, you can now see why we added all those classes to our shapes. We can now directly reference them when styling with CSS. We made the axes thin, gave them a light gray color, and used a smaller font for the labels. The bars should be light blue. Save this and wait for the page to refresh. We've made our first D3 chart:
I recommend fiddling with the values passed to .width and .height to get a feel of the power of D3. You'll notice that everything scales and adjusts to any size without you having to change other code. Smashing!