Thursday, September 15, 2016

Angry man figures out how to make a bar chart in D3 v4

Oh, hey! apparently D3.js is really amazing because

Modifying documents using the W3C DOM API is tedious: the method names are verbose, and the imperative approach requires manual iteration and bookkeeping of temporary state.

Yeah, sure. Whatever. I need to draw a bar chart. Great. Super boring. Whatever. Is there a tutorial for that?

Quick Google search on D3 bar chart tutorials

Oh! Look at that. Fucking marvellous. A tutorial specifically about bar charts. Made my day. Wait. WTF is this? sideways barcharts? Who in their right mind does that? Useless. Oh. It's in three parts. skips to the end Cool. I'll copy/paste this into a browser and see what's up...

d3.scale is undefined

What? Code that doesn't work? What the fuck is wrong with these people? And why are they loading in a tsv? I'm getting my data from a server, numbnuts. Show me how to do it with json.

Heads over to the tutorial section of the official documentation

Tutorials may not be up-to-date with the latest version 4.0 of D3

Just great. No notes to tell me which work with the current version of the software. Fuckin A. Guess I'll write my own.

This is the data I'm working with. Pretty simple stuff.

data.weekday_visits = 
{
    'Monday':132,
    'Tuesday':140,
    'Wednesday':159,
    'Thursday':129,
    'Friday':158,
    'Saturday':132,
    'Sunday':150,
}

Seems pretty reasonable, right? The first thing I need to do is change it suit D3 almighty.

var weekdayVisits = [];
var days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
var maxVisits = 0;

for(var i = 0; i < days.length; i++)
{
    if(data.weekday_visits[days[i]] > maxVisits)
    {
        maxVisits = data.weekday_visits[days[i]];
    }

    weekdayVisits.push({'day': days[i], 'value': data.weekday_visits[days[i]]});
}

That's right. Now it's an array of dictionaries. Amazing.

Now I need to figure out how to draw a fucking chart. What does D3 even do, beyond the marketobabble they spout on the homepage? Apparently it can manipulate SVG into a bar chart, so the out of date tutorial told me.

<svg id="weekdayChart" width="400" height="400"></svg>

There, least I can do. So. Now what? Let's try drawing an axis. Should be easy, right? Good fucking luck. I've never used this library in my life. It's going to be a nightmare.

var chart = document.getElementById('weekdayChart');
var yAxis = d3.axisLeft(maxVisits); //remember maxVisits from above? I bet you thought I was crazy. Yep, but also we need this. This is for the "scale" parameter
yAxis(chart); //That's all I have to do to add an axis to a chart? Nice!

Let's give that a test...

TypeError: n.domain is not a function

Bollocks. What is n? How do I set its domain? Fucksake. What does the API say about axis domains? Fuck all. Great. Thanks a million for this really easy to use library. Of course searching brings up everything for version 3 and nothing for version 4 so that's as good as punching myself in the balls for half an hour. OK, so the out of date tutorial says something like: y.domain(some function) It's worth a shot.

yAxis.domain(function(d){ return d.value; });

Aaaaand eval...

TypeError: yAxis.domain is not a function

Well, at least this is consistent with the API. What next? OK, what if scales are a type. Maybe that's what they mean in the API. OK, clicking on a few links seems to confirm that, although it's not clear what scale I should use. I'm guessing Linear, what's the documentation say? Ah! They mention domains! Finally. Also something called ranges, which also appeared in the out of date tutorial. I'm sure this will fuck me up, too.

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]));

A NOPE!

TypeError: g.selectAll is not a function

More mysterious error messages about objects I know nothing about. SO HELPFUL! OK, OK. I'll stop using the .min version.

TypeError: selection.selectAll is not a function

Oh yeah. So much more helpful now. I haven't selected anything, so I have no idea what that's about. OK, I'll dig around in the code like a monkey scrounging for shit. Apparently something called context needs to have a method called selectAll or have a member called selection. I'll change things up a bit then.

var chart = d3.select('#weekdayChart');

Well, now it's running, but it doesn't... wait... what's that black spot? It's not a dead pixel! I think we've got something. I guess I need to give the scale a range now, so it'll stop being too tiny. I'll take my cue from the out of date tutorial (why am I doing this to myself?)

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([400, 0]));

Well hey! Look at that. I got a black line. Maybe I can do the same for the x axis.

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([0, 400]));
var xAxis = d3.axisBottom(d3.scaleBand().domain(days).range([0, 400]));

OK. The x axis looks a little dumb, but it's on the way to looking right, but now my yaxis is tiny again, or maybe completly invisible. Why the fuck has that happened?

Looking over the out of date tutorial, apparently I need a g for each axis. Fine.

var yAxisHolder = chart.append('g');
var xAxisHolder = chart.append('g');

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([0, 400]));
var xAxis = d3.axisBottom(d3.scaleBand().domain(days).range([0,400]));

yAxis(yAxisHolder);
xAxis(xAxisHolder);

OK. Now the y axis is back, buuuuuuuuuuuut the x axis is at the top. What the fuck? It's called axisBottom, so why's it at the top? Gahhhhhh! Looking at the out of date tutorial, the x axis holder needs to be translated to the bottom of the SVG. Great. But not completely to the bottom, because that hides the labels. Also, that means I need to change the range of the y axis to match the translation.

var xAxisHolder = chart.append('g').attr("transform", "translate(0," + 380 + ")");

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([0, 380]));

It turns out the whole axisBottom vs axisTop determines whether the labels will be above or below the line. That is actually mentioned in the API, so fine, but also, that's a terrible name. Of course, adding numbers to the y axis means more fiddling with where everything is, but at least it's easy to add the numbers (see the ticks at the end of the axis declaration).

var yAxisHolder = chart.append('g').attr("transform", "translate(30,10)");;
var xAxisHolder = chart.append('g').attr("transform", "translate(30,390)");

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([0, 380])).ticks();
var xAxis = d3.axisBottom(d3.scaleBand().domain(days).range([0,370]));

And I'll mage the SVG bigger so it will all fit.

<svg id="weekdayChart" width="400" height="410"></svg>

Huh. The number are going from 0 at the top. Why? I think I know this. This is the range thing. I have to put the numbers backwards. That's in the out of date tutorial.

var yAxis = d3.axisLeft(d3.scaleLinear().domain([0, maxVisits]).range([380,0])).ticks();

Yeah, that's fixed it.

OK. Now I want some bars. From the out of date tutorial, it looks like I want to add rect objects to the chart. This might actually work... doesn't hold breath

chart.selectAll(".bar")
    .data(data)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", function(d) { return xScale(d.day); })
    .attr("y", function(d) { return yscale(d.value); })
    .attr("height", function(d) { return 390 - yScale(d.value); })
    .attr("width", xScale.rangeBand());

Now we get the error:

TypeError: xScale.rangeBand is not a function

The API seems to suggest that bandwidth is the correct function name.

chart.selectAll(".bar")
    .data(weekdayVisits)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", function(d) { return xScale(d.day); })
    .attr("y", function(d) { return yScale(d.value); })
    .attr("height", function(d) { return 390 - yScale(d.value); })
    .attr("width", xScale.bandwidth());

Well, the error is gone, and I do see some bars but they are way too big, and off centre. Let me just hack at these position values...

chart.selectAll(".bar")
    .data(weekdayVisits)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", function(d) { return 40 + xScale(d.day); })
    .attr("y", function(d) { return 10 + yScale(d.value); })
    .attr("height", function(d) { return 380 - yScale(d.value); })
    .attr("width", xScale.bandwidth()-20);

Amazing. Now I have a bar chart. It only took two days of hacking to get here. Thanks to Mike Bostock for the out of date tutorial, and to the D3.js team for the API. Both gave help like panning for gold in a river. A lot of grit but some nuggets are in there if you've got two days to spare.

See below for the full code:

HTML

<svg id="weekdayChart" width="400" height="410"></svg>
<script type="text/javascript" src="d3.js"></script>
<script type="text/javascript" src="blogpost.js"></script>

CSS

.bar { fill: steelblue; }

JavaScript

data.weekday_visits = 
{
    'Monday':132,
    'Tuesday':140,
    'Wednesday':159,
    'Thursday':129,
    'Friday':158,
    'Saturday':132,
    'Sunday':150,
}

var weekdayVisits = [];

var days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];

var maxVisits = 0;

for(var i = 0; i < days.length; i++)
{
    if(data.weekday_visits[days[i]] > maxVisits)
    {
 maxVisits = data.weekday_visits[days[i]];
    }

    weekdayVisits.push({'day': days[i], 'value': data.weekday_visits[days[i]]});
}

var chart = d3.select('#weekdayChart');

var yAxisHolder = chart.append('g').attr("transform", "translate(30,10)");;
var xAxisHolder = chart.append('g').attr("transform", "translate(30,390)");

var yScale = d3.scaleLinear().domain([0, maxVisits]).range([380,0]);
var xScale = d3.scaleBand().domain(days).range([0,370]);

var yAxis = d3.axisLeft(yScale).ticks();
var xAxis = d3.axisBottom(xScale);

yAxis(yAxisHolder);
xAxis(xAxisHolder);

chart.selectAll(".bar")
    .data(weekdayVisits)
    .enter().append("rect")
    .attr("class", "bar")
    .attr("x", function(d) { return 40 + xScale(d.day); })
    .attr("y", function(d) { return 10 + yScale(d.value); })
    .attr("height", function(d) { return 380 - yScale(d.value); })
    .attr("width", xScale.bandwidth()-20);

SVG output

020406080100120140MondayTuesdayWednesdayThursdayFridaySaturdaySunday

1 comment:

T-bone said...

Thanks for the notes man, apparently I also needed a "g" which is what I was missing to get a functioning barchart.
Cheers